秋招笔记汇总

这个周末我会争取将所有的秋招笔记汇总在这里,之前的内容过于分散了,不方便查看也不方便复习。

我们从C++开始:

C++

以下代码的输出是什么?

cpp 复制代码
unsigned int a = 10;
int b = -20;
printf("%d", a + b > 0 ? 1 : 0);

这里其实主要涉及的问题就是一个有符号数和无符号数计算的问题。当有符号数与无符号数混合运算时,有符号数会隐式转换为无符号数。 所以在示例中对于 b = -20,其补码表示(假设 32 位系统)为 4294967276(即 UINT_MAX - 20 + 1)。

switch语句中case的取值可以是哪些类型?

switch的case只能是枚举变量和整型常量(整型常量是编译期确定的固定整数值 )以及其他可以隐式转换成整型常量的类型(intcharshortlong及其unsigned版本)。

以下代码的问题是什么?如何修正?

cpp 复制代码
char* create_str() { char str[] = "Hello"; return str; }
int main() { char *s = create_str(); printf("%s", s); }

这段代码存在悬空指针 的问题:str是create_str()函数的返回值,其生命周期只有在函数执行时,当我们使用指针指向局部变量时就会出现悬空指针的问题,也就是指针指向了已经被释放的对象。怎么解决这个问题呢?

答案是用static关键字修饰这个局部变量:

cpp 复制代码
char* create_str() {
    static char str[] = "Hello";  // 静态存储期,生命周期持续到程序结束
    return str;
}

数组名和指针的区别是什么?以下代码的输出是什么?

cpp 复制代码
int arr[5] = {1,2,3,4,5};
int *ptr = (int*)(&arr + 1);
printf("%d %d", *(arr + 1), *(ptr - 1));

数组名确实在某些特定情况下会退化成指针来使用,但是本质上数组名和指针还是不同:

最根本的区别在于数组名是数组的标识符,指针只是其中一种表示数组的方式。

上述的代码中&arr取的是整个数组的地址,所以&arr+1指向的是整个数组地址的下一个地址,而arr就是数组名退化成指向第一个元素指针的用法,所以arr+1指向的是arr数组的第二个元素也就是2,ptr-1指向整个数组的最后一个元素也就是5(如果直接解引用ptr会导致未定义行为)。

#pragma once#ifndef的区别?

二者都是为了防止头文件被重复包含的预处理命令,但是底层的原理不同。ifndef是C/C++标准的预处理指令,所有符合标准的编译器均支持,兼容性极强,但是需要指定具体的宏名,比如#ifndef MY_HEADER_H;pragma once则是无需定义宏名,仅需一行指令。

有哪些头文件重复引用的情况?

相同源代码文件中多次直接包含同一头文件:

cpp 复制代码
// main.c
#include "myheader.h"  // 第一次包含
#include "myheader.h"  // 第二次包含

相同源代码文件中多次间接包含同一头文件:

cpp 复制代码
// a.h
#include "common.h"  
 
// b.h
#include "common.h"  
 
// main.c
#include "a.h"
#include "b.h"  // 间接包含两次common.h

头文件之间循环嵌套:

cpp 复制代码
// a.h
#include "b.h"  
 
// b.h
#include "a.h"  

相同的头文件多次包含会导致重定义问题

如何声明和使用函数指针?以下代码的输出是什么?

cpp 复制代码
int (*funcs[2])(int, int) = {add, sub};
printf("%d", funcs[1](10, 5));

使用的话就只需要在对应的函数参数列表中放入函数指针之后就可以通过指针具体调用函数了。代码中我们是一个包含两个函数指针的数组,这两个函数指针指向的函数都接受两个Int类型的数据作为参数,这两个函数分别是add和sub,虽然看不到具体的函数实现,假设这两个函数的功能就是返回两个Int参数的和和差的话,那么printf的就是10和5的差也就是5。

什么是常量指针和指针常量?

首先从左往右读,第一行先有const再有*,那就是常量指针,反之第二行是指针常量。常量指针就是指向常量的指针,指针常量就是指针本身是常量。

dynamic_cast的底层原理?

dynamic_cast 的底层原理核心在于 ​运行时类型信息(RTTI)​ ​ 和 ​虚函数表(vtable)​ ​ 的协同工作。当对一个多态类型(即包含虚函数的类)的指针或引用使用 dynamic_cast 时,编译器会通过该对象内部的虚指针(vptr)找到其虚函数表。虚函数表中存储着一个指向 type_info 对象的指针,该对象包含了类的实际类型及其继承关系信息 。

shared_ptr的底层原理?

shared_ptr 的底层原理核心在于引用计数控制块机制。它本质上是一个模板类,内部包含两个指针:一个指向被管理的对象,另一个指向一个动态分配的控制块。

控制块是实现自动内存管理的核心,其中保存着关键的元数据,主要包括共享引用计数 ​(strong count,记录有多少个shared_ptr正持有该对象)和弱引用计数 ​(weak count,记录有多少个weak_ptr在观察该对象),此外还可能包含删除器(deleter)和分配器(allocator)等信息。

当一个新的shared_ptr通过拷贝构造或赋值操作与另一个shared_ptr指向同一对象时,它们会共享同一个控制块 ,并通过原子操作 将共享引用计数加1。当某个shared_ptr被销毁或重置时,其析构函数会将共享引用计数减1。一旦共享引用计数减至0,便表示已没有任何shared_ptr拥有该对象,此时会调用指定的删除器(默认使用delete)释放所管理的对象内存。如果此时弱引用计数也为0,则会进一步释放控制块本身占用的内存。

使用 std::make_shared 函数通常效率更高,因为它可以在一次内存分配中同时为对象本身和控制块分配连续的内存空间 ​。这种设计使得shared_ptr能够安全地在多个所有者之间共享对象所有权,并自动管理生命周期,有效防止内存泄漏,但需要注意可能出现的循环引用问题,这时可以通过结合weak_ptr来打破循环。

那具体来说怎么使用weak_ptr呢?

weak_ptr 必须由一个已有的 shared_ptr 初始化或赋值,它不会增加对象的引用计数。

cpp 复制代码
std::shared_ptr<MyClass> sharedPtr = std::make_shared<MyClass>();
std::weak_ptr<MyClass> weakPtr1(sharedPtr); // 通过shared_ptr构造
std::weak_ptr<MyClass> weakPtr2 = sharedPtr; // 通过赋值操作

内存对齐是什么?为什么要内存对齐?

内存对齐是计算机程序中数据在内存中存储时,其起始地址必须为某个特定值(通常是2、4、8等2的幂次方)的整数倍的规则。这主要是为了提升访问效率,因为CPU读取内存时通常以固定大小的块(如4字节或8字节)进行操作,如果数据按此对齐,一次访问即可获取;若未对齐,则可能需要两次访问和额外的拼接操作,降低性能。此外,某些硬件架构(如ARM)对数据对齐有严格要求,未对齐的访问可能导致硬件异常。虽然编译器通常会自动处理对齐,但理解其原理有助于优化内存布局(如调整结构体成员顺序以减少填充字节)和性能。

stack和queue的底层实现是什么?

stack和queue的底层都是基于deque实现的,stack通过关闭deque关于队列尾部相关操作的API实现而queue则通过关闭push_front和pop_back实现。

如何理解对象切片?如何避免?

对象切片是 C++ 面向对象编程中一个需要留神的现象,简单说就是当一个派生类对象被赋值或传递给一个基类对象时,派生类独有的部分(包括数据和成员函数)会被"切掉",只留下基类部分。这会导致信息丢失和行为异常。

避免对象切片的关键在于使用指针或引用来操作对象。指针和引用本质上传递的是地址,不会创建新的基类对象副本,从而保留了对象的完整类型信息。

类中可以定义引用数据成员吗?

答案是可以的,但是这个引用类型的变量必须用外部的引用变量初始化 才可以,且它必须在构造函数的初始化列表中完成初始化。这是因为引用在创建时必须绑定到一个已存在的对象上,并且这种绑定关系在其生命周期内不可更改。

STL的分配器Allocator是什么?有何作用?

STL的分配器负责给STL中的容器分配和管理空间,这里有两个二:一是STL的分配空间有两级,对于较小的内存需求,分配器直接分配内存池中的内存块,而对于较大的内存需求,分配器会重新去堆中申请内存;二是STL分配器在分配/释放内存时,会分为两阶段执行,分配时先调用allocate()方法执行申请内存,然后再执行对象的构造函数,释放时会先执行析构函数,再执行deallocate()释放内存。

STL迭代器如何删除元素?

对于顺序容器来说,当我们通过迭代器删除某个元素时,后序的迭代器会全部失效,所有后面的迭代器会往前移动一位,然后返回指向下一个元素的迭代器;对于无序容器来说,erase会返回指向下一个元素的迭代器;对于有序关联容器如map,底层是基于红黑树实现的自动维护有序的容器,所以只需要递增迭代器即可;对于list,直接返回下一个元素的迭代器即可。

sort的原理?

一般情况下使用快速排序,针对较少的数据时使用插入排序,当快速排序递归深度过大时转换成堆排序。

堆排和快排都不稳定,那为什么拿堆排作为快排的替代呢?

这么做主要是基于堆排序在最坏情况下的时间复杂度有保障 ,并且它是原地排序 。这里得梳理清楚一个概念就是:排序算法的稳定性 特指如果待排序的序列中存在两个或两个以上值相等的元素 ,在排序之后,这些相等元素的相对先后顺序与排序前的原始序列中的顺序保持一致,那么这个排序算法就是稳定的;否则,就是不稳定的。

快速排序和归并排序的时间复杂度?稳定性?

快速排序的平均时间复杂度是O(n log n),最坏情况是O(n²),不稳定排序;归并排序的时间复杂度是O(n log n),最坏情况也是O(n log n),是稳定排序;快速排序通过选择基准元素分区,相同元素可能改变相对位置,而归并排序通过分治合并,相同元素保持原有相对顺序,所以归并排序是稳定的。

为什么sort使用快速排序而不是归并排序?

快速排序是原地排序算法,不需要额外的O(n)空间,而归并排序需要额外的辅助数组;快速排序的缓存局部性更好,对CPU缓存更友好;虽然快速排序不稳定,但大多数应用场景不需要稳定性。

