3.1 新关键字
alignas/alignof:它是做什么的?
alignas作用于类,定义其内存对齐方式。alignof使用方法则是类似于sizeof,查看其对齐方式。
给一个很大的成员alignas很小的对齐方式会不生效,例如给double的对齐方式设为1不会生效。
alignas(0)意思是默认原始的对齐方式。
对齐值必须是 2 的幂(如 1, 2, 4, 8, 16, ...),否则编译错误。
auto:指针类型该如何推导?
-
int *被推导为int *。 -
字符串被推导为
const char *。 -
float *被推导为float *。 -
double *被推导为double *。
constexpr:它是做什么的?
有些事儿在编译期间就能做完。如果n值在编译时就已经确定,那么这个factorial的值在编译期就会算出来,否则是在运行时才能计算。
decltype:它是做什么的?
声明变量类型,类似于auto。
decltype:声明类型时,再套一层括号是什么?
里面的值升级为表达式:
左值->左值引用
纯右值->纯右值
亡值->右值引用
【必问】decltype:decltype vs auto的区别?
-
auto会忽略顶层const和引用,但会保留底层const。
-
decltype保留变量的所有类型信息,包括顶层const和引用。
-
对于表达式(套一个小括号),decltype的行为更复杂。
dynamic_cast:它是做什么的?
父类指针转换成子类指针,前提是左父右子才能正确转,不然不行。
如果转的是指针,失败返回空指针;如果转的是引用,失败会抛出异常。
enum:传统枚举 vs 现代枚举的区别?
传统枚举没有命名空间,两个枚举里都有red这个枚举值,但是你分不清楚。
现代的enum class拥有命名空间,不会搞错。
explicit:它是做什么的?
加在构造函数前,则必须使用此构造函数显式构造,不能隐式构造。
final:它是做什么的?
-
当你在定义一个类时,在类名后使用
final关键字,表示这个类 不能被其他类继承。 -
当你在类的成员函数声明中(通常是虚函数),在函数声明后加上
final,表示该函数 不能在派生类中被重写(override)。
inline:它是做什么的?
建议编译器把代码展开,空间换时间。编译器是否真的展开不一定。
还可以和namespace组合一下成为inline namespace。
inline namespace(内联命名空间)是一种特殊的命名空间,它的主要作用是让其中的成员表现得像是在外层命名空间中直接定义的一样,从而实现一种“默认版本”或“向后兼容”的机制。使用 inline namespace 后,其中的成员可以直接通过外层命名空间访问,不需要显式指定内联命名空间。
inline namespace 常用于库的版本管理。
inline:编译器怎么确定是否要展开inline函数?
编译器在决定是否内联某个函数时,通常会考虑 一系列启发式因素和优化策略,主要包括:
-
函数体的复杂度
-
函数调用频率 / 上下文
-
函数是定义在头文件中,更会内联
-
递归函数内联麻烦,加了inline也不会内联
-
虚函数运行时确定,加了inline也不会内联
-
高优化级别(如 -O2, -O3, -Os),编译器会更积极地尝试内联,即使你 没有写 inline,它也可能自动内联某些简单函数
mutable:它是做什么的?
类中的可变成员。
noexcept:它是做什么的?
告诉编译器该函数不会抛出异常,这样编译器可以优化代码。
nullptr:nullptr vs NULL的区别?
空指针,和C语言的NULL有所不同。
在C++里,NULL也只是一个0,不像C语言里为(void*)0,这是因为C++是强类型不允许void*这种类型,而nullptr则是有自己的类型的。在C++里输入NULL很容易被推导成int类型。
reinterpret_cast:它是做什么的?
底层指针的转换,慎用。
reinterpret_cast的妙用:检测大小端。
sizeof:sizeof的新花样?
sizeof...:计算模板参数包(parameter pack)中的参数数量。
static:static变量创建在main之前,如何解决不同编译单元间static变量析构顺序不一致的问题?
在C++中,static变量(尤其是具有静态存储期的全局或命名空间作用域的static对象)的构造和析构顺序在不同编译单元(translation unit)之间是不确定的。这可能导致一个static对象在其依赖的另一个static对象已经被析构后仍然尝试使用它,从而引发未定义行为(如访问已销毁的对象、空指针等)。
-
使用 "Construct On First Use"(首次使用时构造) 惯用法:将static对象包裹在一个函数内部(通常是返回引用的静态局部变量),这样该对象就变成了函数内的static对象,它的初始化发生在第一次调用该函数时,并且是线程安全(C++11起)的。
-
单例模式:将对象的构造封装在一个获取函数中,通常也采用函数内static变量实现。
static:如果在头文件中定义一个static变量,会发生什么?
如果在头文件中定义了一个 static 全局变量,然后多个 .cpp 文件都包含了这个头文件:
-
每个
.cpp文件都会获得该static变量的一份 独立副本。 -
这些副本互不干扰,各自有自己的存储空间和生命周期。
-
每个副本的作用域仅限于包含该头文件的 .cpp 文件内部。
-
不会引发链接错误,因为每个编译单元都有自己的
myStaticVar,它们名字相同但链接不同(internal linkage)。
static_assert:它是做什么的?static_assert vs assert的区别?
断言。编译期直接发动。跟assert的不同:assert只是一个宏,且它是运行期发动,不是编译期发动。如果有运行时才能判断的内容需要做断言,则必须使用assert。
using:typedef vs using的区别?
using:拥有和typedef相同的作用。不过using有两个优势:
第一:可以给模版取别名。
第二:给函数指针取别名较为直观。
3.2 函数
3.2.1 函数高级
声明和定义中都设置同一个位置的默认参数,会发生什么?
在 C++ 中,默认参数(Default Argument)只能指定一次,并且通常应该只在函数声明中指定,而不能在函数声明和定义中都为同一个参数设置默认值,否则会导致 编译错误:默认参数重复定义。
什么是不求值表达式?
表达式不被求值。
C++ 中的主要 不求值运算符(语境) 包括以下几个:
-
typeid(...)
-
sizeof(...)
-
decltype(...)
-
noexcept(...)
变长参数是什么?他和模板的形参包是一个东西吗?
不是一个东西,变长参数来源于C。
变长参数:
-
va_list
-
va_start
-
va_arg
-
va_end
函数参数的作用域和函数体内定义的变量的作用域一样吗?函数try块是什么?
正常情况下是一样的,但有一种情况不一样,例如在函数try块时,catch的部分可以访问到函数的参数,而不能访问到函数体内定义的变量。
函数 try 块用于 “捕获构造函数初始化列表或函数体中抛出的异常”。
它 只能用于函数(包括普通函数、构造函数、析构函数),是一种包围整个函数体的 try-catch 结构。
函数try块的形参和函数体内变量完全不在一个作用域,甚至可以重名不触发重定义。
3.2.2 lambda
【必问】lambda表达式的捕获方式有哪几种?
-
[=]:以值方式捕获所有外部变量 -
[变量名1, 变量名2, ...]:按值显式捕获指定变量 -
[&]:以引用方式捕获所有外部变量 -
[&变量名1, &变量名2]:显式按引用捕获指定变量 -
[=, &x, &y]:混合捕获,默认按值捕获所有,但 x 和 y 按引用捕获 -
显式捕获
this指针:用于类成员中的 Lambda -
mutable关键字:允许修改按值捕获的变量
【必问】lambda表达式本质是什么?为什么引用捕获可以修改值,值捕获就必须加mutable才能修改?
因为lambda的本质是一个类,其仿函数重载小括号时是加上const的,他是一个常函数,而值捕获的内容是作为一般成员放到这个类里,所以不加mutable常函数是无法修改它的。而引用传入的内容不是这个类的一部分,是外部的东西,不受常函数的约束。
对一个lambda表达式求sizeof,结果是多少?
lambda本质是一个类的仿函数,大小跟它的捕获的内容有关,因为捕获的内容会变成他的成员。如果没有捕获任何内容,那么只有一个重载小括号,那么大小就是1。
既然lambda的本质是类,那么它为什么能给一个函数指针赋值?
只有当 lambda 表达式是 “无捕获”(即不捕获任何外部变量,使用 [])的时候,它才可以隐式转换(赋值)给一个普通的函数指针。因为此时它的 operator() 是静态的、不依赖任何上下文,行为就像一个普通函数,因此可以视为函数指针指向的目标。
但如果 lambda 捕获了任何外部变量(比如 [x] 或 [&]),它就依赖了上下文状态,不再是一个纯函数,也就不能转为函数指针了。
泛型lambda又是什么?和使用模版有何不同?
泛型 lambda(Generic Lambda)是 C++14 引入的一种简化写法,允许 lambda 的参数使用 auto 关键字,从而让 lambda 可以接受多种类型的参数,而无需为每种类型都写一个重载。
他不是模板!不支持<typename T>语法。
泛型lambda转成函数指针和普通lambda转成函数指针有什么区别?
泛型lambda转成函数指针,这个指针在声明时,必须显式指明auto的类型。也就是说此时这个函数指针没有泛型能力。
lambda捕获static变量/全局变量的机制是什么?
默认就是直接捕获的。在捕获的列表里不要写任何东西,或者写[=]和[&]都行,反正以这两种方式去做捕获也会自动忽略static变量和全局变量。
但是不能指名道姓地去捕获他们,[x]、[&x]编译器就要报错了。
lambda捕获const int局部变量、constexpr变量的机制是什么?
空捕获列表使用这些变量被视为右值,不可取地址。
[=]和[&]捕获过来的是一个不可修改的左值,可以取地址。
可以指名道姓地去捕获他们,[x]、[&x]允许。
lambda捕获const 非int局部变量的机制是什么?
空捕获列表无法使用它。
[=]和[&]捕获过来的是一个不可修改的左值,可以取地址。
可以指名道姓地去捕获他们,[x]、[&x]允许。
3.2.3 std::function
std::function是什么?怎么使用?
std::function 是 C++11 引入的一个通用的函数包装器,它可以存储、复制和调用任何可调用对象(如普通函数、Lambda 表达式、函数对象、绑定表达式等),只要这些对象的调用签名(参数和返回类型)匹配。
std::function比函数指针有什么优点?
std::function
-
类型:可以包装函数、Lambda、函数对象、绑定表达式等任何可调用对象
-
捕获变量:可以包装带捕获的 Lambda,从而拥有状态
-
类型安全:更强(基于模板和类型擦除)
-
灵活性:高,可以存储不同类型的可调用对象(只要签名匹配)
【必问】std::function vs Lambda表达式有什么不同?
Lambda表达式:
-
类型:是一个类。
-
性能:更高。
-
使用:一般直接使用或作为参数传递
-
互相转化:可以赋值给一个匹配签名的
std::function
std::function:
-
类型:是一个通用的函数包装器类模板。
-
性能:没Lambda高。
-
使用:适合统一管理多个可调用对象
-
互相转化:不能直接赋值给 Lambda
类型擦除是什么?
类型未知,但是行为已知。看起来一,实际则多。
简单来说,类型擦除让你可以写一个接口或容器,它看起来只处理一种类型(通常是某个抽象的、通用的类型),但实际上背后可以管理多种不同的具体类型。
std::function 是一个典型的类型擦除工具。它可以存储、复制和调用任何可调用对象(函数、lambda、绑定表达式、函数对象等),只要它们的签名一致。
3.2.4 宏/变量/运算符/字面量
变参宏是什么?
-
...表示可变数量的额外参数 -
__VA_ARGS__是预定义的标识符,在宏展开时会被替换为传入的可变参数
位域是什么?
在普通的 struct 中,每个成员变量通常占用一个或多个完整的字节(比如 int 占 4 字节)。但在某些情况下,我们可能只需要使用一个整数的某几个比特位来表示某个状态或标志,这时候就可以使用位域来精确控制每个成员变量所占用的比特位数。
注意事项:
-
可移植性差:位域的具体布局(比如位的排列顺序、是否跨整型边界)是由编译器决定的,不同平台/编译器可能有差异,不适合用于需要严格跨平台对齐的场景。
-
访问效率略低:相比直接访问整型变量,位域的读写可能稍慢,因为涉及到位操作。
-
不能取地址:位域成员通常不能使用 & 取地址,因为它们可能不独占一个完整的字节。
-
类型与位数限制:比如不能定义一个位域超过其类型的位数(如
unsigned int : 33是不允许的,因为unsigned int通常是 32 位)。
逗号运算符是什么?
逗号运算符(comma operator) 是 C++(和 C)中的一种二元运算符。
它会先计算表达式1,然后计算表达式2,最终整个逗号表达式的结果为 表达式2 的值。
decltype字符串字面量得到的是左值还是右值?
字符串字面量本身是一个左值。
虽然字符串字面量存储在只读内存区域,它仍然是一个左值,因为它有固定的内存地址,可以被取地址(&操作符可用)。
使用 decltype 推导字符串字面量时,不仅会推导出类型,还会保留它的值类别(即它是左值这一事实)。
C++11的自定义字面量是什么?
在 C++11 中引入的自定义字面量(User-defined literals),是一种允许程序员为字面量(如整数、浮点数、字符、字符串等)定义自己专属的后缀,从而创建具有特定含义或类型的对象的语法特性。
通过自定义字面量,你可以让代码更加直观、语义更清晰,同时也能提高代码的可读性和类型安全性。
返回类型 operator"" _后缀(参数);
-
operator""是固定的关键字,表示这是一个字面量操作符。 -
_后缀是你自己定义的后缀名称,必须以 **下划线开头**(标准库保留不以下划线开头的后缀供未来使用)。 -
参数的类型取决于你所要定义的字面量类型(如整型字面量、浮点字面量、字符串字面量等)。
(追问)C++11的自定义字面量有哪几种?
-
整数字面量(Integer literals):用于处理如
123_km这样的整数字面量。 -
浮点数字面量(Floating-point literals):用于处理如
3.14_km这样的浮点数字面量。 -
字符字面量(Character literals):用于处理单个字符,如
'A'_c。 -
字符串字面量(String literals):用于处理字符串,如
"hello"_s。这是比较常用的一种自定义字面量,比如用于实现自定义字符串类型。字符串字面量的操作符函数参数略有不同,它接收的是一个字符指针和长度。
3.3 面向对象高级
3.3.1 大括号初始化列表
使用小括号构造和使用大括号构造的区别是什么?
大括号初始化可能被解释为初始化列表(std::initializer_list)或构造函数参数。优先尝试匹配 std::initializer_list 构造函数,如果没有才匹配其他构造函数。
使用大括号构造时的类型转换问题?
使用大括号,宽类型不能转换为窄类型,例如double转int就不行。
大括号初始化列表能被decltype推导吗?能被模版推导吗?
-
纯粹的大括号初始化列表不是表达式,无法被decltype推导。
-
使用std::initializer_list容器 或者 auto等 接收大括号初始化列表,可以被正常推导。
-
-
模版的参数可以正确推导大括号初始化列表。
3.3.2 类方法的默认参数
给有参构造函数的定义加上默认参数,会发生什么?
有可能会退化为无参构造,你这时候没有声明无参构造就会出错。
虚函数重写时会继承父类函数的默认参数吗?
当子类重写(override)父类的虚函数时,子类函数(看上去)会继承父类虚函数的默认参数值。但是!—— 你一定要注意:默认参数的值是静态绑定的(即在编译时根据指针/引用的静态类型决定),而不是动态绑定的(即不会根据实际对象类型决定)。
省流版:多态左父右子,默认参数跟左父。
类成员函数的默认参数能用类成员吗?
类成员函数的默认参数不能直接使用类的非静态成员变量(即普通成员变量),因为默认参数必须在编译时就能确定,而类的非静态成员变量依赖于具体的对象实例,在编译期间是无法确定的。
-
可以使用全局变量、字面量、枚举值、静态成员变量(static 成员)、函数内的局部静态变量等作为默认参数。
-
如果一定要依赖成员变量的值,应该考虑在函数内部进行默认逻辑处理,而不是通过默认参数来实现。
3.3.3 移动构造函数
类构造时的复制消除是什么?
在 C++ 中,类构造时的复制消除(Copy Elision) 是一种编译器优化技术,它允许编译器在某些情况下省略(消除)不必要的对象拷贝或移动操作,从而提高程序的效率,尤其是在对象构造和返回时。
常见的复制消除场景
-
返回值优化(RVO, Return Value Optimization)
-
当通过返回值初始化对象,并且返回的是一个纯右值(如临时对象或直接构造的对象)时,编译器不再有选择权,必须直接在目标内存中构造对象,而无需调用拷贝/移动构造函数。
-
如果是用move这种方法获得的右值是亡值,触发的是移动构造函数。
移动构造函数为什么需要是noexcept?
假设你的移动构造函数可能会抛异常(比如在资源转移时出错):
-
你从一个对象 A 移动资源到对象 B,
-
但在移动过程中抛出了异常,
-
此时 对象 A 的状态已经被部分修改(资源已经被“移动走”了一部分),
-
而且由于异常,对象 A 可能处于一个不一致的、不可用的状态!
这会导致程序处于一种非常危险的“半转移”状态,既不是原来的状态,也不是移动后的状态,而且异常使得你无法安全地回滚或继续。
什么情况下,编译器会自动给一个类添加默认的移动构造函数?
如果一个类 没有用户声明(即没有手动写)以下任何一项:
-
拷贝构造函数(
X(const X&)) -
拷贝赋值运算符(
X& operator=(const X&)) -
移动构造函数(
X(X&&)) -
移动赋值运算符(
X& operator=(X&&)) -
析构函数(
~X())
并且类的所有非静态成员变量和基类也都支持移动语义(即它们有可用的移动构造函数),那么:
编译器就会自动生成一个默认的移动构造函数。
这个自动生成的移动构造函数的行为是:
对每个成员变量和基类,依次调用它们的移动构造函数(如果存在且可用),否则调用拷贝构造函数。
3.3.4 std::bind
std::bind是做什么的?
std::bind 是 C++11 引入的一个标准库工具,定义在头文件 <functional> 中,它的主要作用是:
将函数(或可调用对象)、参数进行绑定,生成一个新的可调用对象(通常称为绑定表达式或绑定器),这个新对象可以在之后的某个时间点以指定的方式被调用。
简单来说,std::bind 就是用来“预设定函数的部分或全部参数”,生成一个可以稍后调用的新函数对象。
成员函数指针和一般函数指针有什么区别?
一般函数指针
-
指向普通函数(非成员函数),或者静态成员函数(static member function)。
-
这些函数不属于任何类对象,不依赖于类的实例。
成员函数指针
-
指向类的非静态成员函数(包括普通成员函数、虚函数等)。
-
这些函数必须通过类的对象(或指针)来调用,因为它们隐式地依赖于
this指针,即类的实例。 -
可以把它绑到一个对象上使用:
auto boundFunc = std::bind(&MyClass::sayHello, &obj);。
一般函数指针 和 成员函数指针 是不同类型,不能互相赋值或混用。
静态成员函数指针 的类型实际上与普通函数指针是兼容的,因为静态成员函数没有 this 指针。
bind绑定成员函数指针如何确定绑定的是哪个重载版本?
你需要明确地告诉编译器,你想要的成员函数指针是哪一个版本,也就是要强制转换到正确的成员函数指针类型。要么使用类型明确的成员函数指针,要么使用static_cast强转。
一般成员函数指针和虚函数函数指针有什么区别?
虚函数通过成员函数指针调用时,仍然遵循正常的虚函数调用规则,即动态绑定(运行时多态)依然有效。如果你通过成员函数指针调用一个虚函数,实际调用的函数版本取决于指针所绑定的对象的实际类型,而不是指针的静态类型。编译器在生成代码时,仍然会通过对象的 虚函数表(vtable) 去查找最终应该调用的函数地址。因此,虚函数通过成员函数指针调用时,依然可以实现运行时多态。
有引用限定符的成员函数是什么?
在 C++11 及之后的标准中,有引用限定符的成员函数(Reference-Qualified Member Functions) 是指在成员函数的声明后面加上了 &(左值限定)或 &&(右值限定) 的函数,用于限制该成员函数只能被左值对象或右值对象调用。
他们是不同的函数签名,被视为重载。
3.4 左值和右值
【必问】左值是什么?右值是什么?
左值,就是可以取地址的值。a变量可以取地址,10是字面量无法取地址,a+b是一个中间量,也无法取地址。左值一般在等式左边,右值一般在等式右边。
右值可以引用吗?
引用的本质是指针,所以右值不能取地址本来是不行的。但是C++11引入右值引用,使用两个&符号,例如int&& a = 2;也可以在函数传递参数时使用const引用来传递右值引用。右值引用的创建等式右边不能是左值。
右值引用的用途?我们为什么要设计右值引用?
核心问题:临时对象(右值)带来的性能损耗,主要是“不必要的拷贝”。
在 C++ 中,很多情况下我们会 传递或操作临时对象(例如函数返回值、表达式结果),这些对象是 右值,它们的生命周期通常很短,是“临时”的。
这就导致了:
很多时候我们 本可以“窃取”临时对象内部的资源(如动态数组、内存缓冲区)来避免深拷贝,但却只能傻傻地执行拷贝构造或拷贝赋值,造成性能浪费。
STL中有右值引用使用的案例吗?
-
std::vector::push_back和emplace_back—— 移动语义支持。 -
std::unique_ptr的移动语义。 -
STL 容器自身的移动构造函数和移动赋值运算符,比如
std::vector、std::string、std::map等都定义了自己的移动构造函数和移动赋值运算符,它们的参数都是右值引用。
左值有办法变成右值吗?
有,使用std::move就可以。
使用一个对象的右值来创建对象,是调用了拷贝构造函数还是重载=号?
只要你的拷贝构造函数参数是const引用,就会调用拷贝构造函数。否则会创建失败。总之无论如何也不会是重载=号。
左值和右值既然都可以创建对象,那右值有什么优势吗?
有的,兄弟有的。可以使用移动构造函数窃取右值资源。
-
参数是右值引用
ClassName&& other:表示它只能接受右值(临时对象或通过std::move转换的左值)。 -
noexcept是推荐的(但不是必须的):因为移动操作通常不应该抛出异常,否则可能导致资源泄漏或未定义行为(特别是在 STL 容器中)。 -
“窃取”资源:直接接管
other的内部资源(如指针指向的内存),而不是重新分配内存并拷贝数据。 -
将
other置于有效但可析构的状态:通常是将other的指针置为nullptr或重置其他资源,确保other的析构函数不会释放已经被“窃取”的资源。
(追问)为什么要窃取右值的资源呢?
因为有的函数的返回值很可能是一个很大的类,调用一般的拷贝构造函数非常慢,而窃取右值资源底层是指针操作,非常快速。正如同本例中的makeVector函数,返回的vector非常大。不过这种vector这类容器,都是已经ROV(资源优化)过的,它本身会使用移动构造,我们在写自己的类的时候则需要独自考虑这一点。
const int& a和const int&& a有什么区别?
const int& a:可左可右,立场十分灵活。
const int&& a:只能是右值。
万能引用 vs 右值引用有什么区别?
很多人容易混淆 万能引用(Universal Reference) 和 右值引用(Rvalue Reference),它们的语法看起来一样,都是 &&,但含义不同。
完美转发是什么?
C++ 中的 完美转发(Perfect Forwarding) 是一种非常重要的技术,用于在 模板函数中把参数“原封不动”地转发给其他函数,包括:
-
参数的 值类别(左值 / 右值)
-
参数的 类型(包括 const/volatile 修饰符)
它的核心目标是:
让参数以它原本的形式(左值还是右值、const 还是非 const)传递到另一个函数,不做多余的拷贝或修改。
完美转发 = 万能引用T&& + std::forward<T>
T&&:
-
左值 → 推导为左值引用类型
-
右值 → 推导为右值引用类型
-
保留const/volatile等修饰符
std::forward<T>:
-
左值引用 → 推导为左值类型
-
右值引用 → 推导为右值类型
-
保留const/volatile等修饰符
std::move vs std::forward?
std::move:
一般左值 → 右值
纯右值 → 右值
亡值 → 右值
左值引用 → 右值
右值引用 → 右值
std::forward:
一般左值 → 左值
纯右值 → 右值
亡值 → 右值
左值引用 → 左值
右值引用 → 右值
3.5 智能指针
智能指针的灵感来源是什么?为什么需要智能指针?
资源的释放是一个头疼的问题,很容易忘记,而且有时候抛出异常了后续的资源释放也无法执行,造成内存泄漏。
但是人们发现使用类的构造和析构来管理资源就会方便很多,也不怕忘。
把要使用的资源放到一个类里,再用一个变量表明它的使用计数,计数为0时销毁这个对象,释放这个资源。
智能指针常用接口?
shared_ptr<Obj> ptr = new Obj;
ptr.use_count():返回引用计数。
ptr.reset():ptr失去对该资源的管理权,引用计数-1。
ptr.swap(ptr1):交换两个智能指针的资源。
ptr.get():获取裸指针。
ptr.unique():是否引用计数==1。多线程环境下可能比ptr.use_count()更高效。
智能指针作为参数传递时,引用计数会+1吗?
值传递:智能指针引用计数+1。
const引用传递:智能指针计数不会增加。
智能指针自定义析构?什么场景下常见自定义析构?
shared_ptr<Obj> ptr(new Obj(1), deleteFunc);
场景:析构一个数组。
【必问】shared_ptr循环引用怎么解决?weak_ptr是什么?
shared_ptr循环引用:本该释放资源时,智能指针的析构被调用,但是其引用计数>1导致资源没有被释放。掌握该引用计数的指针又在等你释放。最终导致谁都不会释放资源。
使用weak_ptr:weak_ptr只是观察一份资源,当他被使用时,此weak_ptr有效,反之无效。weak_ptr不增加引用计数。
使用shared_ptr构造weak_ptr:weak_ptr<Obj> wk_ptr(shd_ptr);
wk_ptr.lock():尝试将wk_ptr转成shared_ptr,返回值是shared_ptr类型,引用计数+1。如果没有引用计数,返回nullptr。
wk_ptr.expired():返回weak_ptr是否掌握资源。
(追问)什么时候使用单向弱引用,什么时候使用双向弱引用?
单向弱引用(One-way weak reference)
指 两个对象 A 和 B 之间,只有一个对象持有另一个对象的 weak_ptr,而另一个对象仍然通过 shared_ptr 持有它。也就是说,只有一方使用了弱引用,打破了潜在的循环引用风险。
-
通常用于 子对象需要知道父对象,但父对象不依赖子对象,或者父对象生命周期更长 的情况。
双向弱引用(Two-way weak reference 或 Mutual weak reference)
指 两个对象 A 和 B 之间,互相持有对方的 weak_ptr,而不是 shared_ptr。也就是说,双方都使用弱引用来观察对方,谁也不拥有谁。
-
两个对象逻辑上是平等的、相互依赖的,但 谁也不应该“拥有”谁,即谁的生命周期都不应该由对方来决定。
-
比如:两个对等的模块、观察者与被观察者、图结构中的两个节点互相引用但逻辑上独立存在。
shared_from_this是做什么的?
当一个类需要使用自己的智能指针时,如果把this传入智能指针,出这个作用域时,p计数减到0,直接导致this被释放!也就是说,调用这一段代码之后就会直接析构,这个对象会被析构两次。
正确方法:继承std::enable_shared_from_this这个类。
随后使用shared_from_this()获取自己的智能指针,它的本质还是shared_ptr,所以引用计数+1。用了智能指针管理这个类就不推荐再以非智能指针的方式构造了。
在构造函数和析构函数严禁使用shared_from_this(),会直接报错。
【必问】unique_ptr是什么?
同一时间只会有一个unique_ptr管理这份资源,unique_ptr禁止被狭义上的拷贝,包括const引用也被禁止,只有传入纯右值可以。要想在函数间传递unique_ptr需要使用move将值完全变为右值。
ptr.reset(XX):ptr失去对该资源的管理权,析构该资源,并开始管理资源XX。
unique_ptr转换成shared_ptr:使用shared_ptr的构造把unique_ptr传进去,但注意unique_ptr需要move。
为什么一个裸的指针不要用两个shared_ptr管理?普通指针和智能指针同时指向一个对象时的析构问题?
都是用裸指针构造,两个智能指针就彼此不知道对方存在。会导致多个独立的引用计数控制块,从而可能引发多次析构(即重复释放内存),造成未定义行为(UB)。
【必问】make_shared为什么效率更高?
因为他只有一次new,而原始的shared_ptr不仅需要new原始资源,shared_ptr本身也需要new,这就是new两次。
make_shared怎么解决安全问题?
还是一次new还是两次的问题。
如果new两次:如果在 shared_ptr 构造之前发生异常(比如函数参数求值顺序不确定),new 出来的对象可能泄漏。
而make_shared 是一个函数,它先分配 + 构造对象 + 创建控制块 + 返回 shared_ptr,整个过程是原子性的,不会泄漏。
(追问)如果不能用new和make_shared,有没有什么方法可以不用它们来创智能指针?
使用自定义内存分配(比如 malloc、内存池、静态内存、placement new)。
示例:用 malloc + placement new + shared_ptr(需要自定义删除器)。
-
用 malloc 分配内存(不是 new!)
-
用 placement new 在分配的内存上构造对象
-
定义一个删除器:先调用析构函数,再用 free 释放内存
-
用 shared_ptr 管理该对象,并传入自定义删除器
-
当 ptr 离开作用域,自定义删除器会被调用,安全析构 + 释放内存
(追问)类构造函数私有化还能用make_shared吗?
默认情况下,如果类的构造函数是 private 的,你不能直接使用 std::make_shared<T>() 来构造该类的对象,因为 make_shared 是在 T 的外部(通常是全局函数或其它类)调用的,它没有权限访问 private 构造函数。
解决方法:使用工厂模式,或者设置一个友元函数来创建智能指针。
【必问】智能指针的线程安全问题?
std::shared_ptr:
-
多个线程同时读:安全。
-
引用计数变化:安全的,它的引用计数的增减通过原子操作实现的。
-
多个线程同时修改:不安全。
std::unique_ptr:
-
多个线程同时读:安全。
-
多个线程间move
unique_ptr:不安全。
std::weak_ptr:
-
多个线程同时读:安全。
-
多个线程同时
weak_ptr::lock()得到shared_ptr:安全(返回的是新对象)。 -
多个线程同时修改得到的
shared_ptr:不安全。
解决方案:
-
保护 shared_ptr 自身(防止多线程修改):如果多个线程可能 同时 reset、赋值 或拷贝同一个
shared_ptr对象,你需要加锁。 -
保护智能指针指向的对象:即使你安全地管理了
shared_ptr,如果多个线程访问它指向的对象,该对象的数据成员或方法也可能是不安全的。
智能指针的删除器是什么?对sizeof有何影响?
-
默认删除器:sizeof无影响
-
无状态删除器(空类仿函数):空类被优化,sizeof无影响
-
有状态删除器(有成员仿函数):有影响,需要加上删除器成员,并内存对齐
-
函数指针删除器:多一个函数指针,多8
-
std::function删除器:多32
-
普通函数
-
类成员函数
-
函数对象
-
lambda 表达式
-
std::bind 返回结果
-
如果一个项目所有的指针都是shared_ptr和weak_ptr,但是最后有内存泄漏会是什么原因呢?
-
weak_ptr虽然用了但用错了地方,依然有循环引用。
-
过度使用 shared_ptr,甚至在数据结构、树、图、观察者模式等中形成了复杂的引用网,导致对象生命周期被无意延长。
-
第三方库或异步代码持有
shared_ptr。比如你将shared_ptr传给了某个 C 接口、线程、异步任务、网络库、定时器等,而它们在后台持有了该指针且未释放。
3.6 模板高级
【必问】模版的特化和偏特化是什么?
类模板的特化更为常见,分为两种:
(1)全特化(Explicit/Full Specialization)——为所有模板参数指定具体类型
(2)偏特化(也称为部分特化)是介于通用模板和全特化之间的一种形式,它并不为所有模板参数指定具体类型,而是对一部分参数或类型模式进行特化。
类模板实参推导是什么?
它极大地简化了使用类模板时的代码书写,让你在创建模板类的对象时,不再需要显式地写出模板参数(比如 std::vector<int> 中的 <int>),编译器可以根据构造函数的实参自动推导出模板参数的类型。
编译器是通过 构造函数的实参 来推导模板参数的。也就是说:CTAD 的本质是:根据你调用类模板的构造函数时传入的实参,来推断出模板参数应该是什么类型。
那我如果写std::vector v(100);,这样只是说明v长度100,这样还能自动推导出来吗?
编译器会自动推导为 std::vector<int>,即 v 的类型是 std::vector<int>,并且它包含 100 个值为 0 的元素。
那我使用{1, 1.1, 'e', "asdasdasd"}这样的列表推导式让vector自动推导,会发生什么?
无法推导出合理的T,导致编译失败。
std::enable_if_t是什么?
std::enable_if 是 C++11 引入的一个 模板元编程工具,定义在头文件 <type_traits> 中,它的作用是:
根据一个编译期布尔条件(
bool值),决定是否“启用”某个类型。如果条件为 true,则定义一个名为type的内部类型;如果为 false,则不定义type。
std::enable_if_t 是 C++14 引入的 类型别名模板(type alias template),是 std::enable_if<B, T>::type 的简写!
模板里的参数可以使用模板吗?模板模板参数是什么?
是的,C++ 模板的参数本身也可以是模板,这是 C++ 模板中一个非常强大且高级的特性,称为:模板模板参数(Template Template Parameter)。
模板的可变参数“形参包”是什么?
在模板参数列表中,如果你想定义一个可以接受任意多个类型的模板,你可以使用 ... 来表示一个类型参数包。
-
typename... Args表示:Args 是一个模板类型参数包,它可以代表零个或多个类型。 -
Args并不是一个具体的类型,而是一个包(pack),里面可以包含比如int, double, std::string等等。
这个 Args... 就是所谓的 形参包(parameter pack)。
模板形参包如何展开?
-
不展开,但是也达到了展开的效果(经典的递归print案例)
-
模板参数展开:使用这个模板的时候自动展开
-
初始化列表展开(常用于函数调用或表达式执行)
-
折叠表达式!!!
形参包折叠表达式是什么?
折叠表达式(Fold Expression) 是 C++17 提供的一种语法糖,用于对形参包(parameter pack)中的所有元素进行某种运算(如 +、*、&&、, 等),而无需手动编写递归或复杂的展开逻辑。
简单说就是:用一种简洁的语法,对一堆参数(打包在形参包里)进行“折叠”成一个结果,或者执行某种操作。
折叠表达式有四种形式:
(pack op ...): 一元右折叠,从右往左,将 pack 中每个元素用 op 连接
( ... op pack):一元左折叠,从左往右,将 pack 中每个元素用 op 连接
(pack op ... op init):二元右折叠,带初始值,从左往右展开
(init op ... op pack):二元左折叠,带初始值,从左往右展开(更常见)
变量可以是模板吗?
从 C++14 开始,C++ 标准正式支持了 变量模板(Variable Templates),也就是说,你可以定义一个 模板化的变量,其类型或初始值可以依赖于模板参数。
-
一般是编译期就可确定值,所以加
constexpr。 -
它是一个模板,所以你可以为不同的
T定义同一个名字的变量,编译器会根据实际使用的类型进行实例化。
3.7 C++多线程库
3.7.1 thread
std::this_thread有哪些功能?
最常用的功能包括:
-
std::this_thread::get_id():获取当前线程的唯一标识符(std::thread::id类型),可以用来区分不同的线程,比如判断当前线程是否是某个特定线程。 -
std::this_thread::sleep_for(duration):让当前线程休眠一段指定的时间,比如sleep_for(std::chrono::seconds(1))就是让当前线程暂停 1 秒,常用于定时、限速或等待某些条件。 -
std::this_thread::sleep_until(time_point):让当前线程休眠直到某个指定的时间点,比sleep_for更灵活,适用于精确控制唤醒时刻。 -
std::this_thread::yield():提示调度器当前线程愿意让出 CPU,让其他线程有机会运行,通常用于忙等待循环中,避免过度占用 CPU 资源,但不保证一定切换线程,只是给调度器一个建议。
std::thread构造时,第一个参数是一个函数,后续是函数的参数。假设函数签名里需要参数是引用,如何以引用的方式传递这个参数?
法一:lambda函数。std::thread t([=, &c]{add(a, b, c);});。
法二:使用ref。std::thread t(add, a, b, std::ref(c));。如果是const引用则是使用cref。
std::atomic<T> XX这种原子变量对于哪些操作能够原子化?有哪些局限性?
对于一般类型,例如int,++、--、加减乘除都是原子化的。
局限性:只能锁住单个变量,要锁住一整个区域还得是mutex。
如何移交std::thread线程归属权?
在 C++ 中,std::thread 不支持直接复制(copy),但 支持移动(move)。这意味着你不能将一个 std::thread 对象直接赋值给另一个 std::thread 对象(比如通过 = 赋值),但你可以 通过移动语义(std::move)将线程的所有权(ownership)从一个 std::thread 对象转移到另一个 std::thread 对象。
如果主函数结束了,被detach的线程会发生什么?
detach 的线程仍然属于当前进程,是进程的一部分。如果进程结束(比如 main() 返回),那么整个进程包括所有线程都会被操作系统直接终止。
当 main() 函数(主线程)结束时,整个 C++ 程序(进程)会立即终止,所有其他线程(包括被 detach() 的线程)也会被强制结束,无论它们是否执行完毕。
3.7.2 mutex
mutex临界区内允许抛出异常吗?
原始情况下,如果临界区内抛出了异常,mtx.unlock() 将不会被调用,mutex 将一直被锁住,导致其他线程永久阻塞(死锁)。
在 C++ 中使用 std::mutex 的临界区内是允许抛出异常的。但必须使用 RAII 机制(如 std::lock_guard 或 std::unique_lock)来管理锁的生命周期,它们利用 构造函数加锁,析构函数解锁 的机制,确保即使临界区内抛出异常,也能在栈展开(stack unwinding)时正确释放锁,避免死锁和资源泄漏。
如何在常函数内使用mutex?
如果你需要在 const 成员函数中使用 mutex(比如为了线程安全地读取共享数据),你需要将 mutex 成员变量声明为 mutable。这样就可以在 const 函数中调用 mutex 的非 const 方法(如 lock() 和 unlock()),同时不违背 const 成员函数的语义。
更好的方法是自己写一个类或者使用std::lock_guard,利用构造和析构加锁解锁,不受常函数影响。
使用std::lock_guard添加临界区时遇到异常,需要回滚到刚刚添加std::lock_guard的时机,有无方法?
虽然 std::lock_guard 自身 不能回滚已经执行的代码逻辑(比如你已经修改了某些数据),但它能保证 锁一定被释放。
你可以 将可能抛异常的代码放在 try-catch 块中,这样:你可以在 catch 块中执行 异常处理 / 回滚逻辑。
【必问】如何妙用std::lock函数防止死锁?
std::lock(mutex1, mutex2, ...)
作用:一次性锁住多个互斥量(mutex),并且能保证不会导致死锁。
这是防止死锁的 关键工具之一,它的内部使用了类似 死锁避免算法(比如按固定顺序加锁 或 锁排序),但作为使用者,你 不需要手动去排序或判断加锁顺序。
注意:std::lock 不会自动释放锁,你需要自己用 lock_guard 管理。
adopt_lock表示锁已经被拿到。
【必问】unique_lock是什么?他和lock_guard有什么不同?
std::unique_lock也是用于管理互斥锁的 RAII 类,但比 lock_guard 更 灵活。
-
std::unique_lock可以手动加锁/解锁,lock_guard不可以。 -
std::unique_lock可以延迟加锁,lock_guard不可以。 -
std::unique_lock可以与std::condition_variable配合使用,lock_guard不可以。 -
std::unique_lock性能稍低但是更灵活,lock_guard性能更高。 -
std::unique_lock可移动(move),lock_guard不可以。
unique_lock构造使用adopt_lock,再lock会有什么问题?
std::adopt_lock 是一个 空结构体标签(tag type),用于告诉 std::unique_lock 或 std::lock_guard:“不要尝试去加锁,这个互斥量已经被其他方式锁定了,请直接“接管”它的锁状态。”
也就是说,使用 std::adopt_lock 的前提是:该 mutex 对象已经被 lock() 过了,而且仍然处于锁定状态!
所以unique_lock构造使用adopt_lock的前提条件:传入的 mtx 必须 已经被 lock(),且仍然处于锁定状态。
如果你违反这个前提,行为是未定义的(UB, Undefined Behavior)!
scoped_lock是什么?
在 C++17 中,标准库引入了一个新的 RAII 锁管理工具:std::scoped_lock。它是为了更安全、更方便地 同时锁定多个互斥量(mutexes) 而设计的,解决了传统上使用多个 std::lock_guard 或 std::unique_lock 时可能导致的死锁问题。
std::scoped_lock 是一个 RAII(资源获取即初始化)风格的模板类,用于在构造时 锁定一个或多个互斥量(比如 std::mutex),并在析构时 自动释放所有锁。
它类似于 std::lock_guard,但 支持同时锁定多个 mutex,并且 内部使用了 std::lock 的算法来避免死锁。
shared_mutex是什么?
读写锁:
-
它允许并发的多个读操作(共享访问);
-
但写操作是独占的,不可与其他读或写并发。
它支持:
-
多个线程同时以共享(读)模式加锁(通过
std::shared_lock); -
单个线程以独占(写)模式加锁(通过
std::unique_lock或std::lock_guard)。
conditional_variable是什么?
std::condition_variable 允许一个或多个线程等待某个条件变为真,并在条件可能成立时由其他线程通知它们继续执行。
latch和barrier的用法?
std::latch:让多个线程等待某个事件发生(比如初始化完成),计数减到 0 后,所有等待的线程被释放,且 latch 不可重用。
std::barrier:让多个线程在某个同步点等待,所有线程都到达后一起放行,并且可以重复使用(循环同步),非常适合迭代任务。
3.7.3 async
std::async是做什么的?
std::async 是 C++11 引入的一个非常方便的高级工具,用于异步执行任务,定义在 <future> 头文件中。
可选的策略(在 <future> 中定义):
异步执行:任务会在一个新的线程中异步执行,是真正的并发。
延迟同步执行(懒执行):任务不会立即执行,而是推迟到调用 .get() 或 .wait() 时,在当前线程同步执行。
std::future<T>是做什么的?
std::future<T> 是一个模板类,表示一个异步操作的结果将在未来某个时刻可用,这个结果的类型是 T。
-
获取异步结果:通过 .get() 获取异步任务返回的值(只能调用一次!)。如果任务没结束,这里会阻塞。
-
等待任务完成:通过 .wait() 阻塞当前线程,直到异步任务完成。
-
检查是否就绪:通过 .wait_for() 和 .wait_until() 检查任务是否完成(非阻塞或限时等待)。
std::packaged_task是做什么的?
std::packaged_task 的作用是:把一个函数(比如普通函数、lambda、成员函数等)包装成一个“任务”,当这个任务被执行时,它的返回值会被自动存入一个 std::future 对象中,方便你后续获取结果或捕获异常。
std::promise是做什么的?如何与std::future配合工作?
std::promise 是 C++11 引入的一个用于线程间异步传递结果或异常的工具,定义在 <future> 头文件中。它通常与 std::future 配合使用,用于手动设置某个异步操作的结果(或异常),然后其他线程可以通过关联的 std::future 获取这个值。
3.8 C++20最新特性
3.8.1 约束和概念
什么是概念?
概念(Concept) 是对模板参数类型的一组约束的命名集合。它是一种编译期的谓词(predicate),用于描述什么样的类型是可以被接受的。
你可以把 概念 理解为:给模板参数加“条件”或“要求”的一种方式,让模板只接受满足某些条件的类型。
什么是约束?
约束(Constraint) 是概念背后的核心机制,指的是对类型所加的限制条件。你可以把约束看作是用于限制模板参数的“规则”或“条件表达式”。
实际上,概念就是一种命名的、可重用的约束,而约束本身可以是更一般化的、内联写在 requires 中的条件。
概念和约束如何使用?
-
使用预定义的概念(如
<concepts>头文件中的)
C++20 标准库在 <concepts> 头文件中提供了一些常用的预定义概念,比如:
-
std::integral<T>:T 是整型(如 int, long) -
std::floating_point<T>:T 是浮点类型 -
std::same_as<T, U>:T 和 U 是相同类型 -
std::movable<T>:T 是可移动的类型 -
std::invocable<F, Args...>:F 可以被调用,参数为 Args...
-
自定义概念
-
使用
requires子句(内联约束)
requires 表达式是什么?
requires 表达式是 C++20 中用于描述类型是否支持某些操作或具有某些属性的一种语法结构。它返回一个布尔值:该类型是否满足你提出的要求。
3.8.2 模块
模块是什么?
模块是一种新的代码组织方式,它将代码封装成独立的、可重用的单元,并明确地导出(export)需要对外公开的接口,隐藏不需要暴露的实现细节。
模块在编译时是独立编译的,生成一个模块接口单元(module interface unit)的编译结果(通常是一个二进制中间表示),后续的其他编译单元(如其他模块或普通源文件)可以高效导入(import)该模块,而无需重新解析其内容。
C++20 模块主要包含两种类型的文件/单元:
-
模块接口单元(Module Interface Unit)
-
模块实现单元(Module Implementation Unit,可选)
一个文件里可以声明和定义多个模块吗?
不能在一个 .cpp 文件(或模块接口单元)中同时定义多个模块。每个文件只包含一个 export module 模块名; 或 module 模块名; 声明,对应一个独立的模块。
模块分区是什么?
C++20 也引入了模块分区(Module Partitions)的机制,允许你将一个模块拆分成多个逻辑部分(分区),便于代码组织,比如:
-
module A;是主模块 -
module A:part1;和module A:part2;是它的分区
但每个分区也必须定义在单独的翻译单元中(通常是一个文件),虽然它们属于同一个模块逻辑,但仍然每个文件只能定义一个分区(即一个模块声明)。
在模块的“实现单元”里的函数,也用了export,这个函数能正常export吗?
不会,export 只在模块接口单元中有效。
如何使用模块解决模板不能分文件写的问题?
使用 C++20 模块,你可以将模板的声明与定义分离到不同的模块文件中(比如 .ixx 或 .cppm),并且仍然能正常使用!
3.8.3 范围
范围库是什么?
C++ 的范围库(Ranges Library)是 C++20 引入的一个现代化、功能强大的库,它提供了一种更简洁、更声明式(declarative)和更高效的方式来处理数据序列(比如数组、容器、生成器等)。范围库构建在标准算法(如 std::sort, std::transform 等)之上,通过引入范围(range)和视图(view)的概念,使得对数据的操作更加直观和灵活,避免了传统迭代器操作中的冗余和复杂性。
在范围库中,范围指的是一个可以顺序访问一系列元素的对象,通常具有开始(begin)和结束(end)的概念,就像标准容器(如 std::vector)或数组一样。
简单来说,范围就是一组可以迭代的元素序列。
视图是什么?
视图(View) 是范围库中的一个核心概念,它是对某个范围的轻量级、非拥有(non-owning)、惰性求值(lazy-evaluated)的“视图”。
-
非拥有:视图不复制数据,只是引用原始数据。
-
惰性求值:视图的转换操作(如过滤、映射)不会立即执行,而是在你真正遍历它时才进行计算。
-
可组合:多个视图可以像管道一样连接在一起,形成处理流水线。
范围适配器是什么?
范围通过适配器进行操作,获得视图,视图也是范围的一种。
这些是用来对范围进行各种操作的工具,比如过滤、转换、排序等。它们通常以 管道操作符 | 的形式使用,非常直观。
范围的管道操作符的运算机制是什么?
这是 C++ 中通过重载 operator| 实现的语法糖,用于将一个范围(或视图)传递给一个范围适配器(range adaptor),最终生成一个新的视图。
-
左操作数(左边):是一个范围(range) 或者 视图(view)
-
右操作数(右边):是一个范围适配器(如
std::views::filter,std::views::transform)
范围算法有哪些常见的?
C++20 对传统算法进行了扩展,提供了范围版本的算法,比如:
-
std::ranges::sort(r)—— 对范围r排序 -
std::ranges::find(r, value)—— 在范围中查找值 -
std::ranges::copy(r, out)—— 复制范围到输出迭代器 -
std::ranges::transform(r, out, f)—— 转换并输出
3.8.4 协程
【必问】协程是什么?协程 vs 线程的区别?
协程是一种可以暂停和恢复执行的函数,它可以在执行过程中暂停,把执行权交给其他协程,之后再从暂停的地方继续执行。
它的调度不依赖于操作系统的线程调度器,而是由程序员控制或者由语言运行时来管理。
协程的特点(和线程的不同):
-
轻量级:创建和切换开销极小,一个线程中可以运行成千上万个协程。
-
非抢占式调度:协程通常需要主动让出执行权,而不是被强制中断。
-
单线程内并发:多个协程可以在一个线程中交替执行,实现并发效果。
-
不依赖操作系统:协程是由程序语言或库提供的功能,不是操作系统直接支持的。
协程 vs 线程调度机制有什么不同?
-
用户级线程(User-Level Thread, ULT)
-
定义:由 用户空间的线程库(如 POSIX threads 的某些实现、Java 早期版本等)管理,操作系统内核感知不到这些线程的存在。
-
特点:
-
线程的 创建、销毁、同步、调度 都是在 用户态 完成的。
-
操作系统 只看到一个进程(一个内核线程),看不到进程内部的用户级线程。
-
线程调度由用户级线程库负责(例如:一个用户线程库维护一个线程调度队列)。
-
-
内核级线程(Kernel-Level Thread, KLT)
-
定义:由 操作系统内核直接支持和管理的线程,内核 完全知晓每一个线程的存在,并负责其调度与管理。
-
特点:
-
线程的 创建、调度、同步、销毁 都需要通过 系统调用,由 内核完成。
-
操作系统调度器直接调度的是 内核线程,每个内核线程可被单独调度到 CPU 上运行。
-
通常与进程的关系是:一个进程可以包含多个内核线程。
-
有栈协程 vs 无栈协程有什么区别?
有栈协程(Stackful Coroutine):
定义: 每个协程都拥有独立的调用栈(call stack),包括局部变量、返回地址等。协程可以在任意函数调用层次中被挂起(yield)和恢复(resume)。
特点:
-
每个协程有自己完整的调用栈。
-
可以在协程的任何位置(包括深层函数调用中)挂起执行。
-
挂起时保存整个栈状态,恢复时还原。
-
调度灵活,可以在任意函数调用点进行 yield/resume。
优点:
-
灵活性高:可以在任何函数调用深度暂停和恢复。
-
编程模型自然:跟普通函数调用体验接近,不需要限制代码结构。
缺点:
-
内存开销大:每个协程都要分配独立的栈(通常是几KB~几MB,取决于系统和配置)。
-
实现复杂:需要管理多个栈空间,上下文切换较复杂。
-
通常由语言运行时或底层库提供支持(比如 C++ 的 Boost.Coroutine、Go 的 goroutine、Lua 的 coroutine 等)。
无栈协程(Stackless Coroutine):
定义: 协程不拥有独立的调用栈,它的状态保存是通过编译器生成的状态机来实现的。协程只能在特定的挂起点(如 await/async 标记处)暂停和恢复。
特点:
-
没有独立的栈,协程状态靠编译器生成的状态机和局部变量保存。
-
协程的挂起(yield)和恢复(resume)必须在特定的点(如
await、yield关键字处)进行,不能随意在任意函数调用中挂起。 -
通常与异步编程模型结合紧密,比如
async/await。
优点:
-
内存开销小:不需要为每个协程分配独立栈,只需要保存少量状态。
-
实现相对简单:很多情况下由编译器在编译期生成状态机。
-
与现代异步编程模型天然契合,比如
async/await。
缺点:
-
灵活性较差:只能在标记了挂起点的位置暂停(比如
await),不能在任意函数调用中随意挂起。 -
代码结构要求更高:需要按照特定的异步模型组织代码(如不能在普通函数中随意调用挂起函数)。
对称协程 vs 非对称协程有什么区别?
对称协程(Symmetric Coroutine):
定义: 所有的协程在调度上是平等(对称)的,任何一个协程都可以主动挂起自己,并且可以恢复(resume)其他任意协程。协程之间可以相互切换,没有明确的“调用者”和“被调用者”的严格区分。
-
协程 A 可以挂起自己,然后切换到协程 B;
-
协程 B 也可以挂起自己,然后切换回协程 A 或切换到协程 C;
-
任意协程之间可以相互 yield / resume,地位平等。
非对称协程(Asymmetric Coroutine):
定义: 协程之间存在明显的调用与被调用关系(主从关系),通常分为两类角色:
-
调用者(Caller / Caller Coroutine)
-
被调用者(Callee / Callee Coroutine)
-
通常是一个协程(比如主协程)启动另一个协程,被启动的协程在某个时刻可以挂起(yield),并将控制权返回给调用者。
-
控制权通常只能沿着调用栈的方向传递,即由“调用者”发起,“被调用者”挂起后返回给调用者,不能随意跳转到任意其他协程。
-
换句话说:非对称协程的控制流是单向的、有明确调用关系的,类似函数调用与返回。
C++20代码里如何判断一个函数是不是协程?
协程是可以暂停的函数,在C++20里一个使用了以下任一关键字的函数就是协程:
-
co_await:暂停协程的执行,直到某个操作完成(比如异步I/O)。表达式必须是一个 Awaitable 类型。 -
co_yield:暂停协程并返回一个值(常用于生成器模式),之后可以从暂停点恢复。 -
co_return:从协程中返回最终结果并结束协程(不与普通 return 混用)。
co_await是做什么的?
co_await后接的那玩意儿如果是自定义的,则需要定义三个函数:
-
await_ready():告诉协程“现在能不能直接执行?” -
await_suspend():如果要暂停,接下来该干啥? -
await_resume():恢复时,返回什么结果?
co_return是做什么的?
它类似于普通函数的 return,但是:
-
是专门为协程设计的;
-
它会触发协程的最终挂起(final suspend)流程;
-
它会将控制权交还给协程的调用者或恢复点,并可能传递一个返回值;
-
它标志着协程正常结束(而不是被销毁或中途暂停)。
在 C++20 协程中,每个协程函数都有一个关联的 Promise 类型(promise_type),它定义了协程的行为。
co_yield是做什么的?
co_yield 是 C++20 协程(coroutine) 中的关键字之一,用于从协程中返回一个值,并暂停协程的执行,但保留协程的状态,以便后续可以恢复执行并继续生成下一个值。它最常用于实现 生成器(Generator)模式,也就是那种可以一个一个“惰性生成”值的函数,比如生成数列、遍历大数据流等。
它的作用是:
-
返回
some_value给调用者(通常是协程的调用方或封装类); -
暂停当前协程的执行;
-
保留协程的局部变量和执行位置;
-
等待下次被恢复时,从
co_yield的下一条语句继续执行。
2502

被折叠的 条评论
为什么被折叠?