仿函数和匿名函数有何关系?

事实上,C++中的匿名函数(Lambda表达式)本质上就是一种方便快捷地创建仿函数的语法糖

当你编写一个Lambda表达式时,编译器在背后会自动为你生成一个匿名的、重载了函数调用运算符 operator()的类(即一个仿函数)。你使用的捕获列表则会转化为这个匿名类中的成员变量,并通过其构造函数进行初始化。因此,尽管Lambda在语法上更为简洁直观,但其底层机制和仿函数是相通的,最终都会生成一个可以被调用的函数对象。

auto和decltype有何区别?

auto是依赖于表达式的值来推导类型而decltype则是在编译期就根据表达式判断类型,auto只保留数据类型而不保留其他类型的修饰符而decltype是完整保留的,decltype在编译期时执行而auto运行时推导。

malloc的底层原理?

通过调用操作系统提供的 brk(或 sbrk)和 mmap 等系统调用来管理内存。malloc 并非直接分配物理内存,而是先在进程的虚拟地址空间中预留一块区域(通常是堆区)。对于小内存申请(通常小于 128KB),它会通过移动堆顶指针(brk)来扩展堆,并维护一个空闲内存块链表,分配时从链表中寻找合适大小的块,可能进行分割,剩余部分放回链表;释放(free)时则标记为空闲并尝试合并相邻空闲块。对于大内存申请(通常大于等于 128KB),则使用 mmap 建立匿名内存映射,分配独立的内存区域,释放时直接归还操作系统。

如何理解brk和mmap?

它们都是Linux系统中用于内存管理的系统调用。`brk`通过调整程序的数据段边界来分配内存,只能分配连续的内存空间,适合小内存分配,但容易产生内存碎片;`mmap`通过内存映射来分配内存,可以分配大块内存,支持文件映射,内存可以独立释放,更灵活但开销较大。`brk`主要用于堆内存管理,`mmap`既可用于堆内存也可用于文件映射。现代的内存分配器(如malloc)通常结合使用这两种方式:小内存用brk,大内存用mmap,以获得更好的性能和内存利用率。

有哪些new方法?

如图:

plain new就是我们平时用的new,nothrow new就是new失败后不再抛异常而是返回nullptr,placement new也称为定位new,是在给定的地址上执行构造函数,new[]则是在堆上分配数组对象。

如何理解段错误?

段错误的本质是内存访问越界,也就是访问了不允许访问的区域,又或者是栈溢出或者是new一次之后delete多次。栈溢出的常见诱因包括无限递归,局部变量过大或者是栈的缓冲区溢出。

动态库和静态库?

动态库和静态库是两种不同的代码复用和链接方式。静态库(如 .a.lib 文件)在程序编译链接时 ,其代码会被完整地复制到最终的可执行文件中;而动态库(如 .so.dll 文件)在编译时仅记录引用信息,其代码在程序运行时才被加载到内存。

如何实现一个在main函数之前和之后执行的函数?

使用 __attribute__((constructor))__attribute__((destructor)) 让函数在main前后执行。

结构体的内存大小怎么看?

结构体的内存大小并非其所有成员大小的简单相加,而是由内存对齐规则决定的。

其核心规则可归纳为三点:

  1. 起始位置:结构体的第一个成员始终在偏移量为0的地址处。
  2. 成员对齐 :其他每个成员的起始地址必须是 其自身大小编译器默认对齐数 两者中较小值的整数倍。若不满足,编译器会在前一个成员后插入填充字节以满足要求。
  3. 整体对齐 :整个结构体的总大小必须是 所有成员中最大对齐数 的整数倍。若不满足,编译器会在最后一个成员后填充字节直至满足条件。

一个非常实用的优化技巧是:​在声明成员时,按照类型尺寸从大到小或从小到大排列,可以有效减少填充字节的数量,优化内存占用​。

如何理解单一职责原则?

其核心思想是:​一个类(或模块、方法)应该有且仅有一个引起它变化的原因​ 。这意味着每个类都应该专注于单一的功能或职责,从而降低不同职责之间的耦合度,提高代码的内聚性。

对指针和引用使用sizeof的结果如何?

对指针使用 sizeof,得到的是指针变量本身所占用的内存大小 ,这取决于系统架构(例如在 64 位系统上通常为 8 字节,32 位系统上为 4 字节),而与指针所指向的数据类型和内容无关。​而对引用使用 sizeof,得到的是被引用对象本身类型的大小 ,因为引用只是某个已存在对象的别名,它本身不占用独立的存储空间,sizeof 操作会直接查询其目标对象的类型信息。

final和override关键字的作用是什么?

C++里用 final 修饰类,表示该类不能被继承;用 final 修饰虚函数,表示该函数在当前类中已经"封终",派生类不能再重写该虚函数。final 只能用于类和虚函数(含虚析构),用于非虚函数是非法的;可与 override 一起使用(顺序不限),并有助于编译器做去虚化优化。

override 用在派生类的虚函数上,声明"这是对基类同名虚函数的覆盖",并让编译器强制校验签名完全匹配(否则报错);final 用来"封死扩展",加在虚函数上表示此函数到本类为止不能再被重写,加在类上表示该类不能被继承;二者可组合写成 "override final",既确认确实在重写,又禁止后续再重写;简言之:override是"意图校验",final是"继承/重写限制"。

如何理解命名遮蔽?

基类函数非虚时,派生类若声明同名同签名成员,只是"名字隐藏"并重新定义了一个新函数;对派生对象调用命中派生版本,经由基类指针/引用调用仍静态绑定到基类版本,需用限定名 Base::f() 指明基类;基类函数为虚时,派生类写出同名同签名成员(即使不写 override)就是"重写",经由基类指针/引用会虚派发到派生,写 override 只是做匹配校验;一旦签名不完全相同,就不会重写而是触发"名字隐藏",把基类该名字下的所有重载都遮蔽,需要 using Base::f; 把基类重载带回,或用 Base::f(args) 指名调用;若想禁止派生重写,则在基类虚函数上标注 final。

比如:

volatile、mutable和explicit关键字的用法?

volatile 是"别优化掉、每次都去真内存"的读写限定(用于硬件寄存器/异步修改,不是并发同步);mutable 是"即使对象是 const,这个成员也允许改"的成员修饰(常做缓存,不涉及线程安全);explicit 用在构造函数/转换运算符上,禁止隐式转换,只允许显式构造/转换,避免误用与歧义。

这里插入一个知识点:lambda表达式中的值捕获的值可以修改吗?

默认情况下,通过值捕获(Value Capture)的变量在 lambda 表达式内部是 不能 修改的 。但如果你在 lambda 的参数列表后加上 mutable 关键字,就可以修改这个变量的副本 了,并且这个修改不会影响外部的原始变量。

这是因为当你使用值捕获(例如 [x])时,lambda 表达式会创建该变量的一个副本。但默认情况下,这个副本在 lambda 函数体内被视为常量,因此尝试修改它会触发编译错误。

如何理解委托构造函数和转换构造函数?

委托构造函数是C++11引入的特性,允许一个构造函数调用同类的其他构造函数来完成初始化,避免代码重复:

cpp 复制代码
class MyClass {
private:
    int x, y, z;
public:
    MyClass() : MyClass(0, 0, 0) {}  // 委托给三参构造函数
    MyClass(int a) : MyClass(a, 0, 0) {}  // 委托给三参构造函数
    MyClass(int a, int b) : MyClass(a, b, 0) {}  // 委托给三参构造函数
    MyClass(int a, int b, int c) : x(a), y(b), z(c) {}  // 实际执行初始化的构造函数
};

转换构造函数是接受一个参数的构造函数,可以将该参数类型隐式转换为类类型,如接受int参数的构造函数可以将int转换为类对象:

cpp 复制代码
class MyClass {
private:
    int value;
public:
    MyClass(int v) : value(v) {}  // 转换构造函数,可以将int转换为MyClass
    explicit MyClass(double d) : value(static_cast<int>(d)) {}  // explicit禁止隐式转换
};
 
// 使用示例
MyClass obj1 = 10;  // 隐式转换,调用转换构造函数
MyClass obj2(20);   // 显式调用
// MyClass obj3 = 3.14;  // 错误,explicit禁止隐式转换
MyClass obj4(3.14); // 正确,显式调用

如何理解模板的特化?

模板特化是为特定类型提供定制化实现,分为全特化(为具体类型提供完全不同的实现)和偏特化(为部分类型参数提供特殊实现)。全特化如template<> class MyClass<int>,偏特化如template<typename T> class MyClass<T*>。特化允许为特定类型优化性能、处理特殊情况或提供不同的行为,编译器会优先选择最匹配的特化版本。特化是模板元编程的重要工具,用于类型分发和编译时优化。

cpp 复制代码
// 全特化示例
template<typename T>
class MyClass {
public:
    void show() { cout << "General template" << endl; }
};
 
template<>
class MyClass<int> {
public:
    void show() { cout << "Specialized for int" << endl; }
};
 
// 偏特化示例
template<typename T>
class Container {
public:
    void process() { cout << "General container" << endl; }
};
 
template<typename T>
class Container<T*> {
public:
    void process() { cout << "Pointer container" << endl; }
};
 
template<typename T, typename U>
class Pair {
public:
    void display() { cout << "General pair" << endl; }
};
 
template<typename T>
class Pair<T, int> {
public:
    void display() { cout << "Pair with int" << endl; }
};
 
// 函数模板特化
template<typename T>
T max(T a, T b) { return a > b ? a : b; }
 
template<>
const char* max(const char* a, const char* b) {
    return strcmp(a, b) > 0 ? a : b;
}

全特化完全脱离模板,不再使用任何模板参数,直接为具体类型提供实现;偏特化保留部分模板参数,但在类的定义中加入特定的类型模式或约束,比如指针类型、特定类型组合等。全特化完全替换原模板,偏特化提供更具体的版本但仍有模板参数。编译器选择时,偏特化比原模板更具体,全特化比偏特化更具体。

const可以修饰静态成员函数吗?

在C++中,const关键字不能直接修饰静态成员函数。静态成员函数属于类本身而不是类的实例,因此它们没有this指针,也就没有隐含的对象状态需要保护。const修饰符主要用于非静态成员函数,表示该函数不会修改类的非静态成员变量。静态成员函数本身就不能访问非静态成员变量,所以不需要const修饰符来保证不修改对象状态。

如何实现单链表的快排?

这个过程可以概括为以下几个关键步骤:

  1. 选择基准与初始化 ​:通常选择待排序区间(以 startend 标记)的头节点值作为基准(key)​ 。初始化两个指针 slowfastslow 初始指向 startfast 初始指向 start->next

  2. 遍历与交换 ​:fast 指针从头到尾遍历当前区间。当 fast 指向的节点值小于 基准值 key 时,​先将 slow 指针向后移动一位,然后交换 slowfast 所指节点的值 ​。这个操作的核心目的是将小于基准的元素"归拢"到链表的左侧。无论是否交换,fast 指针都会继续向后移动,直到遍历完整个区间。

  3. 确定基准最终位置 ​:当 fast 指针遍历到 end 时,所有小于基准值的节点都已通过交换被置于 slow 指针及其左侧的区域。此时,​交换头节点(start)和 slow 指针所指节点的值。这样一来,基准值就被放置在了其最终的正确位置上,并且满足:其左侧所有节点的值都小于它,右侧所有节点的值都大于等于它。

  4. 递归排序 ​:以 slow 指针现在的位置为界,将链表分为左右两个子区间(startslow 的前一个节点,以及 slow->nextend),然后递归地对这两个子区间重复上述过程,直到每个区间只剩一个节点或为空,排序即告完成。

这种方法之所以高效,是因为它利用了值的交换避免了复杂的指针操作,同时保持了快速排序分而治之的精髓,平均时间复杂度为 O(n log n)。

析构函数中可以调用虚函数吗?可以实现多态吗?

析构函数的执行顺序是从派生类到基类,当执行基类析构函数时,派生类的析构函数已经执行完毕,派生类的成员和虚函数重写版本已经被销毁,此时虚函数表已经部分失效,所以基类析构函数中调用虚函数时只能调用基类自己的版本,无法再调用派生类的重写版本,因为派生类对象的那部分已经被清理掉了,因此析构函数中调用虚函数不会实现多态。

new的对象可以用free来释放吗?

new分配的对象语法上可以用free释放,编译器不会报错,但这是错误的用法,因为free不会调用析构函数可能导致内存泄漏,更重要的是free无法正确识别new分配的内存块信息,可能导致堆损坏、程序崩溃等严重后果,所以必须用delete来释放new分配的对象。

i++与++i的区别,在底层实现上的区别(比如说两者返回的内容究竟是什么)?

i++(后置)返回自增前的值,典型实现是先拷贝当前值到临时对象、对原对象自增、再按值返回临时副本;++i(前置)先对原对象原地自增,并返回对自身的引用。因为返回值语义不同,i++可能产生一次拷贝开销,而++i更高效;除非确实需要旧值,一般优先使用++i。

被free回收的内存立刻返回给操作系统吗?

不会,会被ptmalloc以双链表的形式存储起来,用户下一次申请内存时会从这些内存中返回。

为什么成员初始化列表比传统的在构造函数中定义成员变量快?

成员初始化列表在进入构造函数体之前就"直接构造"各成员(以及先构造基类),可一次性调用目标构造函数;而在构造函数体内赋值相当于"先默认构造/零初始化,再赋值/拷贝/移动",多走一遍工作,非平凡类型(如string/vector/锁/句柄)会多一次分配与拷贝,因而通常更快、也更正确(const成员、引用成员、无默认构造的成员只能用初始化列表)。

C++函数的调用压栈过程?

C++函数的调用压栈过程,本质上是为一次函数调用在内存的栈区准备一个独立的工作环境(栈帧),其核心步骤环环相扣,确保了函数能够独立运行并正确返回。

当函数执行完毕准备返回时,过程则正好相反:释放局部变量空间(mov esp, ebp),恢复调用者的栈基址(pop ebp),最后通过 ret指令从栈中弹出返回地址并跳转回去。至此,本次函数调用所创建的栈帧被完全清理,栈恢复到调用前的状态。

C++允许把临时变量作为函数的返回值吗?

按值返回临时或局部对象是安全的,实际通常发生拷贝省略(RVO/NRVO,C++17在多种情形下保证消除),即便不消除也多为移动而非昂贵拷贝。相反,返回指向临时或局部对象的指针/引用会变成悬垂(未定义行为)。

为什么模版类定义都放在.h文件中?

每个.cpp都会被单独编译成一个"翻译单元",编译期只能看到"自身源码+所包含的头文件文本",看不到其他.cpp里的定义;而模板是在"用到处"实例化,编译器在实例化点必须能看到模板的完整定义,因此通常把模板定义放到头文件(或头文件包含的.tpp/ipp)中。若一定放在.cpp里,就必须配合显式实例化:头文件写extern template声明,某个.cpp提供定义并对所需类型显式实例化。

extern的用法?

extern的本质是声明"外部链接"的实体:在当前编译单元这里只是声明名字与类型,不分配存储,真正的定义(分配存储/给初始值)应在某个.cpp中且全程序仅一次,因此常见写法是头文件里写extern int g;,某个.cpp里写int g = 0;。它不仅用于变量,函数天生就是外部链接,一般不需要写extern;还可配合extern "C"指定C链接以便与C互操作。

一个类的默认成员?

默认构造函数、析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值。

什么时候会使用成员初始化列表?调用过程是什么?

何时使用:优先用成员初始化列表来构造成员与基类,尤其当成员是const、引用、无默认构造、需要特定参数的资源类(string/vector/锁/句柄等)或为避免"先默认构造再赋值"的额外开销与潜在未初始化风险;同理,基类的初始化也应放在初始化列表中而非在构造函数体内赋值。

调用过程:创建对象时按固定顺序执行------先构造虚基类,其次按继承列表从左到右构造非虚基类,再按成员在类中"声明的顺序"构造成员(与初始化列表书写顺序无关,表达式只是提供实参),最后执行构造函数体;成员初始化列表使用"直接初始化",而在构造函数体内写"成员 = 值"属于先(值/默认)构造后赋值,通常会多一次拷贝/移动。

如何理解this指针?

this是非静态成员函数的隐式首参,指向"正在调用该函数的那个对象"(不是指向函数本身),因此可用于访问成员、区分同名变量、把自身指针传给回调或返回this实现链式调用;当成员函数被const修饰时,等价于将this类型变为const Class const,从而禁止修改非mutable成员并禁止调用非常量成员函数;静态成员函数没有this。

基类的虚函数表存储在内存哪个分区?虚函数呢?

虚函数表(vtable)存储在只读数据段(.rodata),包含函数指针数组,由编译器生成且程序运行期间不变;虚函数的代码本身存储在代码段(.text),与普通函数一样。每个多态类通常有一张vtable,所有该类型的对象共享同一张表,对象内的vptr指向对应的vtable。

构造函数和析构函数中可以调用虚函数吗?

可以调用,但不会发生多态:在构造/析构期间,虚函数调用按当前构造/析构阶段的静态类型绑定,不会下派到更派生层。这是因为在构造时,更派生的部分尚未构造完成;在析构时,更派生的部分已经销毁,此时调用更派生的虚函数会访问已销毁的对象,导致未定义行为。因此,构造/析构期间调用虚函数是安全的,但不会产生预期的多态效果。

构造函数和析构函数可否抛出异常?

构造函数可以抛出异常,析构函数不应抛出异常:构造函数抛出异常会触发栈展开,已构造的成员和基类会被正确析构,这是安全的;析构函数抛出异常(特别是在栈展开过程中)会导致std::terminate,因为异常处理机制无法处理这种情况。因此,析构函数应设计为不抛出异常(noexcept),如果析构过程中可能出错,应记录日志或使用其他方式处理,而不是抛出异常。

const和constexpr的区别?

const是一个更灵活的常量修饰符,它修饰的变量或常量可以在编译时确定也可以在运行时确定,只要保证初始化即可,而constexpr是一个更严格的编译时常量修饰符,它修饰的变量或常量必须在编译时能够确定值,必须立即初始化且不能依赖运行时计算。

什么是野指针和悬空指针?

野指针是指从未被正确初始化的指针,它指向的内存地址是随机的垃圾值,从未指向过有效内存。

悬空指针是指曾经指向有效内存,但现在指向的内存已经无效或被释放的指针。

如何理解C++中的重载、重写和隐藏?

重载是在同一个作用域中定义函数名相同但参数列表不同的函数,编译器根据传入的参数类型和数量自动选择最匹配的版本执行,是编译时确定的静态绑定;重写是基于继承关系,父类定义虚函数,子类重新实现该虚函数,通过父类指针调用子类对象时会执行子类的重写版本,实现运行时多态效果;隐藏是指子类定义了与父类同名的普通函数(非虚函数),此时父类的同名函数被隐藏,调用时根据指针类型决定执行哪个函数(父类指针调用父类函数,子类指针调用子类函数),子类的同名函数会覆盖父类的同名函数,无法通过父类指针调用子类的隐藏函数。

浅拷贝和深拷贝的区别?

浅拷贝只复制指针值(内存地址),不复制指针指向的内容,多个对象共享同一块内存,当一个对象修改数据时其他对象也会受影响,适用于所有类型的指针(动态内存、栈内存、全局内存等),性能好但容易导致内存管理问题(如重复释放、悬空指针);深拷贝复制指针指向的内容,为每个对象分配独立的内存空间,对象之间互不影响,内存管理安全但性能较差。

C++中有哪些分配内存的手段?如果我想实现只允许在栈上分配内存的话怎么做?

C++的内存分配方式主要包括栈内存分配(自动分配和释放)、堆内存分配(手动new/delete)、静态内存分配(全局变量、静态变量)和智能指针管理(自动管理堆内存)这四种主要方式;如果要实现只允许在栈上分配内存,可以通过重载或禁用new操作符(使用= delete关键字)来阻止堆分配,或者通过将析构函数设为私有来阻止在堆上创建对象(因为堆分配的对象需要能够调用析构函数),这两种方法都能确保对象只能在栈上创建,提高内存安全性和避免内存泄漏问题。

delete释放内存的时候怎么知道具体的内存大小?

当使用new分配内存时,内存分配器会在实际分配给用户的内存块前面添加一个头部信息,这个头部记录了分配的内存大小、对齐要求等管理信息;当调用delete时,分配器会通过指针偏移计算找到这个头部信息,从中读取原始分配的内存大小,然后释放包含头部在内的整个内存块,所以用户不需要显式指定要释放的内存大小,分配器会自动管理这些信息。

C++将临时变量作为返回值时的处理?

现代C++在按值返回时优先在"调用方的返回值存储位置"就地构造(RVO/强制拷贝省略,C++17对纯右值return T(...)强制生效),若返回的是命名局部变量则尝试NRVO(允许但不强制);若未能就地构造,则依次退化为移动构造、再不行才用拷贝构造,因此整体优先级就是"就地构造 > 移动 > 拷贝",且这一过程发生在调用点的返回槽中而非先生成临时再拷贝。

类如何实现只能静态分配和动态分配?

C++有三种常见存储期------静态存储期(全局/静态变量,程序开始到结束)、自动存储期(俗称"栈上",进入作用域分配、离开作用域自动销毁,发生在运行时)、动态存储期("堆上",用new/delete显式管理,运行时分配);要"只能在栈上/静态分配",做法是禁用堆分配:在类内将operator new/operator new[]声明为= delete(或私有且不提供友元工厂),同时也建议删除对应的delete/delete[]重载来防误用,这样对象只能以自动或静态存储期方式创建;要"只能在堆上分配",做法是禁止自动存储期:将析构函数设为private/protected(外部无法在作用域末尾调用析构,因而不能定义栈对象),并提供公开的工厂(返回std::unique_ptr或std::shared_ptr并内置删除器)来创建与销毁对象。

如何阻止一个类被实例化?

将构造函数设为private/protected(且不提供友元/工厂)可以阻止外部实例化;把类做成抽象类(至少一个纯虚函数未被该类实现)也能阻止直接实例化,只有派生类实现后才能实例化。

什么是RAII?

RAII(Resource Acquisition Is Initialization,资源获取即初始化)是一种C++资源管理范式:在对象构造函数中获取资源(内存、文件句柄、互斥锁、网络连接等),在析构函数中自动释放资源,从而将资源的生命周期与对象作用域绑定,确保无论正常返回还是异常抛出都能确定性释放,提供异常安全与零泄漏;典型例子有std::unique_ptr/std::shared_ptr管理内存、std::lock_guard管理互斥锁、std::fstream管理文件、std::vector管理动态数组。

RAII的要点就是"构造即获得、析构即释放",把资源的生命周期绑定到对象作用域上,从而在正常返回或异常路径下都能自动、确定性地清理资源。

如何释放vector的空间?

clear()只删元素不减容量;想真正归还内存,优先用"重建/交换"------v = std::vector<T>();或std::vector<T>().swap(v),两者都会让v变为空并释放原缓冲(最可靠)。

构造函数和析构函数可以抛出异常吗?

构造函数可以抛出异常,一旦抛出,对象被视为未构造成功,已完成构造的子对象与成员会按逆序自动析构,异常继续向外传播(用RAII确保不泄露资源);析构函数原则上不应抛出异常,且自C++11起析构函数默认noexcept(true),若在栈展开期间(处理另一个异常时)析构函数再抛异常会导致std::terminate,因此若必须报告错误应吞并/记录或提供显式接口返回错误,而非从析构函数抛出;只有在明确声明noexcept(false)且保证不会与栈展开相叠时,才可技术上抛出,但强烈不建议。

"异常栈展开"指抛出异常后,运行时从当前函数向调用者逐层回退调用栈,依次执行已构造对象的析构函数、释放资源,并在每层查找可匹配的catch;如果在栈展开过程中又抛出第二个异常(典型是析构函数或noexcept函数里再抛出,且未被就地捕获),两个异常"相撞",标准规定将立即调用std::terminate终止程序,这也是为何析构函数默认noexcept(true)且不应对外抛异常的原因。

成员列表初始化和类中初始化的区别是什么?

成员列表初始化是在构造函数后面使用冒号语法直接初始化成员变量,它在对象构造时立即执行,效率更高,对于const成员、引用成员和没有默认构造函数的对象成员是必需的,初始化顺序按照成员在类中声明的顺序进行;类中初始化是在类定义中直接给成员变量赋值,它相当于在构造函数中赋值,对于有默认构造函数的成员会先调用默认构造函数再赋值,效率相对较低,但语法更简洁。

哪些情况一定要使用成员初始化列表?为什么?

const成员变量(因为const成员必须在构造时初始化且不能后续赋值)、引用成员变量(因为引用必须在声明时初始化且不能重新绑定)、没有默认构造函数的对象成员(因为无法在构造函数体内调用默认构造函数)、基类成员(因为基类必须在派生类构造前初始化)。

对指针执行++操作具体到地址上来说移动几个字节?

对指针执行++时,地址前进的字节数等于其"所指向类型"的大小:对 T* p,p++ 前进 sizeof(T) 字节(如 char* 前进1、int* 常前进4、double* 前进8、S* 前进 sizeof(S),含对齐带来的填充),与指针本身占用的字节数无关;void* 与函数指针不支持指针算术,若需按字节步进请先转为 char*。

Debug和Release的区别?

Debug通常关闭大部分优化(O0/O1),保留完整调试符号并开启断言与运行时检查(如迭代器/越界/调试堆),因此编译快、运行慢、体积大、便于调试;Release开启高级优化(O2/O3、内联、去死代码等),剥离或最小化符号,定义NDEBUG关闭断言,一般编译慢、运行快、体积小,但更容易暴露未定义行为或竞态等隐藏问题。

静态函数可以定义为虚函数吗?

不可以。静态成员函数不能定义为虚函数,因为虚函数的动态绑定依赖对象的this指针(通过虚函数表按动态类型分派),而静态函数没有this也不属于任何对象实例;因此静态函数既不能virtual、也不能被override(只能发生名字隐藏),同样也不存在"纯虚静态函数"。虚函数仅适用于非静态成员函数。

如何在STL的map和unordered_map的键中使用自定义类型?

在std::map中自定义类型做键需要提供"严格弱序"的比较规则:要么为类型实现operator<,要么在map<Key, T, Compare>里传入自定义比较器(比较器需满足严格弱序,且比较为const、不可修改键);在std::unordered_map中做键需要提供"哈希+相等"规则:要么为类型提供std::hash<Key>特化并实现operator==,要么在unordered_map<Key, T, Hasher, KeyEqual>里传入自定义Hasher与KeyEqual(需满足相等则哈希相同的一致性)。

C++中有几种for循环?

C++严格意义上有两种"for"语法:传统for循环(for(init; cond; step))适合按索引/自定义步进与多变量控制;范围for(range-based for,for(auto ... : range))用于直接遍历容器/数组/初始化列表,写法简洁,读写性取决于使用auto/auto&/const auto&。

传统for提供init/条件/步进三段式控制,便于按索引遍历、任意步长、多变量更新、需要下标的位置访问与复杂循环逻辑;范围for(for(auto ... : range))语法更简洁,直接基于容器的begin/end迭代顺序遍历,默认按值(auto,拷贝)、按引用(auto&,可原地改)、按只读引用(const auto&,只读)决定是否修改元素,但不直接提供下标、步长或多变量控制,想要下标需手动计数器。

vector的元素是存在栈上还是堆上?

std::vector本体只是一个很小的头部,典型包含三样东西:指向元素区的指针、当前元素数 size、容量 capacity。元素区是"一块连续内存",通常由分配器在堆上申请;vector内部就用那个指针指向这块堆内存,所以能做到"本体在栈/堆/静态区取决于你把 vector 放哪儿,而元素在堆上"。当需要扩容时,vector会在堆上申请更大的一块连续内存,搬移已有元素,更新指针、size、capacity。注意标准 std::vector没有小对象优化(SBO),因此元素区不会自动待在栈上;只有使用自定义分配器/容器(如 small_vector)才可能把一小段元素放到对象内部。

inline关键字的用法?

在类定义中直接定义的成员函数默认被视为inline函数,这是C++标准的规定;inline关键字只是对编译器的建议,编译器有权根据函数大小、复杂性等因素决定是否真正内联;过大或包含复杂控制流(如循环、递归)的函数,即使声明为inline,编译器也可能拒绝内联;inline函数的定义通常放在头文件中,以确保在多个编译单元(源文件)中都能看到定义,从而正确内联。

noexcept关键字的作用?

noexcept关键字通过向编译器做出"此函数绝不抛出异常"的强承诺,为编译器提供了关键的优化信息,从而在多个层面提升程序性能。但是如果noexcept修饰的内容抛出了异常程序会立刻终止而不再帧展开调用析构函数了。

如何知道递归函数的时间复杂度?

分析递归函数时间复杂度的关键是确定递归的深度每次递归本身的时间成本 。对于阶乘这个例子,深度是 n,每次成本是 O(1),所以总复杂度就是 ​O(n)​

move函数会将对象的生命周期结束吗?

move是一个模版函数,输入左值输出右值。

std::move本身绝不会结束对象的生命周期,它仅仅是一个执行类型转换的函数模板,其作用是将左值无条件转换为右值引用,从而"允许"编译器在后续操作中调用移动构造函数或移动赋值运算符来转移资源。

被 std::move转换后的对象,其生命周期依然由它原有的作用域和存储期决定。例如,一个栈上的局部变量在被 std::move后,仍然要等到其所在的作用域结束时才会被析构。

然而,​重要且需要注意的是​:由于资源可能已被移走,该对象会进入一个"有效但未定义"(valid but unspecified)的状态。这意味着虽然其析构函数仍会被正常调用以确保生命周期安全结束,但你不应再尝试使用它的值,除非先为其赋予一个新值。

如果类显式声明了拷贝构造函数、拷贝赋值运算符或析构函数,编译器会生成移动操作吗?

不会。在 C++11 及之后的标准中,如果一个类显式声明了拷贝构造函数、拷贝赋值运算符或析构函数中的任何一个,编译器就不会自动生成移动构造函数和移动赋值运算符​。这个规则的设计基于这样的逻辑:当你为类定义了拷贝操作或析构函数,通常意味着该类需要进行一些自定义的资源管理(例如深拷贝、释放资源等)。编译器因此认为,默认生成的按成员移动(逐成员移动)操作可能并不安全或不符合你的预期。

什么是C++的大三律?

C++ 中的"大三律"(Rule of Three)是一条重要的编程准则,它规定:​如果一个类需要显式定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么它很可能也需要同时定义另外两个。这是因为当类管理着资源(如动态内存、文件句柄等)时,编译器默认生成的拷贝操作(浅拷贝)和析构函数可能无法正确管理这些资源的生命周期,容易导致资源泄漏、重复释放或未定义行为等问题。遵循此规则能确保资源被安全地管理。

C++的内存分区中具体来说常量区会存储些什么?代码区呢?

C++ 内存分区中的 ​常量区​(或称只读数据段)主要用于存储程序运行期间不可修改的数据,主要包括字符串字面量​(如 "Hello")、使用 const修饰的全局常量和静态常量​(如 const int globalConst = 10;或 static const int staticConst = 20;),这些数据在程序生命周期内一直存在且具有只读属性,试图修改会导致运行时错误。

当然,我们要特别补充一下,虚表也是存在常量区的。

而 ​代码区​(或称文本段)则专门存放程序编译后的可执行机器指令,即所有函数的二进制代码(包括 main函数和用户自定义函数),该区域同样为只读以防止程序意外修改自身指令,由操作系统管理且在内存中通常仅需一份副本以供共享。

C++中static和const修饰的对象都存在哪里?

在 C++ 中,被 static修饰的对象(包括全局静态变量、局部静态变量和类的静态成员变量)都存储在全局/静态存储区​(具体在 .data或 .bss段),其生命周期贯穿整个程序运行期,由系统在程序结束时释放。

而被 const修饰的对象,其存储位置则取决于其作用域和定义方式:全局 const常量​(包括字符串字面量)通常存储在只读数据段(.rodata)​,其值在程序运行期间不可修改。局部 const常量​(在函数内部定义)通常存储在栈区(Stack)​,其常量性由编译器在编译期检查保证,生命周期与普通局部变量相同。

也就是说,static修饰的对象一定在全局区,而全局常量会存在常量区,局部常量存在栈中。

capitcay(),resize(),reserve(),size()的用法?

在 C++ 的 vector和 string等容器中,size()返回当前容器中实际持有的元素数量;capacity()返回容器在无需重新分配内存的情况下最多能容纳的元素总数,通常 capacity() >= size()以提升插入效率;reserve(n)​仅预分配内存空间​(只增加容量,不改变大小),用于提前预留至少可容纳 n个元素的空间以避免后续操作中的频繁重分配;而 resize(n)​同时改变大小和可能调整容量,它将有效元素数量调整为 n,并根据需要创建或销毁元素,若 n大于当前容量则会自动扩容。简单来说,size管"有多少",capacity管"能装多少",reserve提前"扩容",resize直接"改量"。

说一说strcpy、sprintf、memcpy这三个函数的具体用法?

C++ 中 strcpy、sprintf和 memcpy这三个函数都用于数据复制,但侧重点和用法不同。​strcpy​ 专用于字符串复制,会一直复制到源字符串的结束符 '\0',但不检查目标缓冲区大小,易导致溢出,使用时需确保目标空间足够。​sprintf​ 用于格式化字符串并写入缓冲区,支持类似 printf的丰富格式(如 %d, %f),能将各种类型的数据转换为字符串,同样存在溢出风险,推荐使用更安全的 snprintf。​memcpy​ 则进行原始内存块的拷贝,可复制任意类型数据(如结构体、数组),需指定拷贝的字节数 n,不关心内容也不自动添加 '\0',效率高但不能处理内存重叠(此时应用 memmove)。简言之,复制字符串可用 strcpy(注意安全),构造复杂格式字符串用 sprintf,拷贝任意内存数据用 memcpy。

为什么栈比堆快?

栈之所以通常比堆快,核心在于分配与回收极其简单(只需移动栈指针,O(1)),对象生命周期清晰使编译器能更激进优化(标量替换、寄存器分配),且栈内存连续、局部性好、缓存命中率高,同时每个线程有独立栈无需加锁;相比之下,堆为通用分配需维护复杂元数据与结构、处理碎片与可能的同步,导致分配/回收开销与缓存行为更差。

如何原地实现交换两个数的值?

原地交换两个值常见有三法:最稳妥的是用临时变量(t=a; a=b; b=t),语义清晰、零风险;算术法(a=a+b; b=a-b; a=a-b)不占额外内存但可能溢出、对浮点与带进位类型不安全;异或法(a=a^b; b=a^b; a=a^b)同样不需临时变量、适用于整数且要求a与b指向不同存储位置,否则会把值变为0,也不适用于浮点与指针算术。工程实践中建议直接使用语言提供的交换(如C++的std::swap或Python的a,b=b,a),在开启优化时性能不逊色于异或/算术法,同时更可读、更安全。

C++从源代码转换到可执行文件需要哪些步骤?

预处理把宏展开、头文件包含、条件编译等处理成纯源码(如.i);编译把源码做词法/语法/语义分析并生成中间表示与优化,产出汇编或直接产出可重定位目标文件(.s→.o/.obj,许多编译器直接生成.o/.obj);汇编把汇编指令转成机器码与重定位信息,形成目标文件并包含符号表、节表等;链接将多个目标文件与库(静态.lib/.a、动态.dll/.so)解析未定义符号、做重定位与地址布局,加入启动代码,生成可执行文件(.exe/ELF)。

一个类的内存布局?

类对象内存只包含非静态数据成员与必要的运行时指针(如含虚函数时的vptr),成员按"声明顺序"排布并受对齐与填充影响;有继承时,基类子对象先于派生类自有成员,多重/虚继承可能引入多个vptr或额外偏移信息;成员函数代码在代码段、不占对象空间,静态数据成员也不在对象里(单独存放)。

delete this指针会怎么样?

"delete this"会销毁当前对象并释放其内存,但前提极其严苛:对象必须确实由与之匹配的单个new分配(非栈上、非placement new、非成员内嵌、非new[]数组),且之后绝不能再访问该对象(包括继续执行当前成员函数里任何使用this的语句、返回到调用者后对对象的任何触碰);否则就是未定义行为(悬空指针、双重释放等)。

capitcay(),resize(),reserve(),size()的用法?

在 C++ 的 vector和 string等容器中,size()返回当前容器中实际持有的元素数量;capacity()返回容器在无需重新分配内存的情况下最多能容纳的元素总数,通常 capacity() >= size()以提升插入效率;reserve(n)​仅预分配内存空间​(只增加容量,不改变大小),用于提前预留至少可容纳 n个元素的空间以避免后续操作中的频繁重分配;而 resize(n)​同时改变大小和可能调整容量,它将有效元素数量调整为 n,并根据需要创建或销毁元素,若 n大于当前容量则会自动扩容。简单来说,size管"有多少",capacity管"能装多少",reserve提前"扩容",resize直接"改量"。

C#

我们都说foreach是一个只读循环,如果我们在foreach中尝试对遍历的元素进行修改就会抛出异常,那么让人不禁想问,foreach的底层是什么才会导致这样呢?

具体来说,当你使用 foreach 循环遍历一个集合(如 ArrayList 或 LinkedList)时,编译器会将其转换为使用该集合的 Iterator 对象进行遍历的代码。这个 Iterator 在创建时会记录下集合当前的"状态快照",比如 ArrayList 的迭代器会通过一个名为 expectedModCount 的变量来记录集合被修改次数的期望值(modCount)。在每次通过 iterator.next() 获取下一个元素时,迭代器都会检查集合实际的 modCount 是否与 expectedModCount 一致。如果在循环过程中直接通过集合自身的方法(如 add(), remove())增删了元素,就会改变 modCount 的值,导致下一次迭代时检查失败,从而抛出 ConcurrentModificationException 异常。

如果确实需要在遍历过程中删除元素,安全的方法是使用迭代器自身的 remove() 方法 ​(例如 iterator.remove())。这样做会在删除元素后,同步更新迭代器内部记录的版本号,从而保持一致性,避免异常。

IList接口的用法和意义?

IList 是 C# 中表示有序集合的接口,并且是所有非泛型集合的集合,继承自 ICollection 和 IEnumerable,当然如果需要实现泛型的话还有我们的IList<T>接口,比如我们的List就是实现了IList<T>的数据容器。

C#中的四种委托?

我们知道C#中有四种委托,分别是delegate,event,action和func。

在C#中,委托(Delegate)本质上是一种类型安全的函数指针封装,它允许我们将方法作为参数传递,实现回调函数的效果。委托的核心优势在于类型安全性和灵活性,但它的缺点是在声明它的类外部也可以被直接调用和修改,这可能导致封装性被破坏。为了解决这个问题,C#引入了事件(Event)机制,事件是基于委托实现的,但通过编译器生成的add和remove访问器进行了封装,使得事件只能在声明它的类内部被触发(具体来说就是把委托字段的访问权限修改为private),外部代码只能通过+=和-=操作符来订阅或取消订阅,从而保证了更好的封装性和安全性。Action和Func是.NET框架提供的两种泛型委托,它们通过泛型参数避免了为不同数据类型重复创建相似委托的开销。其中Action用于表示没有返回值的方法(最多支持16个输入参数),而Func用于表示有返回值的方法(最后一个泛型参数表示返回类型)。

C#中的property是什么?

在C#中,属性(Property)的本质是通过get和set访问器对字段进行封装,从而实现对类成员访问权限的精细控制。我们可以通过组合或省略这些访问器来实现四种典型配置:可读可写属性(同时包含get和set)、只读属性(仅包含get)、只写属性(仅包含set)以及完全不可访问的属性(省略访问器或设为私有)。

C#中怎么创建线程?

有这四种创建线程的方式。

C#中的类是单继承还是多继承?

单继承。

C#中的const和readonly和C++的constexpr和const的关系是什么?

C#中的string为什么具有不可修改性?众所周知我们的string在修改时不会直接去修改原对象而是会拷贝一份新的对象之后返回修改后的新对象,然后把旧对象变为可以回收的资源,那么为什么要这样设计呢?

第一个重要的原因就是为了避免使用锁在多线程时避免竞态条件带来的开销;这里还有一个点就是我们的C#的内部是有一个字符串池的,针对字符串常量在C#中对于相同的字符串我们是复用一个对象的(动态生成的字符串会创建独立对象)。

C#的数据类型大体上会分为两个类型:值类型和引用类型,具体区分的方式是什么呢?或者说从哪个角度导致了二者的不同呢?

一开始我以为是根据数据存储的位置来区分的,客观地说也没太大问题,值类型基本存储在栈上而引用类型基本存储在堆上(其实是把堆中的地址存储在栈上,地址指向的内存存储具体的值)。但其实最根本的核心差异是存储数据的方式,值类型就是直接存储数据的,而引用类型则是存储相应的地址,从这个角度来说,C#的引用颇有C++的指针的感觉。

foreach会产生比较大的GC开销。为什么会有GC开销?

在Unity中使用 foreach循环之所以可能产生垃圾回收(GC)开销,核心原因在于其底层会创建迭代器对象。这个对象如果是在堆内存上分配的引用类型,那么每次循环都会生成新的垃圾,从而触发GC。

不过,这个开销并非绝对存在,它取决于你遍历的集合类型。例如,遍历数组或List<T>时,由于它们的迭代器是值类型(struct),通常不会引起GC。但遍历非泛型集合(如ArrayList)​、字典(Dictionary)​,或通过IEnumerable接口操作时,迭代器往往是引用类型,就会导致内存分配。

因此,在对性能敏感的场景(如Update函数),一个关键的优化原则是:​优先使用for循环替代foreach,因为for循环通过索引直接访问元素,从根本上避免了迭代器对象的创建。

C#中的匿名方法的含义?

在 C# 中,​匿名方法(Anonymous Methods)的核心本质是通过 delegate 关键字实现的匿名委托,它允许开发者在不定义独立命名方法的情况下,直接内联编写代码块并赋值给委托对象。

cs 复制代码
// 定义委托类型
delegate void PrintDelegate(string message);
 
// 匿名方法赋值给委托
PrintDelegate print = delegate(string msg) 
{ 
    Console.WriteLine(msg); 
};
print("Hello, Anonymous Delegate!"); // 输出:Hello, Anonymous Delegate!

在这个基础上,匿名方法还有所谓的闭包功能,也就是变量捕获,它可以去获取定义匿名方法程序块的作用域之外的变量来使用,这样会延长二者的生命周期。

.Net是什么?Mono又是什么?MonoBehavior又是什么?

.NET​ 是微软推出的一个免费开源开发平台​(技术体系统称),支持用 C# 等多种语言进行开发,其核心是通过 ​CLR​(公共语言运行时)和 ​CIL​(通用中间语言)来实现跨语言和跨平台能力。​Mono​ 是 .NET 框架的一个开源、跨平台的实现,它让 .NET 应用程序能够运行在 Windows、Linux、macOS 乃至游戏主机等多种操作系统上,是 Unity 引擎早期实现跨平台的核心。​MonoBehaviour​ 则是 Unity 引擎中所有脚本组件的基类,它继承自 .NET 的 Mono环境,允许开发者用 C# 编写控制游戏对象 (GameObject) 行为(如生命周期事件 Start()、Update())的脚本,是 Unity 游戏逻辑编程的基础。

C#中的using有哪些用法?

比较常见的用法就是引入命名空间,创建类型和命名空间的别名以及释放实现了IDisposable接口的对象的资源:本质上编译器在执行时会把using执行为try-finally来保证销毁相应的资源。

C#的GC机制?

C#的垃圾回收机制(GC)以托管堆为基础,通过分代回收(Generational Collection)​和内存压缩(Compaction)​两大核心策略实现高效内存管理。其设计基于"弱代假设"------绝大多数新对象生命周期极短,因此将堆划分为三个代龄:新对象首先进入第0代(Gen 0)​,此处回收频率最高(如内存满时自动触发),能在毫秒内清理90%以上的短期对象(如局部变量);存活对象晋升至第1代(Gen 1)​作为缓冲区,回收频率较低;长期存活对象最终进入第2代(Gen 2)​,仅当内存严重不足时才触发回收,避免频繁处理长期对象的高昂开销。这种分层回收显著降低了GC的整体负担,优先快速清理短期对象,再逐级处理长期对象。

每次GC回收后(尤其是Gen 0和Gen 1),会执行内存压缩​:暂停所有线程以保证引用关系稳定,将存活对象向堆的起始端移动,形成连续内存块,消除碎片空隙,最后更新所有对象引用指针。此过程解决了内存碎片化问题,使后续对象分配只需简单移动堆指针,无需搜索空闲链表,极大提升了分配效率和CPU缓存局部性。但压缩存在两项例外:​大对象(≥85,000字节)​​ 直接分配在大对象堆(LOH)​,默认不压缩以避免移动成本;钉住对象(Pinned Objects)​​(如通过fixed关键字或传递给非托管代码的对象)无法移动,以免破坏非托管代码中的地址引用。

C#结构体和类的区别,结构体可以继承吗?

C#中类是引用类型,实例通常在托管堆上分配,赋值与传参复制的是引用;结构体是值类型,赋值与传参都会整体拷贝本体(可减少别名带来的副作用),适合小而短生命周期的数据。结构体不支持继承(除隐式继承自ValueType/Object外),不能作为其他类型的基类,但可以实现接口;类支持继承与多态。

C#的托管堆是什么?Mono内存又是什么?

托管堆 是 .NET 平台(包括 C#)用于自动管理引用类型对象内存的核心机制,而 ​Mono 内存特指在 Unity 引擎的特定历史版本中,由 Mono 运行时所管理和维护的那部分托管堆。你可以将 Mono 内存理解为托管堆在 Unity 的 Mono 脚本后端环境下的一个具体实现。两者都依赖垃圾回收(GC)机制自动释放内存,但 Mono 所使用的 GC 算法(如标记-清除)与现代 .NET 的 GC 在效率和行为上存在差异,例如 Mono 的堆内存更易"只增不减"。

Unity

关于Awake,OnEnable和Start?

我们都知道执行顺序就是Awake,OnEnable和Start,但是具体来说,Awake在对象初始化时执行,OnEnable在脚本激活时执行,而Start则是在Update之前执行,所以一个生命周期中OnEnable可以执行多次。

我们都知道Unity是通过挂载脚本来实现内容的驱动的,但是具体每个脚本的执行顺序是怎么回事呢?

事实上,针对同一场景中的不同脚本,具体的执行顺序并不固定,我们可以在Edit->Project Settings->Script Exection Order中进行修改。

CharacterController vs Rigidbody

二者都可以实现驱动角色的移动,但是底层的原理不同:前者通过直接计算并修改挂载的角色的几何坐标来实现移动,而后者完全基于物理模拟来实现角色移动。

烘焙(Baking)是什么?

烘焙指预计算并存储复杂数据以减少运行时开销,Unity 中主要应用在两类资源:

Unity的几种坐标系

Unity必须的几个文件夹:

Asset,Library,ProjectSettings,以及如果有第三方插件所需要的Packages文件夹。Unity 的资源构建过程严格限定在项目的 Assets 目录内,无论采用何种打包策略,最终构建的内容均源于此目录下的资源。在开发环境中,Assets 文件夹存储所有原始资源文件(如模型、贴图、音频),Library 文件夹由 Unity 自动生成,用于缓存导入后的中间数据(如 .meta 文件和优化后的资源副本),而 Project Settings 则保存项目的全局配置(如物理参数、渲染设置)。若项目包含第三方插件,通常会被置于 Packages 目录(本质是 Assets 的子集),同样参与构建。

这里引出一个问题就是Unity中在不同的工程文件中如何安全地转移资产文件(asset)?

Unity动态加载资源的方式?

Unity的PlayerPrefs进行保存和读取的方法?

保存是以Set开头的函数,读取是以Get为首的函数,比如我们的数据类型是Int时:

场景中有多个摄像机同时激活会怎样?

默认按Depth值从低到高渲染(低Depth先渲染,高Depth覆盖)。

.Net和Mono的关系?

image和rawimage的区别?

这里需要补充一下的是,我们外部的图片无论JPG或者PNG格式进入Unity之后默认是Texture格式,我们需要手动转换成Sprite格式才能被image使用。

Transform的父类?

Component类

如何理解动态合批和静态合批?

在Unity中,场景中的每个物体本质上由网格(Mesh)​和材质(Material)​构成:网格定义了物体的几何形状(顶点、法线、UV坐标等),而材质则通过着色器(Shader)和引用的纹理(Texture)​共同决定物体表面的视觉表现(如颜色、光泽、透明度),此时纹理可理解为"贴在网格表面的皮肤"。

​静态合批针对标记为Static的物体(不可移动/旋转/缩放),在场景构建(Build)或初始化阶段,将使用相同材质的静态物体的网格顶点和索引数据合并成一个持久化的大网格​(若顶点数超64k则拆分为子网格)。此过程通过单次Draw Call提交整个合并网格,显著减少渲染调用次数,但需付出高内存占用的代价(存储合并后的大网格),且合并后子物体失去独立变换能力。

​动态合批则针对运行时可移动的小型动态物体(顶点数≤300且使用完全相同的材质实例),在每帧实时检测符合条件的物体,将其顶点数据从本地空间变换到世界空间,并填充至公共顶点缓冲区(不生成新网格),最终通过单次Draw Call提交。此机制减少Draw Call但增加CPU开销​(顶点变换计算),且受严格限制(如统一缩放、顶点属性总数≤900)。

Unity的客户端和服务器交互的方式有哪些?

基于TCP或者UDP的Socket,或者是Http或者基于Http的一系列协议等等。

Camera的ClearFlags的含义是什么?

Camera组件的Clear Flags属性用于控制每一帧渲染开始时如何清除屏幕的帧缓冲区(包括颜色缓冲区和深度缓冲区)。

Unity跨平台的原理?

Unity实现跨平台的底层原理本质上是构建了一个三层架构体系:开发者编写的C#代码首先被编译为与平台无关的中间语言(IL),这种符合CLI标准的字节码不依赖具体硬件或操作系统,为跨平台提供了基础;然后Unity通过两种后端方案处理这些IL代码------Mono虚拟机在支持动态编译的平台(如Windows、Android开发期)采用即时编译(JIT)技术,在运行时将IL动态转换为机器码并执行,而IL2CPP工具链则在构建阶段将IL静态转换为C++代码,再调用各平台的原生编译器(如iOS的Clang、Android的NDK)生成机器码,这种预编译(AOT)模式尤其适用于iOS等禁止JIT的平台,兼顾性能与安全;最后,Unity强大的平台抽象层统一封装了不同操作系统的底层差异,例如将DirectX、Metal、Vulkan等图形API抽象为统一的渲染管线接口,将鼠标、触摸屏、手柄等输入设备抽象为Input类,并通过路径接口(如Application.dataPath)屏蔽文件系统差异,同时资源处理系统(如纹理压缩格式转换、AssetBundle打包)也会按目标平台自动适配,确保开发者只需关注业务逻辑,而无需为每个平台重写底层实现。

关于fixed update和update:同一帧中物理更新优先执行?

是的,在 Unity 的同一帧循环内,​FixedUpdate(物理更新)会优先于 Update(常规游戏逻辑更新)执行​。

这样设计的原因是,FixedUpdate以固定的时间间隔​(默认为 0.02 秒)调用,专门用于处理与物理引擎相关的计算(如刚体运动、力的施加等),以确保物理模拟的稳定性和一致性,不受设备帧率波动的影响。而 Update的调用频率与当前游戏的实际渲染帧率同步,主要负责处理大部分常规游戏逻辑。因此,在同一帧中,Unity 会先处理所有需要执行的物理计算(FixedUpdate),然后再执行常规的游戏逻辑(Update),从而保证物理世界先于游戏逻辑得到更新。

你可能会观察到,有时一帧内会执行多次 FixedUpdate,而 Update只执行一次。这是因为如果游戏帧率较低,Unity 会在一帧内连续调用多次 FixedUpdate来"追赶"上真实的物理时间,然后再执行一次 Update,这确保了物理模拟的准确性。

关于协程:

协程是基于迭代器实现的,而迭代器是基于状态机实现的。协程的本质是编译器将 yield 语法转化为状态机类,Unity 通过迭代器接口在帧生命周期中调度其执行,从而实现单线程上的异步编程体验。

cs 复制代码
void Start() {
    Debug.Log("【1】Start开始"); 
    StartCoroutine(MyCoroutine()); // 启动协程
    Debug.Log("【3】Start结束");   // ✅ 协程挂起后立即执行!
}
 
IEnumerator MyCoroutine() {
    Debug.Log("【2】协程开始");     // ✅ 同步执行
    yield return new WaitForSeconds(2); // ⏸️ 挂起点
    Debug.Log("【4】2秒后恢复执行"); // ⏳ 等待2秒后触发
}

这就是一个协程运作的基本流程,我们通过StartCoroutine启动协程,协程正常运作到yield return之前都一切正常,遇到yield return之后我们会把协程挂起,然后这个时候我们会回到start函数中继续执行后续的逻辑,直到协程从挂起恢复后再执行协程后续的内容。

以下是以伪代码的形式实现的协程:

cs 复制代码
// 协程管理器类
class CoroutineManager:
    List<IEnumerator> runningCoroutines
    
    function Update():  // 每帧调用
        for each coroutine in runningCoroutines:
            if coroutine.canContinue():
                bool hasNext = coroutine.MoveNext()
                if not hasNext:
                    remove coroutine from list
    
    function StartCoroutine(coroutine):
        runningCoroutines.add(coroutine)
// 等待条件基类
class YieldInstruction:
    function isDone(): return false
// 等待一帧
class WaitForEndOfFrame extends YieldInstruction:
    function isDone(): return true
// 等待指定时间
class WaitForSeconds extends YieldInstruction:
    float waitTime, startTime
    
    constructor(seconds):
        waitTime = seconds
        startTime = getCurrentTime()
    
    function isDone():
        return (getCurrentTime() - startTime) >= waitTime
 
// 协程迭代器包装类
class CoroutineEnumerator:
    IEnumerator innerEnumerator
    YieldInstruction currentYield
    
    function MoveNext():
        bool hasNext = innerEnumerator.MoveNext()
        if hasNext:
            currentYield = innerEnumerator.Current
        return hasNext
    
    function canContinue():
        if currentYield == null: return true
        return currentYield.isDone()
 
// 使用示例
function MyCoroutine():  // 这是一个生成器函数
    print("协程开始")
    yield return new WaitForSeconds(2.0)
    print("等待2秒后")
    yield return new WaitForEndOfFrame()
    print("等待一帧后")
    yield return null  // 等待一帧
    print("协程结束")
// 启动协程
coroutineManager.StartCoroutine(MyCoroutine())
///
更简单的版本
///
// 协程 = 可以暂停的函数
class Coroutine:
    state = 执行状态
    nextWakeTime = 何时继续
    
    function run():
        执行到下一个 yield 点
        保存当前状态
        设置下次唤醒条件
// 协程调度器
class Scheduler:
    协程列表 = []
    
    function 每帧更新():
        for 每个协程:
            if 可以继续执行:
                协程.run()
            if 协程已完成:
                移除协程
 
// 使用
function 我的协程():
    做一些事
    yield 等待条件  // 暂停在这里
    继续做事
    yield 另一个等待条件  // 再次暂停
    完成
 
调度器.启动(我的协程())

关于Resources,AB包和Addressable:

对于后两种方案来说,AB包和Addressables加载的资源是无法在Unity的构建过程中进行构建的,因为这些资源会被Unity识别为外部依赖,Unity的Build只会去构建Asset中的资源。

关于LOD和Mipmap:LOD(Level of Detail)和Mipmap是Unity中两类核心的渲染优化技术,它们均通过动态调整资源精度来平衡画质与性能。

关于固定帧率模式和追赶帧率模式:

固定帧率模式其核心是通过主动休眠强制延长帧时间,确保每帧耗时严格匹配目标帧率要求。例如目标为60FPS(每帧16.67ms),若某帧逻辑仅耗时5ms,系统会调用 Thread.Sleep() 或 WaitForSeconds() 休眠剩余11.67ms,再执行下一帧。

当帧耗时超过目标时间​(如目标16.67ms但实际耗时25ms),系统会跳过当前帧的渲染阶段,直接进入下一帧的输入处理与逻辑更新,通过多次执行逻辑更新(如连续调用 Update())消化累积的时间延迟。例如连续两帧超时后,第三帧若未超时则渲染最新逻辑状态。

关于UGUI如何触发事件:本质上就是基于射线检测实现的,UGUI会在鼠标点击的位置发射一条射线,如果检测到UI元素就会触发绑定的事件。

如何创建一个有限状态机:

针对不同需求的状态机大体上有两种实现方法:

在实现简单状态机时,我们首先定义一个枚举类型来明确所有可能的状态(如Idle、Walk),然后在状态机类中声明一个当前状态变量(currentState),通过Update()方法内的switch语句根据当前状态执行对应逻辑;在每个case分支中,我们直接编写状态转换的条件判断(例如检测输入事件或条件满足),一旦条件成立就立即更新currentState的值,从而在下一帧切换到新状态。这种方法将状态行为与转换逻辑集中在一个类中,适合状态数量少(≤5个)且逻辑不复杂的场景,但扩展性较差,状态增多后代码会臃肿。

对于复杂状态机,我们采用状态模式:先定义一个基础状态接口(如IState),要求所有具体状态类实现Enter()、Update()、Exit()三个方法;接着为每个状态(如IdleState、WalkState)创建独立类,在这些类的Update()方法内检测转换条件(如按键事件),并直接调用状态机类的ChangeState()方法(需持有状态机引用)触发切换;状态机类负责管理当前状态(private IState currentState),在ChangeState()中依次执行旧状态的Exit()、更新状态引用、新状态的Enter(),同时通过自身的Update()驱动当前状态的Update()。最终,在角色控制器中初始化状态机并设置初始状态,每帧调用状态机的Update()即可完成闭环。这种方式将状态逻辑分散到各状态类,通过多态实现动态行为分发,支持高扩展性和维护性,适用于状态多或逻辑复杂的系统。

实现了三个Update的驱动。

OnPreProcessTexture和OnPostProcessTexture的作用:一句话总结就是前者是导入纹理前执行的方法后者是导入纹理后执行的方法。

AssetModificationProcessor是什么?这是Unity提供的一个编辑器类,用于监听资源在编辑器中的操作​(如创建、删除、移动),而非导入流程。其核心方法包括:

•OnWillCreateAsset:资源创建前触发。

•OnWillDeleteAsset:资源删除前触发。

•OnWillMoveAsset:资源移动前触发。

如果要使用这个类中的方法只需要去继承这个类然后重写调用相关的函数即可。

AB包底层原理?

AB包本质是一种平台特定的二进制压缩文件,用于存储Unity资源(如模型、贴图、材质、预制体、音效等)。它通过将资源打包成独立于应用安装包的二进制文件,实现资源的动态加载与更新。

这里补充一下Resources,我们总说尽量少使用这个文件,但具体为什么呢?Unity 在构建项目时,会将所有 Resources 文件夹内的资源整合为一个序列化文件(resources.assets),并生成一个红黑树作为索引数据结构。游戏启动时,Unity 会完整加载整个红黑树索引到内存中,且该索引不可卸载,会持续占用内存直至游戏结束,红黑树本身占据的内存就不小,且构建和维护红黑树也要很多时间,所以如果Resources中的文件过多,会导致游戏加载卡顿。

在Unity中打包AB包时,首先需要在编辑器中为资源手动标记所属的包名(如将角色模型标记为characters/hero),相同包名的资源会被合并打包;接着选择压缩格式------LZMA适合最小化包体但需整体解压,LZ4则支持按需加载局部资源,更推荐用于平衡性能与体积;打包过程中Unity会自动分析资源间的引用关系(如多个模型共享的材质),将公共依赖提取到独立AB包避免冗余,最终通过BuildPipeline.BuildAssetBundles()生成二进制文件及记录依赖链的主清单文件,此过程必须指定目标平台(如Android/iOS)以确保兼容性。

在加载与卸载AB包时,若资源在本地存储(如StreamingAssets),可通过AssetBundle.LoadFromFile()同步加载或LoadFromFileAsync异步加载,后者避免主线程阻塞;加载后AB包数据暂存于内存镜像区​(压缩态),需调用LoadAsset()解压到活动内存才能实例化使用;关键的是必须通过主清单递归加载所有依赖包(如材质包),否则资源引用失效(如模型变粉);卸载时若调用Unload(false)仅释放内存镜像区的压缩数据,已加载资源保留(需后续手动管理),而Unload(true)则强制卸载所有关联资源,但若场景物体仍引用这些资源会导致材质丢失或报错,因此最佳实践是在场景切换时先Unload(false)释放AB包,再调用Resources.UnloadUnusedAssets()清理残留资源实例。

什么是ShaderLab?

ShaderLab是Unity的着色器配置语言,用于组织和描述Shader的结构和属性;它定义了Properties(材质面板中可调节的参数如贴图、颜色、数值),SubShader(渲染管线的备选方案,按硬件能力排序),Pass(具体的渲染通道,包含顶点/片元着色器代码和渲染状态),Tags(给渲染引擎的元数据如RenderType、Queue等);ShaderLab本身不在GPU上执行,而是作为"外壳"承载真正的HLSL着色代码,Unity会根据ShaderLab的描述生成对应的渲染指令;它提供了跨平台的Shader管理能力,让开发者可以用统一的语法描述不同图形API(DirectX、OpenGL、Metal等)的Shader,是Unity Shader开发的基础框架。

HLSL是什么?

HLSL(High-Level Shading Language)是微软开发的高级着色器语言,用于编写在GPU上执行的着色器程序;在Unity中,HLSL是真正跑在GPU上的着色代码,写在CGPROGRAM或HLSLPROGRAM代码块中,包含顶点着色器、片元着色器、计算着色器等;Unity对HLSL进行了扩展,提供了大量内置函数和宏(如UnityObjectToClipPos、SAMPLE_DEPTH_TEXTURE等),以及跨平台编译支持,将HLSL代码编译到不同的图形API(DirectX、OpenGL、Metal、Vulkan)。

Surface Shader和Shader Graph分别是什么?

Surface Shader是Unity内置渲染管线下的高层Shader开发工具,让你只需定义一个surface function来描述表面属性(如Albedo、Normal、Emission、Metallic、Smoothness),Unity会自动生成包含完整光照、阴影、前向/延迟渲染等Pass的HLSL代码,开发效率高但自定义程度较低,且只支持Built-in渲染管线。

Shader Graph是Unity URP/HDRP的可视化Shader编辑器,通过连接各种节点(纹理采样、数学运算、光照模型等)来定义着色逻辑,Unity实时生成对应的HLSL代码,深度集成现代渲染管线,支持自定义函数节点和自定义Pass,迭代快、协作友好但复杂自定义时仍有限制。

Unity Shader中具体有哪些可以实现性能优化的手段?

使用GPU Instancing批量渲染大量相同对象,减少DrawCall数量;启用SRP Batcher将使用相同Shader变体的对象合并批次,减少CPU开销;合理使用精度类型(fixed/half/float),移动端优先使用fixed和half;避免在片元着色器中使用分支语句,用数学函数替代条件判断;优化纹理采样,使用合适的MIP级别和压缩格式,减少带宽占用;使用变体剔除移除未使用的Shader变体,减少内存占用;合理设置渲染状态,避免不必要的深度测试和混合操作;使用LOD系统根据距离选择不同精度的Shader。

什么是计算着色器?具体如何使用?

Compute Shader是GPU的通用计算着色器,不参与图形渲染管线,而是直接在GPU上执行并行计算任务,使用HLSL编写,通过numthreads定义线程组大小,用StructuredBuffer进行数据读写,支持Barrier同步线程;典型应用包括粒子系统计算、GPU Transform、物理模拟、图像处理、几何处理等,核心优势是充分利用GPU并行计算能力,将计算密集型任务从CPU转移到GPU,显著提升性能。

首先在C#脚本中创建ComputeBuffer存储数据,找到Compute Shader的kernel(计算核心),设置输入参数和缓冲区;然后在Update中调用Dispatch执行计算,指定线程组数量;最后将计算结果传递给渲染Shader或读取回CPU;具体流程是:创建StructuredBuffer存储输入输出数据,用FindKernel找到要执行的kernel,用SetBuffer、SetFloat等设置参数,用Dispatch启动GPU计算,计算完成后数据自动更新到缓冲区,可以在渲染Shader中采样或通过GetData读取回CPU。

Compute Shader是用HLSL编写的GPU计算程序,通过C#创建ComputeBuffer管理数据结构并调用计算流程,其特殊性在于运行在GPU上利用并行计算能力,且计算完成后数据可以直接传递给渲染管线的Shader使用,实现GPU计算和GPU渲染的无缝连接,避免了CPU和GPU之间的数据传输开销,特别适合粒子系统、物理模拟、图像处理等需要大量并行计算的任务,既能充分利用GPU的计算能力,又能将计算结果直接用于渲染。

FrameDebugger是什么?有何作用?

FrameDebugger是Unity内置的渲染调试工具,用于逐帧分析渲染过程,帮助开发者定位渲染问题和优化性能;它的主要作用包括:查看每个DrawCall的详细信息,包括渲染状态、Shader变体、渲染目标、深度/模板缓冲等;分析渲染管线中的每个Pass,了解渲染顺序和状态变化;检测过绘(Overdraw)问题,查看哪些像素被重复渲染;调试渲染状态错误,如深度测试、混合模式、面剔除等设置问题;分析Shader变体使用情况,查看哪些变体被实际使用;检查渲染目标(RenderTexture)的内容和状态;优化渲染性能,通过分析DrawCall数量和渲染状态来找出性能瓶颈;FrameDebugger是Shader开发和渲染优化的重要工具,能帮助开发者深入理解渲染过程并解决复杂的渲染问题。

这里我们也大致介绍一下ECS:

ECS(Entity-Component-System)是一种软件架构模式,将游戏对象分解为三个核心概念:Entity(实体)是纯ID标识符,不包含任何数据或逻辑;Component(组件)是纯数据结构,存储实体的属性信息,如位置、速度、健康值等;System(系统)是纯逻辑,处理具有特定组件组合的实体,如移动系统处理所有具有位置和速度组件的实体;ECS的优势包括:数据局部性好,相同类型的数据存储在连续内存中,提高缓存命中率;并行处理能力强,系统可以独立并行执行;内存效率高,只存储必要的数据;易于扩展,通过添加组件和系统来增加功能;Unity的ECS实现包括Job System用于并行处理,Burst编译器将C#代码编译为高性能原生代码,适合大规模实体模拟和高性能计算场景。

一般说到ECS也绕不开Burst编译器和Job System:

Job System是Unity的并行处理框架,允许将计算任务分解为多个独立的Job,这些Job可以在多个CPU核心上并行执行,显著提升性能;它通过IJob、IJobForEach、IJobParallelFor等接口定义并行任务,自动处理线程调度和同步,避免手动管理线程的复杂性;Burst编译器是Unity的高性能编译器,将C#代码编译为优化的原生代码,接近C++的性能水平,特别适合数学计算、向量运算、循环等计算密集型任务;它通过静态分析确保代码安全,自动向量化优化,减少GC压力。

操作系统

什么是硬链接和软链接?

我们讨论硬链接和软链接时,核心正是在讨论操作系统(尤其是像 Linux 和 Windows 这类系统)的文件系统是如何组织和管理文件的。首先看看二者的区别:

inode(索引节点)​​ 是文件系统中描述文件元数据和数据位置的核心数据结构,每个文件/目录对应唯一的 inode,存储其权限、所有者、大小、时间戳、数据块指针等信息。它相当于文件的"身份证",操作系统通过 inode 定位文件的实际内容,并管理文件的访问控制。

硬链接​ 是文件系统中同一文件的多个别名,所有硬链接共享相同的 inode 和数据块,删除任一链接不影响文件数据,直到最后一个链接被删除。它适用于同一文件系统内的多路径访问,但不支持目录和跨文件系统。

软链接(符号链接)​​ 是一个独立文件,存储目标文件的路径字符串,拥有自己的 inode。它像"快捷方式",可跨文件系统、链接目录,但依赖原文件存在(原文件删除则链接失效)。软链接灵活但需额外存储空间,常用于简化路径或跨分区引用。

中断和轮询是什么?

简单来说,轮询和中断是操作系统与外部世界沟通的两种基本方式。轮询是CPU主动的、周期性的询问,而中断是由外部事件触发的、异步的打断

中断(Interrupt)​​ 是硬件或软件主动触发的事件通知机制,当特定条件(如键盘输入、定时器到期)发生时,强制CPU暂停当前任务,转而执行中断处理程序,实现异步响应​(如网卡收到数据后中断CPU)。​轮询(Polling)​​ 是CPU主动反复检查设备或状态的机制,通过循环查询(如不断读取寄存器)判断事件是否发生,属于同步等待​(如持续检查串口是否有数据)。

什么是IO多路复用?

I/O多路复用(I/O Multiplexing)​​ 是一种通过单线程监听多个I/O流事件的高效并发模型,核心思想是让操作系统(如通过epoll、select、kqueue)批量通知程序哪些I/O操作已就绪(如可读、可写),避免为每个I/O分配独立线程阻塞等待。

//先写到这,后续慢慢更新

相关推荐
laplace01231 天前
Part3 RAG文档切分
笔记·python·中间件·langchain·rag
被遗忘的旋律.1 天前
Linux驱动开发笔记(二十三)—— regmap
linux·驱动开发·笔记
技术宅学长1 天前
关于CLS与mean_pooling的一些笔记
人工智能·pytorch·笔记·pycharm
数据轨迹0011 天前
CVPR DarkIR:低光图像增强与去模糊一体化
经验分享·笔记·facebook·oneapi·twitter
自小吃多1 天前
爬电距离与电气间隙
笔记·嵌入式硬件·硬件工程
半夏知半秋1 天前
rust学习-Option与Result
开发语言·笔记·后端·学习·rust
雍凉明月夜1 天前
深度学习网络笔记Ⅴ(Transformer源码详解)
笔记·深度学习·transformer
week_泽1 天前
小程序云函数全面总结笔记_5
笔记·小程序
wdfk_prog1 天前
[Linux]学习笔记系列 -- [fs]read_write
linux·笔记·学习