1. 怎么实现死锁检测的工具?
实现 C++ 死锁检测工具,本质上是拦截锁操作并寻找线程间的循环等待关系。以下是实现此类工具的 4 个核心步骤:
1. 核心原理:构建“等待图”(Wait-For Graph) 把每个线程看作图里的“节点”。如果线程 A 想要获取被线程 B 占用的锁,就在 A 和 B 之间建立一条有向边(A -> B)。如果在这个动态维护的图里发现了“环”(例如 A -> B -> C -> A),就说明发生了死锁。算法层面,这就变成了一个经典的有向图环路检测问题。
2. 拦截并接管锁操作(Hook) 你的工具需要感知每一次加锁和解锁。有两种常见做法:
- 侵入式(封装标准库): 自己写一个类封装
std::mutex,在lock()和unlock()前后插入你自己的记录逻辑。这要求业务代码修改现有的锁类型。 - 非侵入式(底层劫持): 在 Linux 环境下,最主流的做法是利用
LD_PRELOAD环境变量劫持pthread_mutex_lock和pthread_mutex_unlock等底层 POSIX 函数。这样业务代码无需修改甚至无需重新编译,就能被你的工具监控。
3. 状态维护与环检测触发 在拦截到锁操作后,你需要维护两个全局的映射关系:一个是“锁对象 -> 当前持有的线程 ID”,另一个是“线程 ID -> 正在等待的锁对象”。 每次线程因获取不到锁而陷入等待时,更新这张图。为了不严重拖累业务性能,不要在每次加锁时都去检测环。通常的做法是单开一个后台监控线程,每隔几秒钟对这张有向图跑一次**深度优先搜索(DFS)算法来查环。
4. 现场还原(堆栈回溯) 只报出“发生死锁”是不及格的工具。在线程成功获取锁的那一瞬间,你可以利用 C++ 的 backtrace 或 libunwind 库记录下当前的函数调用栈。当后台线程检测到死锁环时,直接把环路上所有线程的 ID、争抢的锁地址,以及它们各自加锁时的代码堆栈打印出来,让开发者能一眼看出版问题出在哪一行。
2. keyword
2.1 alignas
用途:指定变量或类型的对齐要求(内存对齐)。
语法:alignas(expression) 或 alignas(type)
示例:
int x;
int y;
alignas(16) int z; // 按16字节对齐
alignas(16) int d;int main()
{cout << &x << endl << &y << endl;cout << &z << endl << &d << endl;// 0x6011a0// 0x6011a4// 0x6011b0// 0x6011c0return 0;
}
注意:
- 对齐值必须是2的幂次方。
- 受实现限制,不能小于类型的自然对齐。
2.2 alignof
用途:查询类型的对齐要求(返回std::size_t)。
语法:alignof(type)
示例:
int x;
int y;
alignas(16) int z; // 按16字节对齐
alignas(16) int d;int main()
{cout << alignof(x) << ' ' << alignof(z) << endl; // 4 16return 0;
}
注意:
- 返回的值是类型的内存对齐字节数。
2.3 asm
用途:嵌入汇编代码(编译器相关,非标准)。
语法:asm("assembly code");
示例:
asm("mov eax, 5"); // x86汇编(具体语法依赖编译器)
注意:
- 行为由编译器定义,不同编译器(如GCC/MSVC)语法不同。
- 现代C++推荐使用标准库替代内联汇编。
2.4 export
export 是 C++98/03 引入的关键字,用于支持模板的分离编译(即将模板的声明和定义分别放在不同文件)。然而,由于实现复杂且编译器支持有限,它在 C++11 中被标记为弃用(deprecated),并在 C++17 中正式移除。目前,export 在标准 C++ 中没有实际作用,但仍作为保留关键字存在(避免破坏旧代码)。
2.4.1 export 的原始设计目的
在 C++98/03 中,模板通常必须在头文件中完整定义(因为编译器需要看到完整的模板代码才能实例化)。export 的初衷是允许:
- 声明模板时使用
export,告诉编译器该模板的定义在其他翻译单元(.cpp文件)。 - 实现模板的分离编译,类似普通函数的声明和定义分离。
语法示例(C++98/03)
// 头文件 (example.hpp)
export template<typename T>
void foo(T x); // 声明模板,并标记为 export// 源文件 (example.cpp)
export template<typename T>
void foo(T x) { // 实际定义std::cout << x << std::endl;
}
理论上,其他 .cpp 文件可以包含 example.hpp 并使用 foo,而无需看到其定义。
2.4.2 为什么 export 被弃用?**
(1) 编译器支持极其有限
- 只有 EDG(Edison Design Group) 的前端编译器(如 Comeau C++)真正实现了
export。 - 主流编译器(如 GCC、Clang、MSVC)从未支持,因为实现复杂且性能开销大。
(2) 分离编译的实际收益低
- 模板的分离编译不能显著减少编译时间(仍需解析模板定义)。
- 现代 C++ 更倾向于 显式实例化(explicit instantiation) 或 模块(C++20 Modules) 来管理模板编译。
(3) C++11 开始弃用,C++17 移除
- C++11 标准将
export标记为 deprecated(弃用)。 - C++17 正式移除该关键字,不再有任何标准用途。
2.4.3 现代替代方案
(1) 显式实例化(Explicit Instantiation)
如果希望减少模板编译开销,可以在 .cpp 文件中显式实例化模板:
// 头文件 (example.hpp)
template<typename T>
void foo(T x); // 仅声明// 源文件 (example.cpp)
template<typename T>
void foo(T x) { // 完整定义std::cout << x << std::endl;
}// 显式实例化(只生成 int 和 double 版本)
template void foo<int>(int);
template void foo<double>(double);
这样,只有 int 和 double 版本的 foo 会被编译,其他类型会报链接错误。
(2) C++20 Modules(模块化)
C++20 引入 Modules,提供更高效的模板编译方式:
// 模块文件 (example.ixx)
export module example;export template<typename T>
void foo(T x) { // 直接导出模板定义std::cout << x << std::endl;
}
使用模块后,模板的定义只需编译一次,并可以被其他模块导入(无需重复解析头文件)。
2.5 register
用途:提示编译器将变量存储在寄存器中(C++11起弃用,C++17移除)。
register int i; // C++03及之前:建议编译器优化
现状:
- 现代编译器自动优化,无需手动指定。
- C++17后保留为关键字(兼容性),但无实际作用。
2.6 typeid
用途:获取类型或表达式的运行时类型信息(RTTI)。
语法:typeid(type) 或 typeid(expression)
需头文件:<typeinfo>
示例:
std::cout << typeid(int).name(); // 输出类型名称(如"i")
Base* ptr = new Derived();
if (typeid(*ptr) == typeid(Derived)) { /* 类型检查 */ }
注意:
- 对多态类型(有虚函数)返回动态类型,否则返回静态类型。
- 可能抛出
std::bad_typeid异常(对空指针解引用时)。
2.7 volatile
用途:防止编译器优化对变量的读写(用于硬件寄存器、多线程等)。
A volatile specifier is a hint to a compiler that an object may change its value in ways not specified by the language so that aggressive optimizations must be avoided.
volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
不只是内嵌汇编操纵栈这种方式属于编译无法识别的变量改变,另外更多的可能是多线程并发访问共享变量时,一个线程改变了变量的值,怎样让改变后的值对其它线程 visible。
语法:volatile T var;
示例:
volatile int sensorValue; // 编译器不会优化对此变量的访问
注意:
- 每次访问都直接从内存读取/写入。
- 不同于原子性(
std::atomic),不保证线程安全。 - 在C++中,
volatile不用于多线程同步(需用std::atomic)。
2.8 extern
extern 是 C 和 C++ 中用于控制变量函数链接属性(linkage)的关键字,主要作用是指明某个标识符(变量或函数)具有外部链接(external linkage),即可以在多个源文件之间共享。以下是它的核心用法和注意事项:
2.8.1 2.8.1 基本作用
(1) 声明外部变量或函数
extern 的主要用途是告诉编译器:
“这个变量或函数的定义在其他文件中,链接时再找它的实际地址。”
示例(跨文件共享全局变量)
// File1.c
int globalVar = 42; // 定义全局变量// File2.c
extern int globalVar; // 声明(非定义),表示变量在其他文件
void foo() { printf("%d", globalVar); } // 正确访问
对比(不使用 extern 的后果)
// File2.c(错误写法)
int globalVar; // 编译器会视为定义,导致链接冲突(多重定义)
(2) 声明外部函数(C 和 C++ 的默认行为)
在 C/C++ 中,函数的默认链接属性是 extern,所以通常可以省略:
// File1.c
void func() { ... } // 默认是 extern 的// File2.c
extern void func(); // 显式声明(可省略 extern)
func(); // 正常调用
2.8.2 2.8.2 关键用法
(1) 在头文件中声明全局变量**
为避免多重定义,头文件中应使用 extern 声明变量:
// globals.h
extern int globalVar; // 声明(非定义)// globals.c
int globalVar = 42; // 实际定义// main.c
#include "globals.h"
printf("%d", globalVar); // 正确访问
(2) 结合 const 使用(C++ 特有)
在 C++ 中,const 全局变量默认是内部链接(仅在当前文件可见),若需跨文件共享需加 extern:
// constants.h
extern const int MAX_SIZE; // 声明// constants.cpp
extern const int MAX_SIZE = 100; // 定义(需显式写 extern)// main.cpp
#include "constants.h"
std::cout << MAX_SIZE; // 输出 100
(3) 与 "C" 联合使用(C++ 调用 C 代码)
在 C++ 中,通过 extern "C" 禁止名称修饰(name mangling),确保与 C 库兼容:
// mylib.h
#ifdef __cplusplus
extern "C" { // C++ 编译器会按 C 风格处理
#endifvoid c_function(); // 无名称修饰
#ifdef __cplusplus
}
#endif
2.8.3 常见问题**
(1) extern 和 static 的区别
| 关键字 | 链接属性 | 作用域 |
|---|---|---|
extern |
外部链接 | 跨文件共享 |
static |
内部链接 | 仅当前文件可见 |
(2) 未初始化的 extern 变量
extern int x; // 声明(未定义,不分配内存)
extern int y = 10; // 错误!声明不能初始化(需在定义时初始化)
(3) C 和 C++ 的差异
- C:
const全局变量默认是外部链接(不加extern也可跨文件访问)。 - C++:
const全局变量默认是内部链接(需显式extern才能共享)。
2.8.4 总结
| 场景 | 用法示例 | 说明 |
|---|---|---|
| 声明外部变量 | extern int var; |
告诉编译器变量定义在其他文件 |
| 声明外部函数 | extern void func(); |
通常可省略(函数默认 extern) |
| C++ 共享 const | extern const int VALUE; |
覆盖默认的内部链接属性 |
| C/C++ 混合编程 | extern "C" { ... } |
确保 C++ 兼容 C 的函数命名规则 |
2.8.5 核心规则
- 声明 vs 定义:
extern仅用于声明,不能直接初始化(定义时再初始化)。 - 头文件最佳实践:全局变量应在头文件中用
extern声明,在.c/.cpp中定义。 - C++ 的
const:需显式extern才能跨文件共享。
3. C++的输入输出方式
严格来说,C++ 并未定义任何输入输出语句,取而代之,通过一个全面的标准库来提供 I/O 机制。iostream 库下的 istream 和 ostream 分别表示输入流和输出流。一个流就是一个字符序列,是从 I/O 设备读出或者写入 I/O 设备的。术语“流”想要表达的是,随着时间的推移,字符是顺序生成或消耗的。就像水流一样。I/O 标准库定义了 4 个 I/O 对象,分别是 cin,cout,cerr,clog。
| 特性 | cout |
cerr |
clog |
|---|---|---|---|
| 用途 | 正常输出 | 错误或警告信息输出 | 日志或调试信息输出 |
| 缓冲行为 | 缓冲输出,延迟到缓冲区满或刷新时输出 | 非缓冲输出,立即输出 | 缓冲输出,延迟输出 |
| 输出流类型 | 标准输出(通常是控制台) | 标准错误(通常是控制台) | 标准日志(通常是控制台) |
"流"就是"流动",是物质从一处向另一处流动的过程,比如我们能感知到的水流。C++的流是指信息从外部输入设备(如键盘和磁盘)向计算机内部(即内存)输入和从内存向外部输出设备(如显示器和磁盘)输出的过程,这种输入输出过程被形象地比喻为"流"。
- 通常标准输入或标准输出设备显示器称为 “标准流”;
- 外存磁盘上文件的输入输出称为 “文件流”;
- 对于内存中指定的字符串存储空间称为 “字符串流”。
那么流的内容通常是什么呢?流里的基本单位是字节,所以又称为字节流。字节流可以是ASCII字符、二进制数据、图形图像、音频视频等信息。文件和字符串也可以看成是有序的字节流,又称为文件流和字符串流。
由于流的概念,
>>用于输入又称为提取运算符;<<用于输出又称为插入运算符。
程序员在调式时添加打印语句。这类语句应该保证“一直刷新流,例如使用操纵符 endl。否则,程序崩溃,输出可能还留在缓冲区中,从而误导源于程序崩溃位置的推断。
此外,C++ 提供了很多操纵符用来帮助 operator<< 和 operator>> 控制输入或输出。常用的有操纵符:
endl:输出\n并刷新缓冲区flush:刷新输出流setw:更改下个输入/输出字段的位宽dec,hex,oct:更改输入/输出的基数fixed:更改浮点 I/O 的格式- \(更多操纵符_{reference}\)
4. comments
// 右侧的所有内容都会被编译器忽略,包括额外的双斜线。多行注释会忽略 /* 之后的所以内容,直到下一个 */。当我们使用多行注释并注释多行时,最好能够显示的指出被注释的程序行,例如用 * 标记:
/* This is comments:* line1* line2*/
5. 输入数量不定的元素
我们可以通过 while(std::cin >> val) 来读取数量不定的数据,当我们使用一个 istream 对象作为条件时,其效果是检测流的状态。例如当遇到文件结束符 EOF,或遇到一个无效的输入时(例如类型不匹配),istream 对象的状态会变为无效,处于无效状态的 istream 对象会使条件变为假。
在 UNIX 系统中,我们可以通过 CTRL+D 输入文件结束符,在 Windows 系统中,是先输入 CTRL+Z,然后按 Enter 键。另外注意,当变量类型为 int,即使我们输入一个 double 值类型也是不匹配的。不过当输入类型不匹配时,只是结束 while 循环,程序并不会报错。并且,该值会通过类型转换进入函数体,例如:
int x;
while(cin >> x) { // 1 3.9 1cout << x << endl; // 1 3
}
你可能是认为,这里的 3.9 是通过类型转换(double 转 int) 进入函数体的。不过再次实验我们可以发现,即使是 1 2hello 3 这样的输入,程序也可以输出 1 2。此外,我们修改变量类型为 double,输入为 1 3.14hello 2,输出为 1 3.14。这是为什么呢?对于输入为 double 类型的 3.9,截断为 int 类型的 3 我们还能理解,但是为什么 2hello 和 3.14hello 能转换为 2 和 3.14hello 呢?按理来说,2hello 和 3.14hello 应该作为字符串处理,为什么会输出 2 和 3.14 呢?
其实这主要与 cin 处理输入的规则有关,cin 是把我们的输入作为一个流处理的,它并没有能力提前将 1 2hello 3 这样的输入视为 int string int。换言之,尽管我们的初衷是,通过空格进行分隔,我们输入了三个数据 1,2hello 和 3,但是编译器并不会按照我们的想法来,它有自己的处理规则。即它只是一个字节一个字节的读取输入流,然后将读取的内容与我们的数据类型进行匹配,并且在类型不匹配或者读取到 EOF 时停止。所以说,尽管这里编译器实际上依然会将我们的输入分隔为三个数据,但这三个数据分别是:1,2,hello 3。其中,1 和 2 以空格符分隔,空格在读入后被丢弃;2 和 hello 3 以 h 作为分隔。
注意,一个经典的错误是,误认为编译器总是以空格作为分隔符看待输入。
具体原因解释:
(1) cin >> x 的输入机制
- 当 x 的类型为 int 时,
cin >> x会尝试从输入流中读取一个int类型 的值。 - 如果输入的数据 不符合
int的格式(如3.14),cin会进入 错误状态(failbit被置位),导致while (cin >> x)条件为false,循环终止。
(2) 输入 3.9 时发生了什么?
cin读取3(成功提取为int),x = 3,输出3。- 下一个字符是
.,cin发现它不是一个合法的int字符,于是:- 停止提取,剩余的
.9留在输入缓冲区。 - 设置
failbit,表示输入失败。 while (cin >> x)检测到failbit,循环结束。
- 停止提取,剩余的
(3) 为什么不是无限循环?
- 如果
cin遇到非法输入(如非数字字符或类型不匹配),它会:- 不修改目标变量(
x保持原值)。 - 关闭输入流(
failbit置位),后续所有cin操作都会失败,直到错误被清除(如cin.clear())。
- 不修改目标变量(
简而言之,在 C++ 中,std::cin 的输入操作(如 cin >> x)遵循格式化输入规则,它会尝试从输入流中提取符合目标类型(如 int、double)的数据,忽略前导空白字符,并在遇到不匹配的字符时停止。以下是详细解释:
- 跳过前导空白字符(如空格、换行符、制表符等)。
- 读取连续的数字字符(
0-9、+、-),直到遇到非数字字符(如字母、小数点.、空格等)。 - 将读取的数字字符串转换为
int,并存储到变量x中。 - 停止提取,不消耗后续的非数字字符(它们留在输入缓冲区中)。
示例分析:cin >> x 输入 3hello。假设有以下代码:
int x;
cin >> x; // 用户输入 "3hello"
执行流程:
cin跳过前导空白(此处没有空白)。- 读取字符
'3'(数字,合法)。 - 继续读取
'h',发现它不是数字('h'不是0-9、+、-),停止提取。 - 将已读取的
"3"转换为int,赋值给x。 'h'及后续的"ello"仍留在输入缓冲区。
附录:
cin的状态位详解在 C++ 中,
std::cin(以及所有std::istream对象)通过状态位(state flags) 来记录输入操作的成功或失败。这些状态位决定了cin的当前状态,并影响后续的输入操作。
1. 四种状态位
std::ios_base定义了以下四种状态位(通过位掩码表示):
状态位 含义 触发条件 goodbit一切正常,无错误。 输入/输出操作成功完成。 eofbit到达文件末尾(End-Of-File)。 尝试读取输入时遇到文件结束符(如 Ctrl+D(Linux/Mac)或Ctrl+Z(Windows))。failbit输入/输出操作失败,但流未完全损坏。 类型不匹配(如输入 "abc"到int)、格式错误等。badbit流发生严重错误(如设备故障、内存不足),无法继续使用。 缓冲区错误、不可恢复的硬件问题等。
2. 检测状态位的成员函数
以下成员函数用于检查流的状态:
函数 作用 good()返回 true当且仅当goodbit被设置(无任何错误)。eof()返回 true如果eofbit被设置(到达文件末尾)。fail()返回 true如果failbit或badbit被设置(操作失败或流损坏)。bad()返回 true如果badbit被设置(流严重损坏)。operator bool()等价于 !fail(),用于条件判断(如while (cin >> x))。
3. 状态位的触发示例
(1) 正常输入(
goodbit)int x; cin >> x; // 输入 "42" cout << cin.good(); // 输出 1(true)(2) 类型不匹配(
failbit)int x; cin >> x; // 输入 "abc" cout << cin.fail(); // 输出 1(true)(3) 文件结束(
eofbit)int x; while (cin >> x) { /* ... */ } // 输入 Ctrl+D(Linux/Mac)或 Ctrl+Z(Windows) cout << cin.eof(); // 输出 1(true)(4) 流严重错误(
badbit)cin.setstate(ios::badbit); // 模拟流损坏 cout << cin.bad(); // 输出 1(true)
4. 管理状态位
(1) 清除错误状态
使用
clear()重置状态位:cin.clear(); // 重置为 goodbit cin.clear(ios::failbit); // 仅清除 failbit(保留其他状态)(2) 清空输入缓冲区
在错误发生后,通常需要清空缓冲区:
cin.ignore(numeric_limits<streamsize>::max(), '\n'); // 忽略所有字符,直到换行符(3) 综合修复示例
int x; while (true) { cout << "Enter an integer: "; if (cin >> x) { // 输入成功 break; } else { // 输入失败 if (cin.eof()) { // 用户主动结束输入 cerr << "Input aborted." << endl; break; } cerr << "Invalid input! Try again." << endl; cin.clear(); // 清除错误状态 cin.ignore(1000, '\n'); // 清空缓冲区 } }
5. 状态位与条件判断
while (cin >> x)的工作原理:
cin >> x尝试读取输入。- 如果成功,返回
cin(隐式调用operator bool(),等价于!cin.fail())。- 如果失败(如类型不匹配或 EOF),
failbit或eofbit被设置,operator bool()返回false,循环终止。
6. 总结
场景 状态位 处理方法 输入成功 goodbit继续正常操作。 类型不匹配 failbitclear()+ignore(),然后重试。文件结束 eofbit终止输入循环(用户主动结束)。 流严重损坏 badbit通常无法恢复,建议终止程序。 关键点
failbit最常见:通常由格式错误(如输入"abc"到int)触发。eofbit需显式检查:while (cin >> x)在 EOF 时会退出循环。badbit极少发生:通常由硬件或系统级错误引起。通过合理管理状态位,可以编写健壮的输入处理代码!
6. byte& word
大多数计算机以 2 的整数次幂个比特作为块来处理内存,可寻址的最小内存块称为 “字节(byte)”,在 C++ 语言中,一个字节至少能容纳机器基本字符集中的字符。
计算机进行数据处理时,一次存取、加工和传送的数据长度称为 “字”。计算机的字长决定了其 CPU 一次操作处理数据的实际位数的多少,因此可见字长越大,其性能越优越。在 64 位计算机中,字长应位 64bit,这意味着 CPU 一次可以处理 64bit 的数据。
7. 字符类型
| 类型 | 含义 | 最小尺寸 |
|---|---|---|
| wchar_t | 宽字符 | 16bit |
| char16_t | Unicode字符 | 16bit |
| char32_t | Unicode字符 | 32bit |
一个 char 的空间应确保可以存放机器基本字符集(ASCII 或扩展 ASCII)中任意字符对应的数字值。也就是说,一个 char 的大小和一个机器字节一样。wchar_t 类型用于确保可以存放机器最大拓展字符集中的任意一个字符。类型 char16_t 和 char32_t 则为 Unicode 字符集服务(Unicode是用于表示所有自然语言中字符的标准)。
char 首先是为字符服务的,虽然它也可以作为一个
1B大小的整数使用。
与其他整数类型不同,字符型 char 被分为了三种:char,unsigned char,signed char,即,类型 char 和类型 signed char 并不一样。尽管字符型有三种,但是字符的表现形式只有两种:有符号和无符号的。因此类型 char 实际上会表现为上述两种形式中的一种,具体是哪种由编译器决定。
因此我们在编写代码时,应该避免依赖于 char 类型的有无符号,即,如果我们需要确保 char 类型是有符号的,应该使用 signed char;如果我们需要确保 char 类型是无符号的,应该使用 unsigned char。此时的 char 更像一个整形。(虽然 char 类型本身就是最小的整形)。而平时我们直接使用 char 时,是把他作为一个字符类型看待,而字符没有正负之分。另外,C 标准中明确指出 size_t 是无符号的。最后,如何验证当前编译器下 char 是否有符号呢?
template<typename T>
bool isSigned() {return T(-1) < (T)0;
}int main()
{cout << isSigned<char>() << endl; //truecout << isSigned<signed char>() << endl; // truecout << isSigned<unsigned char>() << endl; // faslereturn 0;
}
// g++ (Ubuntu 13.2.0-23ubuntu4) 13.2.0
从经验上看,大多数编译器都是按照 signed char 来实现 char 的。
8. 类型转换 & 溢出
C/C++ 中的算术及其陷阱
8.1 int 转 float 导致精度丢失
既然 float 能表示的范围比 int 要大,那为什么 int 转换为 float 会有精度损失?这是因为在表格化浮点数中,包含隐藏位,float 一共有 24 位尾数部分,但是 int 类型除去符号位有 31 位,显然 float 的尾数部分无法表示 31 位数据。因此当 int 的值大于 \(2^{24}\) 或小于 -\(2^{24}\) 时,转换为 float 会有精度的损失。
对于相同字节大小的数据类型,可表示范围更大了,就必然意味着精度的丢失。
8.2 有符号数和无符号数溢出
对于无符号数,如果我们传入的数超出范围,结果是取模后的余数,这是明确定义的行为;对于有符号数,如果我们传入的数超出范围,结果是未定义的。
C/C++ 标准未明确定义有符号数溢出的行为,原因包括:
- 硬件差异:
- 某些 CPU 会触发异常(如 x86 的
Overflow Flag),而另一些直接截断。- 优化灵活性:
- 编译器可以假设有符号数不会溢出,从而进行激进优化(如删除溢出检查)。
- 历史原因:
- C 语言设计时未强制规定,沿用至今。
9. literal
9.1 字面值类型
- 字面值类型是指编译时就能得到结果的类型,具体包括算术类型、数组、enum、字符串、引用和指针。自定义类(字面值常量类除外)、IO 类不属于该类型。
- 字面值类型的对象有严格的要求,字面值类型是那些具有常量表达式属性的对象的类型。例如:字面值常量是算术类型。
- 对于引用和指针,其限定比较严格。不是所有的指针都是常量表达式。只有那些在编译时就确定地址指向的指针才是常量表达式,引用同理。
- 因此
nullptr、NULL、指向固定地址的指针是字面值类型。
通过使用字面值类型,C++ 提供了一种在编译期执行计算和优化的方式,从而提高程序的性能。
nullptr 是指针字面量。
浮点型字面量的默认类型是 double。我们可以用科学计数法表示浮点数。
9.2 整形字面量
整形字面值的具体类型由它的值和符号决定。默认情况下,十进制字面量值的类型 是都是带符号数,八进制和十六进制字面量值的类型 既可能是带符号的也可能是无符号的。
- 十进制字面量的类型是
int、long和long long中能容纳当前值中尺寸最小的那个。 - 八进制和十六进制则是
int、unsigned int、long、unsigned long、long long和unsigned long long中尺寸最小的那个。
如果一个整形字面量连与之关联的最大数据类型都放不下,将产生错误。
#include <iostream>
#include <typeinfo>
#include <cxxabi.h>using namespace std;int main()
{// INT_MAX+1auto x = 0x80000000; // 十六进制 --> uintauto y = 2147483648; // 十进制 --> long longint status;char* demangled1 = abi::__cxa_demangle(typeid(x).name(), 0, 0, &status);std::cout << (status == 0 ? demangled1 : typeid(x).name()) << std::endl; // unsigned intchar* demangled2 = abi::__cxa_demangle(typeid(y).name(), 0, 0, &status);std::cout << (status == 0 ? demangled2 : typeid(y).name()) << std::endl; // longfree(demangled1);free(demangled2);return 0;
}
尽管八进制和十六进制整形字面量可以存储在带符号数据类型中,但严格来说,无论是十进制数还是二进制、八进制、十六进制数,他们的字面量值本身都默认被视为无符号数。这意味着对于 0xffffffff 来说,它不会被解释为 -1,而是会被解释为 unsigned int 类型的最大值。即使我们用形如 -0x1 或 -1 的负字面量,那个负号也并不在字面值之内,它的作用仅仅是对字面值取负值而已,这就会带来一个问题:
auto x = -0x80000000; // - INT_MAX
cout << x << endl; // 2147483648
在这里我们明明通过负号将 0x80000000 转化为负数,但打印出来的数确实正数,这是因为 0x80000000 作为十六进制整形字面量先被推导为 unsigned int 类型,然后在对其取负数,其结果仍是无符号数。也即在对字面量进行类型推导的过程中,负号并不参与。
但如果我们将字面值修改为 -2148473678,输出 x 的值为 -2148473648,就是我们期望的结果了。因为这里十进制字面量推导出的数据类型是 long ,而 long 类型是有符号的,因此添加负号之后变为负数。
9.3 字符串字面量
在 C 和 C++ 中,字符串字面量(如 "Hello")的类型和行为有所不同,主要区别在于 常量性 和 存储方式。以下是详细分析:
9.3.1 2.1 C 语言中的字符串字面量
类型
-
类型:
char[N](N是字符数 + 1,因为包含终止符\0)。 -
常量性:虽然存储在只读内存,但类型是
char[](非const),历史遗留问题。 -
示例:
char* str = "Hello"; // 合法但不安全(C 允许,C++ 报错) str[0] = 'h'; // 未定义行为(UB),可能崩溃!
特点
- 存储位置:通常位于程序的 只读数据段(.rodata),修改会导致未定义行为。
- 隐式添加
\0:"ABC"的实际类型是char[4]({'A','B','C','\0'})。
在 C 和 C++ 中,字符串字面量(如
"hello")的类型在 C 中被定义为char[],而在 C++ 中则是const char[]。这种差异源于 C 和 C++ 的语言设计演变,而 C 的char[]类型在现代编程中确实被认为是一种历史遗留问题,主要原因如下:1. C 语言诞生时(1972年)没有
const
- C 语言最初没有
const关键字(const在 C89 标准才引入)。- 因此,字符串字面量的类型被简单地定义为
char[],即使它存储在只读内存(.rodata段)。- 允许
char* p = "hello";,尽管修改p[0]是未定义行为(UB)。2. 向后兼容性
- 由于大量旧代码依赖
char*指向字符串字面量,即使 C89 引入const,也无法直接修改字符串字面量的类型定义,否则会破坏现有代码。
9.3.2 2.2 C++ 中的字符串字面量
类型
-
类型:
const char[N](N是字符数 + 1)。 -
常量性:明确为
const,禁止修改。 -
示例:
const char* str = "Hello"; // 正确 // char* str = "Hello"; // 错误:C++ 禁止丢弃 const
特点
-
存储位置:与 C 相同(只读内存)。
-
C++11 起支持原始字符串字面量(Raw String Literals):
const char* raw = R"(Line1\nLine2)"; // 保留 \n 作为普通字符
9.3.3 2 宽字符串字面量(Wide String Literals)
支持多字节字符集(如 Unicode):
| 前缀 | 类型 | 示例 |
|---|---|---|
| 无 | const char[N] |
"Hello" |
L |
const wchar_t[N] |
L"中文" |
u8 |
const char8_t[N] |
u8"UTF-8" |
u |
const char16_t[N] |
u"UTF-16" |
U |
const char32_t[N] |
U"UTF-32" |
9.3.4 2.4 字符串字面量的特殊性质
(1) 生命周期
-
静态存储期:存在于整个程序运行期间(类似
static变量)。 -
地址唯一性:相同内容的字符串字面量可能共享存储(编译器优化)。
const char* a = "ABC"; const char* b = "ABC"; assert(a == b); // 可能为 true(依赖编译器)
(2) 数组到指针的隐式转换
-
字符串字面量可隐式转换为
const char*(指向首字符):const char* p = "Hello"; // 等价于 &"Hello"[0]
(3) 原始字符串字面量(C++11)
避免转义字符:
const char* path = R"(C:\Files\test.txt)"; // 无需转义 \
const char* json = R"({"key":"value"})"; // 直接包含引号
9.3.5 2.5 常见问题与陷阱
(1) 修改字符串字面量
char* str = "Hello"; // C 中合法但危险,C++ 中错误
str[0] = 'h'; // 未定义行为(UB)!
修正:
-
C:使用数组初始化(可修改副本):
char str[] = "Hello"; // 栈上创建副本 str[0] = 'h'; // 合法 -
C++:直接使用
const char*:const char* str = "Hello"; // 必须加 const
(2) 类型区分
auto a = "Hello"; // a 的类型是 const char[6]
auto* b = "Hello"; // b 的类型是 const char*
(3) 多字节字符集处理
const wchar_t* wstr = L"中文";
const char16_t* u16str = u"UTF-16";
const char8_t* u8str = u8"UTF-8"; // C++20 引入
9.3.6 2.6 总结
| 特性 | C 语言 | C++ 语言 |
|---|---|---|
| 类型 | char[N] |
const char[N] |
| 常量性 | 无 const(但实际只读) |
明确 const |
| 可修改性 | 未定义行为(UB) | 编译错误 |
| 宽字符串支持 | L"..."(wchar_t) |
L"...", u8"...", u"..."等 |
| 原始字符串 | 不支持 | R"(...)"(C++11 起) |
9.3.7 2.7 最佳实践
- C++:始终用
const char*或std::string处理字符串字面量。 - C:若需修改,使用数组副本(
char str[] = "...")。 - 跨平台:优先使用
u8"..."(UTF-8)或std::u8string(C++20)。
9.3.8 顶层const被忽略
我们前面提到,字符串字面量实际类型是常量字符构成的数组,但这里显示其类型是 char[N](N 是字符数+1),并不符合常量字符的要求啊。
int status;
char* demangled1 = abi::__cxa_demangle(typeid("hello").name(), 0, 0, &status);
std::cout << (status == 0 ? demangled1 : typeid("hello").name()) << std::endl; // char [6]
可能是因为 typeid 在某些实现中可能忽略顶层 const(即 const char[6] 显示为 char [6]),这是编译器行为差异。
如果需要精确包含 const,可以:
(1) 使用模板 + 类型推断
template<typename T>
void print_type() {std::cout << __PRETTY_FUNCTION__ << std::endl; // GCC/Clang 特有
}
print_type<decltype("hello")>(); // 输出完整类型(含 const)
GCC/Clang 输出:
void print_type() [T = const char (&)[6]]
(2) 使用 C++11 的 decltype
std::cout << typeid(decltype("hello")).name() << std::endl; // 可能仍不包含 const
输出 A6_c,对应类型为 char[6]。
9.3.9 字符串字面量的自动拼接
在 C 和 C++ 中,如果 两个字符串字面量 在代码中 位置相邻且仅由空格、缩进或换行符分隔,则编译器会在编译阶段自动将它们 拼接成一个字符串。这是语言标准规定的行为,称为 字符串字面量拼接(String Literal Concatenation)。
1. 标准规定
(1) C 标准(C11/C17)
-
C11 §6.4.5:
在翻译阶段(编译阶段),相邻的字符串字面量会被合并为一个字符串。
(2) C++ 标准(C++17/C++20)
-
C++17 §5.13.5:
相邻的窄字符串字面量(
"...")或宽字符串字面量(L"...")会被拼接。
2. 拼接规则
(1) 基本拼接
const char* str = "Hello" " World"; // 等价于 "Hello World"
- 两个字符串字面量
"Hello"和" World"被合并为"Hello World"。
(2) 跨行拼接
const char* str = "Hello ""World"; // 等价于 "Hello World"
- 换行和缩进不影响拼接。
(3) 混合空格和注释
const char* str = "Hello" /* 注释 */ " World"; // 合法,拼接为 "Hello World"
- 注释和空白字符(空格、换行、制表符)不会阻止拼接。
3. 限制条件
(1) 必须同为窄字符串或宽字符串
-
合法:
const char* s1 = "A" "B"; // 窄字符串 + 窄字符串 const wchar_t* s2 = L"A" L"B"; // 宽字符串 + 宽字符串 -
非法:
// const char* s3 = "A" L"B"; // 错误:窄字符串 + 宽字符串(类型不匹配)
(2) 不能跨类型拼接
// const char* s = "Hello" + " World"; // 错误:+ 是运行时操作,拼接是编译时行为
9.4 9.3 实际用途
(1) 长字符串分行书写
const char* long_str = "This is a very long string ""that spans multiple lines ""for better readability.";
- 提高代码可读性,避免一行过长。
(2) 宏定义中的拼接
#define MSG "Hello " \"World" // 预处理阶段拼接
(3) 多语言字符串处理
const char* path = "C:\\""Documents\\""file.txt"; // 拼接为 "C:\\Documents\\file.txt"
9.5 前缀和后缀
最后,我们还可以通过前缀或者后缀来明确的指定字面值的类型:
(1)字符和字符串字面量:
| 前缀 | 类型 |
|---|---|
| u | char16_t |
| U | char32_t |
| L | wchar_t |
| u8(utf-8) | char(char8_t) |
(2)整形字面量:
| 后缀 | 最小匹配类型 |
|---|---|
| u/U | unsigned |
| l/L | long |
| ll/LL | long long |
u 和 l 或 ll 可以结合,例如 ul,可以匹配 unsigned long 或 unsigned long long
(3)浮点型字面量:
| 后缀 | 类型 |
|---|---|
| f/F | float |
| l/L | long double |
10. variable
变量是一个具名的、可供程序操作的存储空间。
C++ 中,每个变量都有其数据类型,数据类型决定了变量所占内存空间的大小和布局方式,即数据元素的内容(属性),还定义了这类数据上可以进行的运算(方法)。
一般来说,我们并不严格区分变量(variable)和对象(object),即变量和对象我们可以视为同一个概念,即有某种数据类型的内存空间。
11. initialize & assign
虽然有时初始化和赋值的形式相同,都用 = 完成,但它们的含义并不相同。初始化的含义是创建变量时赋予其一个初始值(调用构造函数),而赋值的含义是把对象的当前值擦除,而以一个新值来替代(调用赋值运算符)。
12. List Initialization
C++11 引入 列表初始化(List Initialization),主要是为了解决传统初始化方式的缺陷,并提供更统一、更安全的初始化语法。它的设计目标包括:
12.1 解决传统初始化的不一致性
在 C++11 之前,初始化方式混乱且容易混淆:
-
普通变量:可以用
=、()或直接初始化。int x = 5; // 赋值风格 int y(5); // 函数风格(构造) int z{5}; // C++11 引入的列表初始化 -
聚合类型(如数组、结构体):只能用
{}。int arr[] = {1, 2, 3}; // C 风格 -
类对象:可能依赖构造函数重载或
=运算符。std::vector<int> v(5, 1); // 5 个 1,但容易误认为初始化列表
问题:不同场景需要不同语法,容易误用(例如 std::vector<int> v(5, 1) 和 std::vector<int> v{5, 1} 行为完全不同)。
12.2 禁止隐式窄化转换(Narrowing Conversions)
传统初始化允许隐式的数据截断(如 double → int),可能导致难以察觉的 Bug:
int a = 3.14; // 静默截断,丢失小数部分(编译器可能不警告)
int b{3.14}; // C++11 直接报错或警告,防止意外窄化
列表初始化会严格检查窄化转换,提升安全性。
12.3 支持初始化列表(std::initializer_list)
C++11 引入了 std::initializer_list,允许容器和类直接接受花括号列表作为参数:
std::vector<int> v1 = {1, 2, 3}; // 调用接受 initializer_list 的构造函数
std::vector<int> v2{1, 2, 3}; // 同上
这简化了容器初始化,并支持自定义类的列表初始化。
12.4 统一初始化语法
列表初始化可以用于几乎所有场景:
-
基本类型:
int x{42}; -
数组和聚合类型:
int arr[]{1, 2, 3}; struct Point { int x; int y; }; Point p{1, 2}; -
STL 容器:
std::map<int, std::string> m{{1, "a"}, {2, "b"}}; -
动态分配的对象:
int* ptr = new int[3]{1, 2, 3}; -
函数返回值:
std::vector<int> foo() { return {1, 2, 3}; }
12.5 避免“最令人烦恼的解析”(Most Vexing Parse)
传统语法中,带括号的初始化可能被误认为是函数声明:
std::vector<int> v(10); // 调用构造函数,创建 10 个元素的 vector
std::vector<int> v(); // 被解析为函数声明!而非默认构造的 vector
使用 {} 可以明确意图:
std::vector<int> v{}; // 明确调用默认构造函数
12.6 与自动类型推导(auto)配合更好
列表初始化可以与 auto 结合,避免类型不匹配:
auto x{42}; // C++11 中推导为 std::initializer_list<int>(后来修正)
auto y = {42}; // 明确推导为 initializer_list
auto z(42); // 推导为 int
(注:C++17 修正了 auto x{42} 的推导规则,直接推导为 int。)
12.7 总结:C++ 为什么引入列表初始化?
| 原因 | 示例 | 传统初始化的问题 |
|---|---|---|
| 统一语法 | int x{5}; std::vector<int> v{1, 2, 3}; |
不同场景需要不同语法(=、()) |
| 禁止窄化转换 | int x{3.14}; 会报错 |
int x = 3.14; 静默截断 |
支持 initializer_list |
std::vector<int> v{1, 2, 3}; |
需手动构造或多次 push_back |
| 避免歧义解析 | std::vector<int> v{}; |
std::vector<int> v(); 是函数声明 |
更好的 auto 配合 |
auto x = {1, 2, 3}; |
传统方式无法直接推导列表 |
列表初始化是 C++ 迈向更安全、更一致语法的重要一步,尽管它引入了一些新规则(如窄化检查),但显著减少了初始化相关的 Bug 和歧义。
13. 内置类型默认初始化
函数体内的内置类型变量不会默认初始化,此时如果我们使用未初始化的变量会导致未定义的错误。因此建议初始化每一个内置类型的变量,虽然并非必须这么做,但这是一种简单且可靠的做法。
14. 分离式编译
C++ 的分离式编译(separate compilation)是一种将程序的不同部分(通常是源文件)分开编译的技术。这种方法允许开发者在大型项目中更高效地组织代码,提高编译速度和模块化程度。
分离式编译的主要概念
- 源文件与头文件:
- 源文件(
.cpp文件)包含具体的实现代码。 - 头文件(
.h或.hpp文件)用于声明类、函数和变量的接口,通常包含函数原型、类定义和常量等。
- 源文件(
- 编译过程:
- 在分离式编译中,每个源文件被单独编译为目标文件(
.o或.obj文件),然后所有的目标文件链接成最终的可执行文件。 - 这种方法允许你修改某个源文件而不必重新编译整个项目,只需重新编译受影响的源文件重新链接即可。
- 在分离式编译中,每个源文件被单独编译为目标文件(
优势
- 提高编译效率:在修改某个源文件后,只需重新编译该文件及其依赖的文件然后与之前已经编译过的文件链接即可,而不必重新编译整个项目。
- 代码组织:将接口(头文件)与实现(源文件)分开,使得代码更易于管理和维护。
- 模块化:使得代码的复用和分布式开发更加容易。
注意事项
- 包含保护:在头文件中使用包含保护(如
#ifndef/#define)来防止重复包含,避免链接错误。 - 依赖关系管理:确保在修改源文件时,及时更新相关头文件,以保持代码的正确性。
15. 声明和定义的关系
为了允许把程序拆分为多个逻辑部分来编写,C++ 支持分离式编译机制,该机制允许将程序分割为若干个文件,每个文件可被独立编译。
为了支持分离式编译,C++ 语言将声明和定义区分开来。声明使得变量为程序所知,一个文件如果想使用别处定义的名字必须包含对那个名字的声明。而定义负责创建与名字关联的实体,为变量和函数提供具体实现并分配内存。
因此变量只能被定义一次,但可以被多次声明。
如果只是声明一个变量而非定义它,就在变量名前面添加关键字 extern,而不要显式的初始化变量:
extern double pi = 3.14; // declaration and definition
extern int x; // declaration
int y; // declaration and definition
16. 下划线命名
在 C++ 中,下划线命名的使用是有限制的,因为某些形式的标识符被标准库保留,用户代码中随意使用可能导致未定义行为或命名冲突。以下是具体的规则:
-
禁止连续两个下划线(
__)- 例如:
int __value;❌(保留给编译器和标准库实现) - 用户代码中不应使用连续的两个下划线。
- 例如:
-
禁止以下划线 + 大写字母开头
- 例如:
_Value、_MAX❌(标准库保留) - 这类名称(如
_MAX)可能和标准库宏或内部实现冲突。
- 例如:
-
禁止在全局/命名空间作用域内以下划线开头
-
例如:
int _count; // ❌ 全局作用域以下划线开头(风险) namespace {int _internal; // ❌ 即使是匿名命名空间也不建议 } -
标准库可能使用此类名称,用户代码中使用可能导致冲突。
-
-
函数局部作用域允许单下划线开头(但仍需谨慎)
-
例如:
void foo() {int _tmp; // ✅ 允许但不推荐(可能和局部库实现冲突) } -
虽然合法,但建议避免,以防标准库内部实现使用类似名称。
-
C++ 标准(ISO/IEC 14882)明确保留以下形式的名称:
- 双下划线(
__)或下划线 + 大写字母(如_E)。 - 全局作用域的单下划线开头名称。
17. for 循环作用域
下面的代码将会导致意想不到的变量遮掩(shadowing)现象,而不会报错:
int main()
{int i = 100;for(int i = 0; i < 2; i ++ )cout << "scope: " << i << endl;cout << "global: " << i << endl;return 0;
}
//scope: 0
//scope: 1
//global: 100
外部 i 的作用域:
- 在
main函数的作用域中,i被初始化为100。这个i是外部main函数中的变量。
局部 i 的作用域:
- 在
for循环中,int i = 0;声明了一个新的局部变量i,这个局部变量遮蔽了外部的i。因此,在for循环的作用域内,i指的是局部变量。
18. 复合类型、基本数据类型、声明符、类型修饰符
复合类型是指基于其他类型定义的类型,C++ 中的复合类型有:
- array
- struct
- class
- union
- enum
- pointer
- reference
在同一条定义语句中,虽然“基本数据类型”只有一个,但是声明符的形式可以不同。也就是说,一条定义语句可能定义出不同类型的变量,例如:
int a = 4, *p = &a, &r = a;
其中a,*p 和 &r 都是声明符, * 和 & 称之为“类型修饰符”,它是声明符的一部分,而不是基本数据类型。其中 * 用来修饰 p,p 就是一个复合类型指针;& 用来修饰 r,r 就是一个复合类型引用。
有时候我们可能将类型修饰符紧跟在基本数据类型后来,容易产生误解,因此不要误认为下面的语句声明了两个 int 类型的指针:
int* p1, p2;
// p1's type a pointer to int
// p2's type is int
因此更推荐将类型修饰符与标识符写在一起而不是与基本数据类型写在一起,避免不必要的误解😁。
19. reference
引用不是一个对象,它只是一个别名,对引用的操作就是对所引用对象的操作。由于引用没有实际内存地址,因此不能定义引用的引用,也不能定义指向引用的指针。当引用指向常量时,不可以修改被引用的对象。不可以让一个非常量引用指向一个常量对象。
引用的类型必须与其所引用对象的类型一致,即使由 int 到 long 的转换也不可以,但有两种特殊情况:
- 在初始化指向常量的引用时允许使用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型。尤其,允许为一个常量引用绑定非常量的对象、字面值,甚至是一个表达式。
- 指向常量的引用可能引用一个非const对象。
对于第一种情况,其本质上就相当于通过类型转换创建了一个临时对象,然后将引用绑定到这个临时对象上。在 C++ 中,指向常量的引用可以指向临时对象,有以下几点原因:
- 防止对象拷贝: 常量引用可以绑定到临时对象的一个重要原因是为了避免拷贝开销。如果常量引用不能绑定临时对象,编译器可能不得不创建一个新的对象副本,这会增加不必要的开销。如果对象是一个
int,可能开销很小,但如果对象是一个很大的类或结构体,创建新的对象副本就会带来较大的额外的内存开销和拷贝时间开销。允许常量引用绑定临时对象是一种优化手段,可以减少对象的拷贝。 - 临时对象的生命周期延长: 当临时对象被常量引用绑定时,C++ 语言规则会自动延长该临时对象的生命周期,使其在常量引用的作用域结束之前都不会被销毁。这意味着只要常量引用有效,临时对象就一直存在。
- 常量性保证安全性: 常量引用(
const)的一个重要特点是它不能改变所绑定的对象的值,这在绑定临时对象时提供了一定的安全性。由于你无法修改临时对象,程序的行为是确定的,不会因为对临时对象的修改而引发不可预期的错误。
20. pointer
指针本身是一个对象,它有自己的内存地址。void* 是一种特殊的指针类型,可用于存放任意类型对象的地址。区分空指针和无效指针,空指针即指针为 NULL,无效指针是指指针既不为空,也不指向某个对象,例如指向已被释放空间的指针。
和引用一样,指针的类型也必须与其所指对象类型一致,但也有几种特殊情况:
void*类型指针。- 指向常量的指针可以指向非常量对象。
- 上行转换:子类指针可以安全的隐式转换为父类指针。
注意对于 type *,对其解引用获得的是 type& 而不是 type,即,指针解引用获得的是指针所指的对象的引用而不是对象的拷贝。
21. 标识符解释规则
int val = 16;
int *p = &val;
int *&r = p; // r是一个指向指针p的引用
在面对一条比较复杂的指针或引用的声明语句时,从右向左解释有助于弄清他的真实含义。这是因为离变量名最近的符号最变量的类型有最直接的影响。因此由于 & 最靠近 r,所以 r 首先是一个引用,其次他是一个 int * 类型的引用。
对于数组来说,想要理解数组声明的含义,最好的办法是从数组的名字开始按照由内而外的顺序阅读:
int *ptrs[10]; // ptrs是一个含有10个整形指针的数组
int (*ptrs)[10] = &arr; // ptrs是一个指针,指向有10个整形元素的数组
int* (*ptrs)[10] = &arr; // ptrs是一个指针,指向有10个整形指针的数组
22. const的链接属性
在 C++ 中,默认状态下,const 对象是内部连接 的,仅在当前文件内有效。这一点与 C 不同,C 语言的 const 对象是外部链接 的,可以在不同的源文件之间共享。如果我们希望 C++ 中的 const 对象可以在不同源文件之间共享,对于该 const 对象,不管是声明还是定义都添加 extern 关键字即可。
所以,为什么 C++ 要规定 const 对象为内部链接呢?
这是因为在编译阶段,编译器通常会对常量进行优化,即所谓的常量传播或者常量折叠,表现为将常量对象直接替换为该对象的值或者将常量表达式替换为计算结果。这样我们就不用在内存当中实际存储这些变量的值了,从而减少内存访问和计算开销。
而如果我们将 const 的链接性定义为外部链接,那么不同文件中的 const 变量则更容易产生链接后的命名冲突,因此编译器保守起见,不去进行常量优化。同理,当
const变量在某些情况下具有外部链接属性(例如,使用extern),编译器可能会认为这些变量在不同源文件中可能冲突,从而影响优化的决策。
总而言之,就是为了进行编译优化。
但这里有一个问题,我们之所以不去进行常量优化,是担心程序中可能存在命名冲突,也即链接错误。那么,既然在最终链接时发现了命名冲突,并导致链接错误,那么为什么编译器不能更激进一些,先尝试优化,最后如果有冲突再报错呢?也就是说,不管三七二十一,直接把常量全优化了,这样如果程序真的存在命名冲突,反正也是链接错误;如果不存在命名冲突,那就是成功的优化。横竖都是赢,何乐而不为呢?
这就要从编译器的优化原理来解释了:
- 编译器优化的整体流程
编译器的优化过程通常分为多个阶段,包括以下几个:
- 前端阶段:负责语法解析和类型检查,生成中间代码(如抽象语法树)。
- 中间阶段:进行各种优化,比如常量折叠、循环优化、代码内联等。
- 后端阶段:生成目标机器代码,并在最终的链接过程中合并多个翻译单元。
编译阶段和链接阶段是分开的。在编译阶段,编译器通常只处理单个翻译单元(一个
.cpp文件),它并不知道其他文件中的const变量定义。因此,如果编译器在编译阶段对const变量进行激进优化,比如将它们直接替换为常量值,它不知道是否在其他翻译单元中有同名但不同值的const变量存在。
- 潜在的风险:编译阶段与链接阶段的分离
当编译器决定在编译阶段优化某个
const变量为其字面值时,它是基于翻译单元内部的知识做出的决定。如果链接阶段发现了命名冲突,虽然链接器会报错,但问题在于编译器已经进行了前置优化,并且这种优化依赖于假设该const变量在全局范围内是唯一且不冲突的。
- 如果优化过早进行(如在编译阶段将
const变量替换为字面常量),编译器可能会丢失对原始变量地址的引用信息。如果链接器发现了同名的const变量,这种早期的优化可能已经破坏了代码的结构和行为。- 一旦发现命名冲突,优化可能已经让程序处于一种不可恢复的状态,因为原本的符号引用已经被替换成了常量,无法再简单恢复为符号引用。
- 编译器的保守策略:避免不安全的优化
为了保证程序的正确性和可维护性,编译器在遇到具有外部链接潜力的
const变量时,往往采取保守策略,避免过早优化。特别是在链接前,编译器并不知道这些const变量是否会在其他翻译单元中出现重定义的情况。
- 可预测性:如果编译器不进行优化,它能保证程序在链接阶段不会由于变量冲突而产生意外的行为。
- 安全性:即使发生链接错误,程序的符号引用仍保持不变,不会由于过早替换为常量而丢失语义上的正确性。
- 为什么编译器不更激进?
从理论上来说,编译器可以尝试在编译阶段进行激进的优化,比如将所有的
const变量都直接替换为它们的字面值,然后在链接阶段再去检测冲突。但是这种做法带来了几个问题:
- 复杂性和成本:一旦编译器进行了这种激进优化,后续再发现冲突时,恢复优化前的状态会非常困难,且增加了编译器实现的复杂性。
- 潜在错误的隐蔽性:如果编译器早期进行了错误的假设并进行了优化,后续的错误可能不会立刻暴露出来。优化本身可能会掩盖真正的问题,甚至让问题更难调试和修复。
- 更安全的替代方案:内部链接
在 C++ 中,默认情况下
const变量是具有内部链接的,编译器通过这种设计策略规避了命名冲突的问题:
- 每个翻译单元中的
const变量是独立的,编译器可以放心地优化这些变量,而不必担心其他文件中定义了相同名称的const变量。- 这种设计允许编译器在编译阶段进行激进的优化,因为它确保了这些常量的唯一性和局部性,不会引发链接阶段的冲突。
- 总结
编译器采取保守的优化策略是为了避免在链接阶段引入不可预测的行为或破坏程序的结构。虽然在最终链接时会发现命名冲突并报错,但如果编译器在编译阶段就进行了激进优化,这种优化可能导致程序行为被破坏,甚至在报错之前已经无法恢复。
这种保守策略确保了编译器不会在编译时进行可能破坏程序行为的优化,即使它在链接阶段报错,程序的结构和引用仍然是安全的、可预测的。
总而言之,就是避免优化可能导致的对程序行为(语义)的破坏,进而掩盖问题甚至导致其他的问题。
23. The As-if rule
C++ 中的 "as-if" 规则 是指编译器可以对代码进行任何优化,只要优化后的程序行为与标准规定的行为没有区别。换句话说,编译器可以自由地进行各种优化,但前提是程序的可观察行为与未优化时完全一致。
"as-if" 规则的关键点:
- 可观察行为:
- 可观察行为包括变量的值、函数调用、输出结果(例如打印到控制台或写入文件)等。
- 编译器可以改变程序的输出、产生的副作用或执行顺序,只要这些变化不会被程序的可观察行为检测到。
- 允许的优化:
- 编译器可以跳过、重排代码,甚至删除一些看似没用的操作,只要这些更改不会影响程序的最终输出或行为。例如:
- 循环展开:编译器可以将循环优化为更高效的指令。
- 常量折叠:编译器可以在编译时计算常量表达式,而不是在运行时计算。
- 跳过未使用的变量:如果某个变量未使用,编译器可以直接将其移除。
- 编译器可以跳过、重排代码,甚至删除一些看似没用的操作,只要这些更改不会影响程序的最终输出或行为。例如:
- 本质:
- 编译器的这些优化不会违反 C++ 标准规定的行为,因为从外部观察来看,程序执行的结果与未优化的结果完全相同。
例子:
int x = 10;
int y = 20;
int z = x + y;
std::cout << z << std::endl;
编译器可以直接将 z 计算为 30 并跳过 x + y 的实际计算,因为这不会影响程序的可观察行为,输出结果依然是 30。这就是 "as-if" 规则的典型例子。
总结:"as-if" 规则使编译器能够自由优化代码,提升程序性能,只要这些优化不改变程序的可观察行为。
24. top-level const
和引用不同,由于指针本身是一个对象,因此指针本身可以是一个常量,而指针所指的对象也可以是一个常量,为了区分这两个const,我们用 top-level const 表示指针本身是个常量,用 low-level const 表示指针所指对象是一个常量。
更一般的,顶层 const 可以表示任意的对象是常量,这一点对任何数据类型都适用,如算数类型、类、指针等。底层 const 则与指针和引用等符合类型的基本数据类型部分有关。
由于引用本身不是一个对象,因此指向引用的 const 都是底层 const。
当执行对象的拷贝(初始化/赋值)操作时,顶层 const 不影响,对于底层 const,非常量可以转换为常量,反之则不行。这很容易理解,我们不能将一个指向常量对象的指针拷贝到一个不是指向常量对象的指针,因为它可以修改所指向对象的值。
忽略顶层 const 意味着,对于
const type val;你既可以传入一个 const 对象,也可以传入一个非 const 对象,这都是合法的。
简而言之,顶层 const 就指的是类型本身;底层 const 指的是具体所指向的对象。
25. constexpr和常量表达式
25.1 constexpr
constexpr 关键字在 C++ 中用于声明 常量表达式,即在编译时就能求值的表达式。它可以用于变量、函数、构造函数和成员函数,使得这些对象或函数在编译期进行计算,从而提高效率。
25.1.1 (1) constexpr 变量
constexpr 变量必须是 编译时可求值 的常量:
constexpr int x = 10; // 编译时计算
constexpr int y = x;
constexpr double pi = 3.14159;
但如果变量的值不能在编译时确定,则无法使用 constexpr:
int a = 10;
constexpr int b = a; // ❌ 错误:a 不是编译时常量
// a.cpp:8:19: error: the value of 'a' is not usable in a constant expression
// 8 | constexpr int b = a;
// | ^
// a.cpp:7:5: note: 'int a' is not const
// 7 | int a = 10;
// | ^
25.1.2 (2) constexpr 函数
constexpr 函数要求其 所有的操作都必须是编译期可计算的:
constexpr int square(int x) {return x * x;
}constexpr int value = square(5); // 在编译期计算出 25
但是,如果 constexpr 函数的参数不是编译时常量,则它也可以在运行时求值:
int n;
std::cin >> n;
int result = square(n); // 运行时计算
-
C++11:
constexpr函数只能包含return语句。constexpr int factorial(int n) {return (n <= 1) ? 1 : (n * factorial(n - 1)); // C++11 允许递归 } -
C++14+:允许局部变量、
if、for等。constexpr int factorial(int n) {int result = 1;for (int i = 1; i <= n; ++i) {result *= i;}return result; }
错误示例:
constexpr int get_random() {return rand(); // 错误:rand() 不是常量表达式
}
- 不能在
constexpr函数中调用非constexpr函数或使用运行时数据。
25.1.3 (3) constexpr 构造函数
constexpr 构造函数允许 在编译期构造对象:
struct Point {int x, y;constexpr Point(int a, int b) : x(a), y(b) {}
};constexpr Point p(3, 4); // 在编译期创建 Point(3, 4)
25.1.4 (4) constexpr 与 const 的区别
| 关键字 | 作用范围 | 计算时间 |
|---|---|---|
const |
仅限制变量值不可变,不一定是编译期常量 | 运行时或编译时 |
constexpr |
常量,并且表达式必须在编译期求值 | 仅编译时 |
示例:
const int a = 10; // 可以是编译时或运行时常量
constexpr int b = 20; // 必须是编译时常量
25.1.5 (5) constexpr 在 if 语句中 (if constexpr)
C++17 引入了 if constexpr,用于 编译期分支优化:
template <typename T>
void func(T val) {if constexpr (std::is_integral_v<T>) { // 编译期判断std::cout << "整数: " << val << std::endl;} else {std::cout << "非整数: " << val << std::endl;}
}
- 如果
T是整型,std::cout << "非整数"这部分代码不会被编译。
25.1.6 (6) constexpr 与 std::array
C++ 标准库 std::array 需要 固定大小,因此可以结合 constexpr 使用:
constexpr int getSize() { return 5; }
std::array<int, getSize()> arr; // 数组大小在编译期确定
25.1.7 (7) constexpr lambda 表达式(C++17)
C++17 允许 constexpr 用于 lambda 表达式:
constexpr auto add = [](int a, int b) { return a + b; };
constexpr int sum = add(3, 4); // 在编译期计算
25.1.8 总结
| 用法 | 说明 |
|---|---|
constexpr 变量 |
必须是编译期可计算的常量 |
constexpr 函数 |
允许在编译期进行计算 |
constexpr 构造函数 |
允许在编译期构造对象 |
if constexpr |
允许编译期分支优化 |
constexpr lambda |
支持编译期 lambda 表达式 |
constexpr 适用于 优化代码,特别是在避免运行时计算,提高性能的场景中。
25.2 constant expression
常量表达式是指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面量就属于常量表达式。
一个对象(或表达式)是不是常量表达式由它的数据结构和初始值共同决定。常量也未必就是一个常量表达式,因为常量的值可能在运行时才能获取到,例如它是一个函数的返回值。常量只是意味着这个值不可以修改,并不意味着这个值可以在编译期得到。
因此说在一个复杂系统中,很难分辨一个初始值到底是不是常量表达式。C++11 标准规定,允许将变量声明为 constexpr 类型以便编译器来验证变量的值是否是一个常量表达式。即,声明为 constexpr 的变量一定是一个常量,并且必须使用常量表达式初始化(可以在编译期求值)。
常量表达式的值需要在编译时就得到计算,因此对声明 constexpr 时用到的类型必须有所限制。这些类型必须是“字面值类型”。其中,算符类型,指针类型,引用类型,聚合类型,枚举类型和 constexpr 修饰的类型都是字面值类型。
对于那些需要动态管理内存的对象,他们肯定不是字面值类型,因为它们需要在运行时分配和销毁内存。
尽管指针和引用都能定义成 constexpr,但它们的初始值受到严格限制,即需要满足顶层 const 的定义,指针的初始值必须是存储在某个固定地址中的对象;引用虽然没有顶层 const 的概念,但引用所指向对象也必须是存储在某个固定地址中的对象,例如函数中的局部对象是不合法的,因为它们的地址是随机的。
注意 constexpr 的常量总是顶层 const。
26. triviality
在 C++ 中,平凡性(Triviality) 是指一个类型 的特殊性质,这个性质决定了该类型是否拥有默认的、无需编译器进行复杂生成和操作的构造函数、拷贝构造函数、移动构造函数、析构函数和赋值运算符。平凡的类型非常简单,允许编译器在内存中直接操作对象(例如,通过简单的内存拷贝构造新对象),而不需要调用特殊的函数。
一个类型的平凡性主要影响编译器的优化、对象的复制和销毁过程。C++ 标准定义了平凡的类型具有以下几个特征:
平凡类型的特征:
- 默认构造函数是平凡的:
- 类型没有用户定义的默认构造函数。也就是说,编译器生成的默认构造函数只进行简单的内存分配,并不执行任何复杂的初始化操作。
- 拷贝构造函数是平凡的:
- 如果类没有用户定义的拷贝构造函数,并且所有基类和成员的拷贝构造函数也是平凡的,那么拷贝构造函数是平凡的。
- 移动构造函数是平凡的:
- 同样,如果没有用户定义的移动构造函数,并且所有成员和基类的移动构造函数都是平凡的,那么移动构造函数也是平凡的。
- 析构函数是平凡的:
- 类不能有用户定义的析构函数,且所有基类和成员的析构函数也必须是平凡的。
- 赋值运算符是平凡的:
- 如果类没有用户定义的赋值运算符,并且所有基类和成员的赋值运算符都是平凡的,那么赋值运算符也是平凡的。
如果一个类型有以下情况之一,它就不是平凡的:
- 拥有用户定义的构造函数、析构函数、拷贝/移动构造函数或赋值运算符。
- 类型中包含非平凡的成员变量或继承了非平凡的基类。
- 类型包含指针或使用动态内存分配。
平凡类型的意义:
- 性能优化
- Trivial 类型可以用
memcpy快速拷贝,而不用调用构造/析构函数。 - 适用于高性能场景,如 网络协议、游戏开发、低级别系统编程。
- Trivial 类型可以用
- 与 C 兼容
- Trivial 类型可以与 C 语言的数据结构直接兼容,避免额外的 C++ 特性(如虚函数、构造析构函数)。
- 标准库 STL 优化
std::vector<T>之类的容器在存储 Trivial 类型时,不需要调用构造/析构,提高效率。
示例:
struct TrivialType {int a;int b;
}; // 这是一个平凡类型struct NonTrivialType {int a;int b;NonTrivialType() { a = 0; b = 0; } // 用户定义的构造函数,非平凡
};
在上面的例子中:
TrivialType是平凡的类型,因为它没有用户定义的构造函数、析构函数、或其他非平凡的操作。NonTrivialType是非平凡类型,因为它有一个用户定义的构造函数。
平凡类型与字面值类型的关系:
平凡类型和字面值类型(literal type)有相似之处,但它们不是完全相同的概念。字面值类型要求类型必须能在编译期进行常量表达式求值,而平凡性更关注对象的构造、析构和拷贝的复杂度。
因此说下面的 node 是平凡的,尽管它的成员函数 f() 中有动态内存管理的操作:
struct node {int a;void f() {int *p = new int(4);delete p;}
};int main()
{cout<<boolalpha<<is_trivial<node>::value<<endl;constexpr node a = node();return 0;
}
27. typedef 陷阱
如果某个类型别名代指的是复合类型,那么把它用到声明语句里就会产生意想不到的效果:
int a = 20;
int b = 30;int main()
{typedef int *pint;const pint p1 = &a;const int* p2 = &a;return 0;
}
在上面的例子当中,虽然从形式上看,int * 是对 pint 的替换,但在 const 的修饰下,p1 和 p2 的含义完全不同。
- 对于
p1,const修饰的是pint,而pint是指向int类型的指针,因此const修饰的是指针,p1是一个指向int类型的常量指针。 - 对于
p2,const修饰的是int,因此p2是一个指向const int类型的普通指针。
const的修饰规则:在 C 语言中,const修饰的是它左边最近的标识符,但如果左边没有,它就会修饰右边的标识符。
28. char c = "好" vs. string s = "好"
char a = '好';这个语句不能编译通过,是因为 char 类型在 C++ 中通常是1 个字节,而一个中文字符“好”在任何现代编码(如 UTF-8 或 GBK)中都超过 1 个字节。
- 在 UTF-8 编码下,“好”这个字通常需要 3个字节(
E5 A5 BD)来表示。 - 在 GBK 编码下,它需要 2个字节。
虽然一些旧的编译器或特殊配置下可能允许这种写法,但通常会将其视为一个多字节字符字面量(multi-byte character literal),并将其值存储为 int 类型,但这是一种不安全且不可移植的行为,应避免使用。
std::string 是一个完全不同的概念。它是一个可变长度的容器,专门用于存储一系列字符,也就是我们所说的字符串。
" "字符串字面量:双引号" "用于表示一个字符串字面量。它本质上是一个以空字符\0结尾的字符数组(例如const char[])。- 内存分配:当编译器遇到
"好"这个字符串字面量时,它会在程序的数据段中创建一个包含“好”的 UTF-8 编码字节序列(E5 A5 BD)和一个终止符\0的数组。 - 对象构造:
std::string s = "好";这行代码会调用std::string的构造函数。这个构造函数会从字符串字面量所在的内存位置读取这些字节,并动态地为s分配足够的内存来存储它们。std::string知道它需要 3 个字节来存储“好”的编码,所以它会自动分配足够的空间。
string s = "好";
cout << s.size() << endl; // 3
for(int i = 0; i < s.size(); i ++ ) {cout << hex << "0x" << (unsigned int)(unsigned char)s[i] << ' ';
} // 0xe5 0xa5 0xbd
cout << endl;
29. <limits> vs. <limits.h>
<limits.h> 是 C 语言的标准库头文件,在 C++ 中也可以使用,但通常不推荐。它以宏定义的形式提供信息。
-
头文件:
<limits.h> -
内容: 它包含一系列宏,例如
INT_MAX、INT_MIN、CHAR_MAX等。 -
使用方式:
#include <limits.h> #include <iostream>int main() {std::cout << "int 类型的最大值是: " << INT_MAX << std::endl;std::cout << "char 类型的最小值是: " << CHAR_MIN << std::endl;return 0; } -
缺点:
- 非面向对象: 这些是全局宏,不属于任何命名空间,容易造成命名冲突。
- 非模板化: 它们只能提供基本类型的限制,无法用于自定义类型或泛型编程。
- 类型不安全: 由于是宏,可能会引入一些意外的副作用。
<limits> 是 C++ 标准库提供的头文件,它以模板类 std::numeric_limits 的形式提供信息。这是在现代 C++ 编程中推荐使用的头文件。
-
头文件:
<limits> -
内容: 它定义了模板类
std::numeric_limits,该模板为各种数据类型提供了静态成员函数和常量。 -
使用方式:
#include <iostream> #include <limits>int main() {// 使用模板类,指定类型std::cout << "int 类型的最大值是: " << std::numeric_limits<int>::max() << std::endl;std::cout << "char 类型的最小值是: " << std::numeric_limits<char>::min() << std::endl;// 浮点数类型std::cout << "double 类型的最小正非零值是: " << std::numeric_limits<double>::denorm_min() << std::endl;std::cout << "double 类型的精度是: " << std::numeric_limits<double>::digits10 << std::endl;return 0; } -
优点:
- 类型安全和模板化: 这是一个模板,可以用于任何类型,包括基本类型和自定义类型(如果为它们提供了特化)。
- 面向对象: 它是
std命名空间下的一个类,不会造成全局命名污染。 - 更丰富的信息: 它不仅提供了最大值和最小值,还提供了许多其他有用的信息,例如:
max()和min()lowest()(最低值,对有符号数和浮点数很重要)epsilon()(浮点数的最小可分辨差值)digits和digits10(有效位数)is_signed、is_integer等(类型属性)- ...以及更多
30. 类定义后的分号
之所以要在 class 或 struct 的定义后面写一个分号,是因为类体后面可以紧跟变量名以表示对该类型对象的定义。但并不建议这样做,因为这混淆了对类的定义和对于类对象的定义,把两种不同实体的定义杂糅在了一条语句中。
31. 不能使用圆括号初始化类内数据成员
这是因为如果使用圆括号初始化成员变量会导致语法歧义,可能被解析为函数成员,而不是成员初始化(The Most Vexing Parse) 。因此 C++ 不允许在类定义中使用圆括号来初始化成员变量。
32. 头文件不应包含using声明
如果头文件里有某个 using 声明,那么每个使用了该头文件的文件就有有这个声明。对于某些程序来说,在不经意间包含了一些名字,反而可能产生始料未及的命名冲突。
33. signed和unsigned混用
一般容器的 size() 返回的都是一个 size_type 类型的无符号数,因此如果我们使用 size(),就不要再使用 int 了,从而避免可能的有无符号数的混用问题。
34. string
如果我们希望初始化一个空 string 对象,直接 string s; 即可,无需 string s("");,因此 string 会调用默认构造函数。
使用标准读入读取 string 对象时,会忽略输入前后的空格。当使用 getline 读取 string 时,不会忽略输入前后的空格,但最后的换行符读取后会被丢弃。
对 string 执行加法运算时,需要确保加号两侧至少有一个 string 对象,这是因为 C++ 语言中的字符串字面值并不是标准库类型 string 的对象。
之所以这样出了历史原因之外,也是为了与 C 兼容,因为 C++ 的 string 是动态内存管理,而字符串字面量是静态内存管理。如果 C++ 要实现字符串字面量是 string 对象这一操作,就要改变字符串字面量的内存管理方式,以适配 string,但这样就与 C 中的字符串字面量不是同一个概念了。
string s = "hello" + "world";
// a.cpp:9:24: error: invalid operands of types 'const char [6]' and 'const char [6]' to binary 'operator+'
// 9 | string s = "hello" + "world";
// | ~~~~~~~ ^ ~~~~~~~
// | | |
// | | const char [6]
// | const char [6]
35. 下标越界
C++ 标准并不要求标准库检测下标是否合法,也就是说当我们的下标不合法时,C++ 并不会报错。但一旦使用了一个超出范围的下标,就会产生不可预知的后果。当我们通过一个越界的索引访问容器内容时,可能导致缓冲区溢出(buffer overflow)。
36. const容器
容器对象也只是普通的类对象,对于 const 属性的类对象,它只能调用类的 const 属性的成员方法。
以 vector 为例,如果我们声明了一个 const vector<T> v;。那么这个向量对象的成员变量是不可修改的,这意味着你不能调用 push_back,pop_back,resize 等改变向量大小的方法。同理,我们也不能改变容器元素,因为无论是迭代器、指针还是 operator[],都会调用 const 版本。
再看下面的代码:
const vector<int> a{1, 2, 3};
for(auto &x : a) {cout << x << ' ';x = 1024; // ???
}
代码中对 x 的修改是不合法的,因为此时 x 被推导为 const int&,这是因为当我们通过范围 for 循环遍历容器时,相同于通过迭代器来遍历容器。
在范围
for循环内部,编译器会为容器创建一个迭代器,并在每次迭代时使用迭代器访问容器元素。
而当容器类型是 const 时,迭代器的类型是 const_iterator 而不是 iterator,上面的代码相当于:
const vector<int> a{1, 2, 3};
for(vector<int>::const_iterator it = a.begin(); it != a.end(); ++ it) {cout << *it << ' ';
}
37. vector
vector 不是一个类型。要注意,vector 只是一个模板,它并不是一个类型,由 vector 生成的类型必须包含 vector 中元素的类型,例如 vector<int>。
当使用花括号对 vector 进行列表初始化时,会优先将花括号里的内容解释为容器的初始值列表,只有列表初始化无法进行时,才会考虑其他初始化方法。例如:
// 10无法为string类型初始化,因此这里解释为创建10个string元素
// 即:vector<string> s(10)
vector<string> s{10};
cout << s.size() << endl; // 10vector<string> s2{"hello", "world!"};
cout << s2.size() << endl; // 2
C++ 标准要求 vector 应该能在运行时高效快速的添加元素。因此既然 vector 对象能高效的增长,那么在定义 vector 对象的时候设定其大小也就没什么必要了,事实上这么做性能可能更差。只有一种情况例外,那就是所有元素的值都一样。但在某些情况下,如果已知大约要添加元素的数量,并且元素数量很大,可以通过 reserve 预留空间以提高性能,因为 vector 的动态扩容可能需要拷贝数组元素,另外频繁的分配和释放可能导致内存碎片化进而影响程序的整体性能。
如果要把 vector 当作 “静态容器”(也即当作普通数组使用,不会改变大小),也可以预先指定 vector 的大小。
一个容易忽略的点就是,如果容器内元素支持比较运算,那么 vector 也是支持比较运算的。其运算规则和 string 相似。
38. iterator
使用迭代器进行 for 循环时,通过 == 或 != 来判断循环是否结束比 < 更推荐,因为不是所有的容器都有 < 运算符,只有支持下标运算的容器才重载了 <,但是所有的容器都支持 == 和 !=。
通过 begin 和 end 获取迭代器时,如果容器类型不是常量,返回的是 iterator;如果容器类型是常量,则返回的是常量迭代器 const_iterator,通过该迭代器遍历元素只能读不能写。但有时,我们只希望获得常量迭代器,则可以通过 cbegin 和 cend 实现。
39. array
所谓数组的维度,是指数组中元素的个数,即对于 a[d] 来说,d 就是数组的维度。在编译的时候数组的维度就因该是已知的,也就是说数组的维度必须是一个常量表达式。
严格来说,C++ 没有多维数组,通常所说的多维数组,其实就是数组元素为数组的数组。对于多维数组的初始化,有两种初始化方式:
- 有内嵌花括号的初始化(将数组的数组视为多维数组)
int a[3][3] = {{1}, // 第一维剩余部分默认初始化{2}, // 第二维剩余部分默认初始化// 第三维默认初始化
};
/*
1 0 0
2 0 0
0 0 0 */
- 没有内嵌花括号的初始化(将数组的数组视为一维数组)
int a[3][3] = {1, 2, 3, 4 // 剩余元素默认初始化
};
/*
1 2 3
4 0 0
0 0 0 */
对于多维数组元素的遍历,有一些比较有意思的形式:
int a[3][3] = {1, 2, 3,4, 5, 6,7, 8, 9
};int main()
{// case1:指针{for(int (*p)[3] = a; p != a + 3; p ++ ) {for(int *q = *p; q != *p + 3; q ++ )cout << *q << ' ';cout << endl;}}// case2:auto{for(auto *p = a; p != a + 3; p ++ ) {for(auto *q = *p; q != *p + 3; q ++ )cout << *q << ' ';cout << endl;}}// case3:auto+范围for循环// 数组也可以使用 range-for 进行遍历{for(auto &p : a) {for(auto &q : p)cout << q << ' ';cout << endl;}}return 0;
}
在 C++ 中,不允许定义长度为 0 的数组,它应该导致编译错误。但在 C99 中,允许大小为 0 的“灵活数组成员”,它只能在结构体的最后一个成员中使用,形式上并不指定数组的大小,而是在结构体实例化之后动态分配内存。C++ 中禁止这么做是因为 vector 是一个更好的替代品。
我们可以通过 sizeof 运算符得到数组的大小进而可以根据数组元素的大小可得到数组元素的个数,不过对于上面提到的“灵活数组成员”,在编译时无法直到它的大小,因此无法使用 sizeof 运算符。因此如果使用“灵活数组成员”,它的一般形式为:
struct Node {int size;int data[];
};int main()
{int n = 10;Node *node = (Node *)malloc(sizeof(Node) + n * sizeof(int));node->size = n;for(int i = 0; i < n; i ++ ) {node->data[i] = i + 1;}for(int i = 0; i < node->size; i ++ ) {cout << node->data[i] << ' ';}cout << endl;return 0;
}
在使用初始值列表对数组初始化时,如果制定了数组的维度,那么初始值列表中元素的个数不能大于数组的维度;如果初始值列表中元素的个数小于数组的维度,那么剩余元素会初始化为默认值,因此 int a[n]={}; 即可初始化所有数组元素为 0。
字符数组有一种额外的初始化形式,即使用字符串字面值来初始化。不过要注意字符串字面值结尾处的空字符 \0 也会拷贝到数组当中:
char a1[] = "1234";
cout << sizeof(a1) << endl; // 5
char a2[4] = "1234"; // [ERROR]没有额外空间存放空字符
一个容易忽视的点就是,我们可以通过 range-for 循环来遍历数组。这是因为数组大小在编译期就已知了,并且数组的大小还会被隐式的存储在栈或堆上。这也同样解释了我们为什么能使用 sizeof 运算符计算数组大小。
数组的存储位置(栈或堆)取决于数组的声明方式:
- 栈数组:如果数组是局部数组(例如
int arr[10];),它通常存储在栈上。在栈上分配的数组大小通常是固定的。- 堆数组:如果数组是动态分配的(例如
new int[10];或使用std::vector<int>),它的大小会存储在堆上,并且大小可能会在运行时动态变化。
不过要主义的是,当我们使用范围 for 循环遍历多维数组的时候,除了最内层的循环外,其它所有循环的控制变量都应该是引用类型。这是因为如果不通过引用类型来控制外层循环,由于数组会自动转换为指针,auto 会把数组推导为一个指针,而内层循环试图在一个指针内进行遍历,这显然是不合法的。
数组还有一个特性,那就是在很多用到数组的地方,编译器会自动地将其替换为一个指向数组首元素的指针。这就会导致一些特殊的情况:
int a[] = {1, 2, 3};
auto b(a); // <==> int *b = a;
实际上相当于:auto c(&a[0]); // <==> int *c = a;
当程序直接使用数组的名字时,也会自动将其转换为指向数组首元素的指针。看下面的例子:
int a[3][3] = {1, 2, 3,4, 5, 6,7, 8, 9
};int main()
{for(int (*p)[3] = a; p != a + 3; p ++ ) {for(int *q = *p; q != *p + 3; q ++ )cout << *q << ' ';cout << endl;}return 0;
}
但是当使用 decltype 关键字时,由数组到指针的转换不会发生,decltype(a) 返回的是由三个整数构成的数组。另外,作为取地址符(&),sizeof 及 typeid 等运算符的运算对象时,数组也不会自动转换为指针。
数组不是一个 class,因此它也没有 begin 和 end 成员函数,如果我们想通过迭代器的形式遍历数组,需要自行指定 begin 和 end:
int a[] = {1, 2, 3};
int *begin = &a[0], *end = &a[3];
尽管这样可行,但是并不可靠,因为我们需要手动指定地址,而人总是会出错的!为了解决这个问题,C++11 新标准引入了两个名为 begin 和 end 的函数,它可以接受数组或容器作为它的参数:
int a[] = {1, 2, 3};
int *b = begin(a), *e = end(a);
另外 C++11 引入的 array 也可以解决该问题,它内置了 begin 和 end 等成员方法。
数组的索引下标允许是一个负数,相当于当前指针前面的位置。而 vector 和 string 的下标是无符号数,不能为负,因此如果你传入一个负数,会被解释为一个正数,这个正数往往很大而越界,使用它的应为是未定义的。如果使用 at 函数来访问会抛出一个 out_of_range 异常。
数组下标可以为负数的底层逻辑就是,
a[n]<==>*a+n,即,数组下标相当于指针运算,而指针当然可以向前寻址,但这种寻址未必总是合法的。int a[3] = {1, 2, 3}; int *b = &a[1]; cout << b[-1] << endl; // 1
40. c style character string
C 风格字符串不是一种类型,而是为了表示和使用字符串而形成的一种约定成俗的写法。按此习惯书写的字符串存放在字符数组(char[])中并以空字符(\0)结尾。
不能像 string 一样直接通过 < 运算符来比较两个 C 风格字符串的大小。因为我们已经知道编译器会把对数组的操作转换为对指针的操作,因此直接比较两个字符数组相当于比较这两个数组的首指针。如果我们想要比较两个字符数组,需要使用 strcmp(p1,p2) 函数。如果 p1==p2 返回 0;如果 p1<p2 返回一个负数(-1);如果 p1>p2 返回一个正数(1)。
通过 strlen(p) 可以获取字符数组的长度,不包含空字符。
通过 strcat(p1,p2) 可以将 p2 附加到 p1 之后,但要保证 p1 有足够的空间来容纳 p2。
最后,我们并不推荐使用 C 风格字符串,因为 C++ 标准库的 string 更安全,更高效。
考虑到为了与 C 兼容,C++ 提供了 string 转换为 C 风格字符串的方式:string.c_str(),它返回一个 const char* 对象。
41. buffer overflow
缓冲区溢出(Buffer Overflow)是编程中的一种常见漏洞或错误,尤其在 C 和 C++ 语言中更为常见。它发生在程序尝试写入比目标缓冲区(通常是一个数组或内存块)所能容纳的更多数据时,导致数据溢出到相邻的内存区域。这可能导致程序崩溃、内存破坏,甚至被恶意利用进行攻击。
缓冲区溢出的工作原理:
在程序中,缓冲区通常是分配给特定类型数据的固定大小的内存块,例如字符数组或整数数组。当程序试图将超过该大小的数据写入缓冲区时,溢出的数据会覆盖到缓冲区之外的内存区域。这些区域可能包含其他数据、指针,甚至程序的执行路径,这为恶意用户提供了注入代码或控制程序执行的机会。
缓冲区溢出的例子:
以下是一个简单的 C++ 代码示例,展示了缓冲区溢出是如何发生的:
#include <iostream>
#include <cstring> // 包含 strcpyint main() {char buffer[10]; // 定义了一个长度为10的字符缓冲区// 如果输入字符串超过了缓冲区大小,就会发生缓冲区溢出strcpy(buffer, "This is a very long string"); // 溢出错误std::cout << "Buffer: " << buffer << std::endl;return 0;
}
在这个例子中,buffer 只能容纳 10 个字符,但是我们使用 strcpy 复制了一个比它长得多的字符串。这会导致缓冲区溢出,溢出的数据可能覆盖程序的其他内存区域,从而导致未定义行为。
缓冲区溢出的危害:
- 程序崩溃:如果溢出数据覆盖了重要的内存区域,程序可能会立即崩溃。
- 安全漏洞:攻击者可以利用缓冲区溢出漏洞注入恶意代码(称为代码注入攻击),并劫持程序的控制流,执行任意指令。这是早期许多恶意软件和病毒传播的方式。
- 内存破坏:溢出可能破坏程序的其他数据结构,导致数据丢失或错误。
- 远程代码执行:通过精心设计的输入,攻击者可能远程控制受影响的程序,执行任意代码,甚至获取系统权限。
缓冲区溢出的种类:
-
栈缓冲区溢出:
- 发生在栈上分配的缓冲区上。例如局部变量的数组。
- 溢出可能覆盖函数的返回地址,从而导致控制流劫持。
void foo() {char buffer[8];strcpy(buffer, "A long string"); } -
堆缓冲区溢出:
- 发生在堆上分配的缓冲区上(如通过
malloc或new分配的内存)。 - 堆溢出可以覆盖其他分配在堆上的数据或破坏内存管理信息。
void foo() {char* buffer = new char[8];strcpy(buffer, "A long string"); } - 发生在堆上分配的缓冲区上(如通过
如何防止缓冲区溢出:
-
使用安全函数:
- 避免使用不安全的函数,如
strcpy、sprintf,改用安全版本的函数,如strncpy、snprintf,指定要复制的最大字符数以防止溢出。
char buffer[10]; strncpy(buffer, "This is a very long string", sizeof(buffer) - 1); buffer[sizeof(buffer) - 1] = '\0'; // 确保最后一个字符是 null 终止符 - 避免使用不安全的函数,如
-
手动检查输入长度:
- 在处理用户输入或外部数据时,确保检查输入数据的长度,并将其限制在缓冲区的大小范围内。
-
使用标准库的容器类:
- 在 C++ 中,优先使用
std::vector、std::string等动态调整大小的容器,避免手动管理固定大小的缓冲区。
std::string buffer = "This is a long string"; - 在 C++ 中,优先使用
-
启用编译器的安全机制:
- 现代编译器提供了多种缓冲区溢出保护机制,例如栈保护(Stack Canaries)和地址空间布局随机化(ASLR),这些技术可以在运行时检测溢出并终止程序。
例如,编译时加上
-fstack-protector标志可以启用栈保护:g++ -fstack-protector your_program.cpp -
使用静态或动态分析工具:
- 使用静态分析工具(如
Clang的静态分析器、Coverity)或动态分析工具(如Valgrind)来检测缓冲区溢出等内存问题。
- 使用静态分析工具(如
1. 栈保护(Stack Canaries)
栈保护(也叫栈金丝雀)是一种在编译时加入的安全措施,用于防止栈溢出攻击。栈溢出攻击通常利用缓冲区溢出漏洞来覆盖函数的返回地址,从而执行攻击者的恶意代码。栈金丝雀通过在栈帧中插入一个已知的、随机的值(通常是一个“金丝雀值”)来检测栈溢出。
“金丝雀”(Canary)这个名字来源于一个非常形象的历史类比:在煤矿中,矿工们曾经使用金丝雀来检测有毒气体。因为金丝雀比人类对有毒气体更敏感,如果矿井中的有毒气体浓度过高,金丝雀会先中毒并死亡,矿工们就可以及时撤离,避免危险。
在栈保护技术中,栈金丝雀的作用类似于这种检测机制,用于检测栈溢出攻击。栈金丝雀被嵌入到栈帧中,作为一种早期警告机制,检测栈数据是否被非法篡改,从而防止缓冲区溢出攻击通过覆盖返回地址来执行恶意代码。
工作原理:
- 在函数调用时,栈保护机制会在返回地址和局部变量之间插入一个金丝雀值(通常是一个随机数),例如一个特定的整数值或字符串。
- 在函数退出之前,编译器会检查这个金丝雀值是否被修改。如果金丝雀值被修改了,说明发生了栈溢出,攻击者可能试图覆盖返回地址或其他重要数据。
- 如果栈金丝雀值被修改,程序会立即终止,从而防止进一步的攻击。
举个例子:
假设你有以下的代码:
void vulnerable_function() {char buffer[100];gets(buffer); // 不安全的输入函数,可能导致缓冲区溢出 }在开启栈保护的情况下,编译器会将栈金丝雀值插入到
buffer和函数的返回地址之间。即使攻击者试图通过溢出buffer来覆盖返回地址,栈金丝雀值的改变也会被检测到,从而触发程序的异常终止。常见的编译器选项:
- GCC 和 Clang 提供了
-fstack-protector或-fstack-protector-all等选项来启用栈保护。- MSVC 编译器使用
/GS选项来启用栈保护。2. 地址空间布局随机化(ASLR)
地址空间布局随机化(ASLR) 是一种通过随机化内存地址来提高程序安全性的技术。ASLR 使得每次程序执行时,关键的内存区域(如堆、栈和共享库)的地址都被随机化,从而使得攻击者难以预测或控制程序的内存布局,减少了利用缓冲区溢出漏洞的机会。
工作原理:
- ASLR 随机化程序的 栈、堆 和 共享库 的内存地址,以及其他可能被攻击者利用的内存区域。
- 这样,攻击者无法预测恶意代码应该加载到哪个内存地址,从而难以通过已知的内存地址执行恶意代码。
举个例子:
如果攻击者试图通过缓冲区溢出来覆盖一个返回地址并跳转到攻击代码,ASLR 随机化了栈的地址,这就使得攻击者无法确定返回地址的实际位置,因此很难成功利用这个漏洞。
常见的操作系统支持:
- Linux:现代 Linux 操作系统使用 ASLR 来随机化进程的内存布局。可以通过
/proc/sys/kernel/randomize来控制 ASLR 设置。- Windows:Windows 从 Vista 版本开始支持 ASLR,可以在操作系统的安全设置中启用。
- macOS:macOS 也启用了 ASLR,并通过硬件和软件一起增加了程序的安全性。
42. IO缓冲区刷新问题
在 C++ 中,当我们对文件流进行操作的时候,它们与一个 streambuf 类型的缓存(buffer)联系在一起。这个缓存实际是一块内存空间,作为流和物理文件的媒介。例如,对于一个输出流, 每次成员函数 put(写一个单个字符)被调用,这个字符不是直接被写入该输出流所对应的物理文件中的,而是首先被插入到该流的缓存中。
当缓存被排放出来时,它里面的所有数据或者被写入物理媒质中(如果是一个输出流的话),或者简单的被抹掉(如果是一个输入流的话)。这个过程称为刷新(flush),它会在以下任一情况下发生:
| 情况 | 说明 | 示例 |
|---|---|---|
| 程序正常结束 | main() 返回时自动刷新所有输出流 |
return 0; |
| 缓冲区满 | 缓冲区达到容量时自动刷新 | for (int i =0; i<10000; i++) cout << i; |
| 显式刷新 | 使用 flush 或 endl |
cout << "Hello" << flush; |
unitbuf 设置 |
每次输出后自动刷新 | cout << unitbuf; |
| 关联流刷新 | 读取 cin 会刷新 cout |
cin >> x;(如果 cout 和 cin 关联) |
在 C++ 中,iostream 的流对象(如 cin、cout、cerr)与 C 标准库的 stdio 流(如 stdin、stdout、stderr)是相关联的,但它们并不直接共享同一块缓冲区。
iostream使用streambuf(C++ 流缓冲区)stdio使用FILE*缓冲区(C 标准库缓冲区)

1. C++
iostream的缓冲机制(streambuf)C++ 中的
iostream流对象(例如cin、cout、cerr)是基于std::streambuf的。std::streambuf是一个抽象类,负责管理流的缓冲区。流缓冲区通常是一个内存缓冲区,用于暂存输入输出数据,直到数据被写入到实际的输出设备(例如控制台、文件等)。streambuf使得 C++ 可以有效地管理输入输出,提高性能。
- 输入流(如
cin)通过std::istream处理数据,std::istream内部维护着一个std::streambuf(如std::cin.rdbuf()),负责从缓冲区中读取数据。- 输出流(如
cout)通过std::ostream处理数据,std::ostream内部也维护着一个std::streambuf,负责将数据写入缓冲区,最终再输出到终端或文件。2. C 标准库的
stdio缓冲机制(FILE*)C 标准库的
stdio流(如stdin、stdout、stderr)使用FILE*类型来管理缓冲区。每个FILE*流对象代表一个与输入输出相关联的缓冲区。C 标准库提供了一些基本的输入输出函数,如scanf、printf、fgets、fputs等,这些函数与FILE*流对象一起工作。
- 输入流(如
stdin)是通过fgetc、fscanf等函数从FILE*缓冲区中读取数据的。- 输出流(如
stdout)是通过fputc、fprintf等函数将数据写入FILE*缓冲区,最终输出到终端或文件。
默认情况下,C++ 的 iostream 和 C 的 stdio 是同步的,这意味着,它们之间的输出会保持顺序一致,并且有时会共享数据。实现同步是为了确保,混合使用 cin 和 printf、scanf 时输出的顺序不会错乱。具体是通过 std::ios_base::sync_with_stdio(true) 来设置的。
同步同时会带来一定的性能开销,这些开销时导致 iostream 变慢的主要原因:
-
频繁的缓冲区状态检查
- 每次
cout << x或cin >> x都会检查stdio的缓冲区状态(是否需要刷新或同步)。
- 每次
-
隐式的刷新操作
-
混合使用时,可能频繁触发
fflush(stdout)或fflush(stdin),例如:printf("A"); cout << "B"; // 隐式刷新 stdout,确保 "A" 先输出
-
-
虚函数调用 + 锁开销
iostream的operator<<和operator>>是虚函数调用,比printf/scanf的直接解析更慢。- 同步机制引入了额外的锁或原子操作(即使单线程也有少量开销)。
operator<<和operator>>是虚函数(通过std::basic_ostream和std::basic_istream的继承体系实现),运行时需要通过虚函数表(vtable)动态解析调用目标。每次调用
cout << x或cin >> x时,需要:- 查虚函数表。
- 跳转到实际函数地址。
- 处理可能的类型转换(如
int到string的格式化)。
对比
printf/scanf- 直接函数调用:
printf和scanf是 C 标准库的非虚函数,编译时直接绑定地址,无运行时查找开销。 - 轻量级解析:
格式字符串(如"%d")在编译时解析,运行时直接按格式处理数据
关闭同步(提升性能):
std::ios::sync_with_stdio(false); // 关闭同步
此时 iostream 和 stdio 的缓冲区独立,混合使用可能导致输出顺序问题。
int main()
{ios_base::sync_with_stdio(false);for(int i = 0; i < 10; i++) {cout << "1 ";printf("2 ");}cout << endl; // 刷新cout的缓冲区printf("\n");return 0;// 刷新cout和printf的缓冲区
}
//1 1 1 1 1 1 1 1 1 1
//2 2 2 2 2 2 2 2 2 2
43. std::sort 的底层是怎么实现的?
std::sort 主要是三种算法的结合体:插入排序,快速排序,堆排序。
| 算法 | 时间复杂度 | 优点 | 缺点 |
|---|---|---|---|
| 插入排序 | O(\(N^2\)) | 当数据量很少时,且部分有序时,效率比较高。 | 当数据量比较大时,时间复杂度比较高。 |
| 快速排序 | 平均 O(\(Nlog^N\))最坏 O(\(N^2\)) | 大部分时候性能比较好。 | 算法时间复杂度不稳定,数据量大时递归深度很大,影响程序工作效率。 |
| 堆排序 | O(\(Nlog^N\)) | 算法时间复杂度稳定,比较小,适合数据量比较大的排序。 | 堆排序在建堆和调整堆的过程中会产生比较大的开销,数据量少的时候不适用。 |
STL 的 std::sort 函数是基于 Musser 在 1996 年提出的 内省排序(Introspective sort)算法 实现。这个算法是个缝合怪,它汲取了插入排序、堆排序以及快排的优点:
- 针对大数据量,使用快排,时间复杂度是 O(NlogN);
- 若快排递归深度超过阈值
__depth_limit,改用堆排序,防止快排递归过深,同时保持时间复杂度仍是 O(NlogN); - 当数据规模小于阈值
_S_threshold时,改用插入排序。

std::sort采用的是分治思维,先采用快速排序,将整个区域分成多个子区域,每个子区域内部根据数据量采用不同算法。- 分治后,各个子区域局部有序后再通过整个区域进行排序。
44. 优先级、结合律、求值顺序
44.1 求值顺序
复合表达式是指含有两个或多个运算符的表达式。求复合表达式的值首先需要将运算符和运算对象合理的组合在一起,优先级和结合律决定了运算对象组合的方式。也就是说,它们决定了表达式中每个运算符对应的运算对象来自表达式的哪一部分。表达式中的括号无视上述规则。
优先级虽然规定了运算对象的组合方式,但是并没有说明运算对象按照什么顺序求值。在大多数情况下,不会明确指明求值的顺序。例如:
int val = f1() + f2();
val 是一个由加法组合而成的表达式,我们可以明确 f1() 和 f2() 一定会在执行加法之前调用。但是对于 val,我们无法确定是 f1() 先求值还是 f2() 先求值。
对于那些没有指定执行顺序的运算符,如果表达式指向并修改了一个对象,将会引发错误并产生未定义的行为。例如:
int val = 0;
cout << val << ' ' << ++ val << endl; // 未定义行为
对于上面的输出语句,编译器可能先求 ++val 的值再求 val 的值,输出 1 1;也可能先求 val 的值再求 ++val 的值,输出 0 1。甚至编译器还可能做出完全不同的操作。但无论怎样,由于表达式的行为是不可预知的,因此无论编译器生成什么代码程序都是错误的。
不过有四种运算符明确规定了运算对象的求值顺序:
&&:左侧为真右侧才执行||:左侧为假右侧才执行,:右侧结果的值,逗号表达式会从左到右依次求值,但只返回最后一个表达式的值?::真时计算左侧的值,假时计算右侧的值
最后,我们要强调的是:运算对象的求值顺序与优先级和结合律无关,在一条形如 f()+g()*h()+j() 的语句中:
- 优先级规定,
g()的返回值和h()的返回值相乘 - 结合律规定,
f()的返回值先与g()和h()的乘积相加,所得结果再与j()的返回值相加 - 对于这些函数的调用顺序并没有明确规定
因此,如果 f、g、h 和 j 是无关函数,它们不会改变同一对象的状态,那么它们的调用顺序不受限制。反之,如果其中某几个函数影响同一对象,则它是一条错误的表达式,将产生未定义的行为。
因为 C++ 语言并没有规定加法和乘法运算符的运算对象的求值顺序,这给编译器留下了优化的余地,编译器可以选择任意的求值顺序。
因此,如果我们改变了某个运算对象的值,在表达式的其他地方不要再使用这个运算对象。
44.2 关于优先级和结合性的思考
准确来说,优先级和结合性确定了表达式的语义结构,不能跟求值次序混为一谈。
语义结构就涉及到编译器根据文法规则构建语法树。
所谓语义结构,我们就可以理解为“加圆括号的过程”,通过加圆括号,编译器就能准确无误的确定执行顺序了,表达式的语义就是固定的了,例如:
flag ? a : b = 1024转换为flag ? a : (b = 1024)cout << a << b转换为(cout << a) << ba = b = c = 1024转换为a = (b = (c = 1024))*it++转换为*(it++)cond ? ++x, ++y : --x, --y转换为(cond ? ++x, ++y : --x), --y
通过这个过程,我们也能更好的理解,求复合表达式的值首先需要将运算符和运算对象合理的组合在一起,优先级和结合律决定了运算对象组合的方式。
45. 取余的符号
首先,参与取余运算的两个运算对象必须是整数类型,如果是其它类型此时并不会进行自动类型转换。
int a = 4, b = 0;
cout << a % b << endl; //Float Point Exceptiondouble c = 4, d = 2;
cout << c % d << endl; // error: invalid operands of types 'double' and 'double' to binary 'operator%'
对于 m % n,无论在 C 还是 C++ 中,如果 m 不能整除 n,取模结果的符号都是和 m 相同的。为什么会这样设计?
考虑取模运算的定义,如果 m 和 n 是整数且 n 非 0,则 m=(m/n)*n+m%n。为了除法和取模操作之间的一致性得以保持。取模运算的结果需要补偿整数除法向零取整带来的偏差,这样才能保证余数加上商乘以除数等于原始的被除数 m。
即,由于向零取整的缘故,导致当商为正数时,这个正数更小了;当商为负数时,这个负数更大了。因此当 m 为负数时,余数 m%n 也应该为负数,以抵消商变大的影响;当 m 为正数时,余数 m%n 也应该为正数,以抵消商变小的影响。
在数学上,模运算(Modulus) 是用来求 补余量的。所谓补余量,指的是对于 \(a \%b=r\),\(a/b=q\) 确保 \(a=bq+r\),且 \(0≤r<∣b∣\)(无论 a 的符号如何)。
在 C++ 中,由于 \(a/b\) 是向下取整,因此 \(r\) 的符号永远与 a 的符号相同。
而在 Java 和 Python 中,取模符号与 n (除号)是相同的。这在数学上更常见。
46. 右结合律的运算符
平常我们遇到的运算符基本都是左结合律的,但也有一部分运算符是右结合律的:
- 赋值运算符(
=,+=,-=,\*=,/=,%=,<<=,>>=,&=,|=,^=) - 三元条件运算符(
?:) - 单目运算符(
++,--,+,-,!,~,\*,&,(type))
例如:*++it,++*it,*it++
*++it相当于*(++it)++*it相当于++(*it)*it++相当于*(it++)
* 和 ++ 的优先级是相同的,所以考虑结合律,单目运算符的结合律是从右向左的。
和 operator= 一样,operator+= 也可以连加,因此它也需要返回左值。结合律为自右向左。
int a = 1, b = 2, c = 3;
a += b += c += 3;
// c = 6
// a += b += 6
// b = 8
// a += 8
// a = 9
cout << a << ' ' << b << ' ' << c << endl;
47. 复合赋值运算符
相较于 A = A op C 的形式,A op= C 可以少访问一次 A。因为编译器通常将 A op= C 优化为仅对 A 的值进行一次访问(仅一次写操作或一次读取-写入操作,具体取决于硬件架构),因为它不需要先读取 A 的值再写回,能够直接修改 A。因此某些情况下可以些许提高程序性能。
- 内存访问的代价:在现代计算机系统中,内存访问是相对较慢的操作,尤其是在
A是一个大对象(如数组、类实例等)时。减少内存访问次数可以提高性能,特别是在嵌套循环、批量计算或者大型数据集上进行操作时。 - CPU 指令优化:
A op= C通常可以通过更少的指令实现,而A = A op C可能涉及更多的指令,尤其在涉及临时变量和额外的内存读取时。复合赋值操作允许编译器利用 CPU 寄存器和流水线(pipeline)的优化,使代码执行得更高效。
现代编译器通常能智能地优化代码,但 op= 形式明确提示编译器进行内存访问的优化。尽管编译器可能对 A = A op C 进行一些优化,但使用 A op= C 形式更直接,避免对 A 的多余读取。
48. vector无法直接保存引用类型元素
48.1 为什么 vector 无法直接存储引用类型
一般来说,标准库的容器(包括 vector)都要求元素类型是可复制和可移动的,以便在调整容量时能重新排列内部的元素。而引用显然不可以复制和移动。
其次,容器一般使用动态空间,我们需要一个指针指向分配的动态空间,指针的类型就是元素类型。然而引用只是一个别名,它没有地址,因此不能定义指向引用的指针。
如果我们将 vector 的元素类型设置为引用,语法上是没有错误的,这就导致我们无法立即察觉错误。并且编译报错由于涉及模板,也是完全看不懂。
48.2 std::reference_wrapper
我们可以通过直接存储值或者指针的方式取代引用,如果执意使用引用类型,可以用 <functional> 里的 std::reference_wrapper:
#include <vector>
#include <iostream>
#include <functional> // 需要包含此头文件int main() {int a = 1, b = 2, c = 3, d = 4, e = 5;std::vector<std::reference_wrapper<const int>> v{a, b, c, d, e};for (const int& num : v) { // 可以直接用引用访问std::cout << num << " ";}// 输出:1 2 3 4 5
}
std::reference_wrapper 是 C++ 标准库(<functional> 头文件)提供的一个工具,用于在容器(如 std::vector)中存储引用,同时保持引用语义。
std::reference_wrapper 是一个轻量级包装类,模拟引用行为,同时可以被存储和操作。
48.2.1 1. 与普通引用的区别:
| 特性 | T&(普通引用) |
std::reference_wrapper<T> |
|---|---|---|
| 能否存储在容器中 | ❌ 不能 | ✅ 可以 |
| 能否重新绑定 | ❌ 不能(初始化后固定) | ✅ 可以(通过 = 赋值) |
| 是否占用额外内存 | ❌ 不额外占用 | ⚠️ 轻微额外开销(通常是一个指针) |
能否用于 std::bind |
❌ 不能 | ✅ 可以(std::bind 内部使用 reference_wrapper) |
48.2.2 2. 适用场景
在容器中存储引用:
std::vector<std::reference_wrapper<std::string>> names;
std::string s1 = "Alice", s2 = "Bob";
names.push_back(std::ref(s1));
names.push_back(std::ref(s2));names[0].get() = "Charlie"; // 修改 s1
std::cout << s1; // 输出 "Charlie"
用于 std::bind:
#include <functional>void foo(int& x) {x += 10;
}int main() {int val = 5;auto func = std::bind(foo, std::ref(val));func(); // val 变为 15
}
用于多线程(避免拷贝)
#include <thread>void worker(int& counter) {counter++;
}int main() {int count = 0;std::thread t(worker, std::ref(count)); // 避免拷贝,直接传引用t.join();std::cout << count; // 输出 1
}
总结:
std::reference_wrapper允许在容器中存储引用,弥补了 C++ 引用不能直接存储的缺陷。- 适用于
std::vector、std::bind、多线程等场景。 - 比指针更安全(不能为
nullptr,且语义更清晰)。
49. 三目(条件)运算符
三目运算符的优先级相当低,仅仅比赋值和逗号表达式的优先级高,因此当用到三目运算符时建议总是加上括号,即 (cond ? exprexpr2)。
当三目运算符的两个表达式都是左值或能转换为同一类型的左值类型时,运算的结果是左值;否则运算的结果是右值。
int a = 1, b = 2;
bool flag = true;(flag ? a : b) = 1024;
cout << a << endl; // 1024
cout << b << endl; // 2
注意对于上面的 (flag ? a : b) = 1024; 如果不加括号,即 flag ? a : b = 1024; 编译器会把它解释为 flag ? a : (b = 1024); 而不是 (flag ? a : b) = 1024
这是因为编译器在处理 flag ? a : b = 1024; 时,会将这个表达式看成三个部分:
- condition:
flag - case true:
a - case false:
b=1024
50. npos
npos 是 C++ 中定义的一个常量,通常用于表示字符串操作中的无效位置。它的全称是 "no position"。
在 C++ 的标准库中,npos 是 std::string 和其他相关容器(如 std::wstring、std::u16string 等)中的一个特殊的静态常量。npos 的值通常被定义为 -1,但由于它是无符号类型(std::string::size_type),实际上会被解释为一个非常大的数字(通常是 std::string::size_type 能表示的最大值,通常是 size_t(-1))。因此,npos 表示一种特殊的情况,即找不到某个位置。
使用场景:字符串查找: 当在字符串中查找子串时,如果子串不存在,返回值会是 npos。
find函数在未找到目标字符或子字符串时返回std::string::npos。由于npos是一个极大的数值,它可以确保与有效索引进行比较时不会意外地与一个真实的索引值相等。
51. 位运算符
参与位运算的对象可以是带符号的,也可以是无符号的。如果运算对象是带符号的且它的值为负,那么位运算符如何处理运算对象的“符号位”依赖于机器。而且,此时的左移操作可能会改变符号位的位置,因此这是一种未定义行为。所以强烈建议仅将位运算用于处理无符号类型。
一般来说,如果运算对象是“小整形”,它的值会被自动提升为较大的整数类型。例如 char 和 short 会自动提升为 int。注意不是提升为 unsigned int。例如,对于下面的程序,计算输出的值是一个负数。
uint8_t q = 0b01110001;
cout << (~q << 6) << endl; // -7296
52. sizeof
sizeof 返回一条表达式或一个类型名字所占的字节数。它在编译期求值并返回一个 size_t 类型的常量表达式。
当求表达式的字节数时, sizeof 并不会实际计算表达式的值(同 decltype 相同)。因此说,对于 sizeof *p;,即便 p 是一个无效指针或空指针,也不会有什么影响,因为 sizeof 并不需要真的解引用指针也能知道它所指对象的类型。
52.1 sizeof 作用于类成员
C++11 允许通过作用域运算符来获取类成员的大小。通常情况下只有通过类的对象才能访问类的成员,但是 sizeof 运算符无需我们提供一个具体的对象,因为 sizeof 运算符无需真的获取该成员。例如:
struct Foo {int i;double d;
};int main()
{cout << sizeof(Foo::i) << endl;cout << sizeof(Foo::d) << endl;cout << sizeof(Foo) << endl;return 0;
}
注意,如果类成员的访问属性不是 public,则不可以在类外直接通过域运算符来访问。
52.2 sizeof 作用于类对象
对 string 和 vector 对象执行 sizeof 运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间:
vector<int> v1{1, 2, 3};
vector<int> v2;
string s1("hello");
string s2;
cout << sizeof(v1) << ' ' << sizeof(v2) << endl; // 24 24
cout << sizeof(s1) << ' ' << sizeof(s2) << endl; // 32 32
52.3 52.3sizeof 作用于数组
对于数组, sizeof 会返回数组中元素大小之和。
52.4 sizeof 作用于引用
对于引用,sizeof 会返回引用所指对象的大小,这符合对引用的操作就是对引用所指对象的操作的规则。
53. 类作用域、类内成员的解析规则、类型成员
类本身就是一个作用域。类的成员函数的定义嵌套在类的作用域之内。这也解释了,为什么当我们在类外定义函数时,需要在函数名前面加上 类名::,就是为了告诉编译器这个函数的作用域是属于这个类的。我们知道类内的函数都是隐式 inline 的,不过对于在类外定义的函数,该成员函数默认情况下不是 inline 的。
不过我们也可以发现一个事实,那就是返回类型出现在类作用域之前,所以返回类型实际上是在类作用域之外的。因此如果我们的返回类型需要使用类作用域内的类型成员,需要使用作用域运算符。例如:
struct Foo {
public:typedef vector<int> vi_t;vi_t get();
private:vi_t vec;
};// 返回类型位于类作用域之外,需要加上类名::
Foo::vi_t Foo::get() {// 函数体位于作用域之内,无需加上类名::return vi_t();
}int main()
{Foo f;Foo::vi_t vec = f.get();for(int i = 0; i < 4; i ++ ) vec.push_back(i);for(auto &x : vec) cout << x << endl;return 0;
}
然后就是要注意,在类中也可以使用类定义之前的变量。特殊的,对于在类的外部定义的成员函数,还需要考虑在成员函数定义之前的全局作用域中的声明。
在 C++ 中,类的成员变量和成员函数都可以在类的内部任意顺序地引用。这是因为在解析类时,会首先了解整个类的成员(包括函数和变量)。即,编译器分两步处理类:
- 首先编译成员的声明,了解到成员变量和成员函数的隐式声明
- 直到类完全可见时才轮到成员函数体。
这意味着,编译器处理完类中的全部声明后才会处理成员函数的定义。因此成员函数体可以随用使用类中的其他成员(变量和函数)而无需在意这些成员出现的次序。
换言之,我们可以理解为类的成员变量和成员函数都在类的作用域中隐式声明,不要求按顺序定义。所以,在成员函数中引用成员变量时,不需要成员变量的前置声明。
但是对于类型变量,这个规则稍有不同,类型变量的声明必须出现在其使用之前。因为编译器在处理类声明时,本质上依然是按顺序解析的。如果函数或者变量用到了某个类型变量,而该类型变量的声明没有出现,那么编译器就会报错,因为他不知道该类型变量的确切含义。也因此,类型变量的声明要放在类体的开头位置。
54. intergral pormotion
在大多数表达式中,比 int 类型小的整型字面量值首先提升为较大的整数类型。例如一个 char 类型和一个 short 类型相加,它们都会提升为 int 类型。
如果两个运算对象都带符号或者都不带符号,那么较小的类型将提升为较大的类型。
如果一个运算对象带符号,一个运算对象不带符号:
- 类型相同:例如
unsigned int和int,那么int会转换为unsigned int - 无符号数的类型更大:例如
unsigned long long和int,那么int会转换为unsigned long long - 有符号数的类型更大:此时有符号数可以保存无符号数的所有值,例如
unsigned int和long long,那么unsigned int转换为long long
55. C++风格显式(强制)类型转换
旧式风格类型转换:
type(expr); // 函数形式的强制类型转换
(type)expr; // C语言风格的强制类型转换
55.1 static_cast
用于执行有理智的、相关的常规类型转换。
55.2 const_cast
const_cast 是专门用来改变(添加或溢出)运算对象的底层 const 和 volatile 属性的。常常用于用函数重载的上下文中。
const string& shorterString(const string &s1, const string &s2)
{return s1.size() < s2.size() ? s1 : s2;
}string& shorterString(string &s1, string &s2)
{return const_cast<string&>(shorterString(static_cast<const string&>(s1),static_cast<const string&>(s2)));
}
虽然 const_cast 可以去掉对象的底层 const 属性,但修改原 const 对象是危险的未定义行为。因此使用时应该非常谨慎,确保不会违反 const 语义。
注意,不要忘了
const_cast可以去掉对象的 volatile 属性,这很容易忽略 🤯。
55.3 dynamic_cast
dynamic_cast 利用 运行时类型识别(RTTI) 来确保沿着类的继承关系向下进行类型转换的安全性。在运行时,它会检查转换的类型是否合法,并根据情况返回合适的结果(失败时返回 NULL)。
例如我们可以用它来确保某个基类向派生类的转换是安全的。如果我们自己就能确保该转换的安全性而无需借助编译器的检查,可以直接使用 static_cast 实现基类到派生类的类型转换。
dynamic_cast常用于将 基类指针/引用 转换为 派生类指针/引用。只有在基类指针指向的对象实际类型是派生类的实例时,转换才是合法的。
向上转换是指将派生类指针或引用转换为基类指针或引用。C++ 中向上转换是隐式支持的,不需要使用 dynamic_cast,但可以使用 dynamic_cast 显式完成转换。
在多重继承或虚继承的情况下,dynamic_cast 可以用于不同派生类之间的转换。例如,当两个类之间有共同的虚基类时,可以在这些类的指针或引用之间进行转换。
#include <iostream>class Base {
public:virtual void show() = 0; // 基类必须是多态的
};class Derived1 : virtual public Base {
public:void show() override { std::cout << "Derived1\n"; }
};class Derived2 : virtual public Base {
public:void show() override { std::cout << "Derived2\n"; }
};int main() {Derived1 d1;Base *basePtr = &d1;Derived2 *d2Ptr = dynamic_cast<Derived2*>(basePtr); // 跨类转换if (d2Ptr) {d2Ptr->show(); // 不会执行} else {std::cout << "转换失败\n"; // 输出 "转换失败"}return 0;
}
// 转换失败
当你使用 dynamic_cast 进行转换时,如果转换失败(即指针没有指向目标类型的对象),它会返回 nullptr(对于指针类型转换)。对于引用类型的转换,则会抛出 std::bad_cast 异常。
55.4 reinterpret_cast
reinterpret_cast 通常为运算对象的位模式提供较低层次上的重新解释。
- 可以用于在不改变底层二进制表示的情况下,将一种类型的指针或引用转换为另一种类型(指针、数值类型、函数等)。它是最强大也最危险的类型转换方式之一。
reinterpret_cast 本质上是依赖于机器的,因为不同的机器可能对二进制位有不同的解释规则。因此若想安全的使用它,必须要涉及的类型和编译器实现转换的过程都非常了解。
通过 interpret_cast 进行的转换结果几乎都是 implement-defined。因此,使用 interpret_cast 的代码很难移植。
为什么 reinterpret_cast 难以移植?
- 依赖底层实现:
reinterpret_cast用来在指针类型和其它类型(指针类型、数值类型等)之间进行转换,而不需要编译器进行任何检查。它基本上是告诉编译器:“相信我,我知道我在做什么,把这些位当作另一种类型来解释。” 这意味着它直接操作内存地址和底层数据表示,而这些表示在不同的编译器、操作系统或硬件架构上可能完全不同。 - 不进行类型检查和安全转换: 与
static_cast(用于执行有理智的、相关的类型转换)或dynamic_cast(用于在继承层次结构中进行安全的运行时转换)不同,reinterpret_cast不会执行任何类型安全检查,也不会进行任何必要的调整(例如,在多重继承中调整指针偏移量)。它只是简单地重新解释现有位的含义。 - 对齐和大小问题: 不同的数据类型可能有不同的内存对齐要求和大小。例如,一个
int*被reinterpret_cast转换为char*通常是安全的,但反过来,将一个任意的char*转换为int*并在其指向的地址上直接解引用,如果该地址没有正确对齐到int的边界,就可能导致未定义行为(undefined behavior),甚至程序崩溃。 - 未定义行为的风险: 使用
reinterpret_cast很容易导致未定义行为。一旦发生未定义行为,程序的行为就不可预测,这使得调试变得极其困难,而且在不同的系统上可能表现出不同的错误,甚至看起来完全正常,但实际上已经损坏了数据。
55.4.1 例一
char cp[] = "12341234";
int *ip = reinterpret_cast<int*>(cp);
cout << hex << ip[0] << ' ' << ip[1] << endl;
// 34333231 34333231
⚠ 注意,这里
cp[]="12341234"的每个字符替换在内存上,不是真的1,2,3,4,而是它们对应的字符编码(e.g. ASCII Code):0x31,0x32,0x33,0x34。
字符 1 的十六进制表示为 0x31,字符 2 的十六进制表示为 0x32…。因此我们可以推断出,cp 为首地址的内存存储的数据为:0x31 0x32 0x33 0x34 0x31 0x32 0x33 0x34 0x00。
如果我们要将其解释为 int 类型,需要考虑字节序。
- 在计算机中,数据一般是以小端方式存储的,这样低地址存放低位数据,方便计算机处理。
- 而大端方式在低地址存放高位数据,更符合我们现实中的情况,利于人类阅读。
在这里,我的计算机是以小端方式存储的,因此当 cp 解释为 int 时,低地址会存放低位数据,因此 1 是低地址处的数据,所以它会被放到 int 的低位;而 4 是高地址处的数据,因此它会被放到 int 的高位。所以输出是 0x34333231 而不是 0x31323334。
55.4.2 例二
使用 reinterpret_cast 是非常危险的,因为它虽然确保了语法层面的正确性,但它的某些行为可能导致错误的:
int ip[] = {0x64636261, 0x64636261, 0x64636261};
char *cp = reinterpret_cast<char*>(ip);
cout << cp << endl;
string str(cp);
cout << str << endl;
会输出:
abcdabcdabcd
abcdabcdabcd -5V
我们发现最后会输出一些乱码,这是因为从 int* 转换为 char* 之后,char* 并没有空字符 \0,也就会导致缓冲区溢出问题。
55.4.3 例三
reinterpret_cast 还常常用于文件读取中:
void write_binary_data(const string &filename, int value)
{ofstream file(filename, ios_base::binary | ios_base::out);file.write(reinterpret_cast<char*>(&value), sizeof(int));
}
- 这里会将
int *重新解释为char *- 原本是按照
int*的方式读取,每次读取 4 字节;现在会按照char*的方式读取,每次读取 1 字节 - 原本是按照
int的方式解释读取的数据;现在会按照char的方式读取数据
- 原本是按照
我们可以重新写一下这个例子:
int main()
{// a: 0x61// A: 0x41// ^: 0x5e// $: 0x24int value = 0x245e4161; // 从高位到低位分别对应 $^Aachar *caddr = reinterpret_cast<char*>(&value);for(int i = 0; i < 4; i ++ ) {// 注意 caddr[i] 是一个 char,它默认按字符输出// 因此下面的输出形式会打印出符号cout << hex << caddr[i] << ':';// 我们需要对其进行类型转换cout << hex << static_cast<unsigned int>(static_cast<unsigned char>(caddr[i])) << endl;}return 0;
}
输出为:
a:61
A:41
^:5e
$:24
这与例子一比较相似,同样也可以证明我们的电脑是小端序。
56. 执行输出任务的函数
一般来说,执行输出任务的函数应该尽量减少对格式的控制,例如自定义的输出语句不应该负责换行,这样可以确保由用户代码来决定是否换行。
57. 域运算符与typename
对于模板类型,当我们使用域(::)运算符来访问模板的类型成员时,需要在类型前面加上 typename。
因为对模板来说,编译器不像内置模板类类型(例如 string)一样可以获取类的定义,因此他不知道对于 T::mem 来说, 到底是一个静态数据成员,还是一个类型成员。
默认情况下,C++ 语言假定通过作用域运算符访问的是变量名字而不是类型。例如:
template<typename It>
auto func(It beg, It end) -> remove_reference<decltype(*beg)>::type
{auto start = beg;while(beg != end) {cout << *beg << ' ';++ beg;}return *start;
}
会报错:
main.cpp:9:30: error: need ‘typename’ before ‘std::remove_reference<decltype (* beg)>::type’ because ‘std::remove_reference<decltype (* beg)>’ is a dependent scope9 | auto func(It beg, It end) -> remove_reference<decltype(*beg)>::type| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~| typename
只要加上 typename 即可:
template<typename It>
auto func(It beg, It end) -> typename remove_reference<decltype(*beg)>::type
{auto start = beg;while(beg != end) {cout << *beg << ' ';++ beg;}cout << endl;return *start;
}
58. 空语句
一般来说,当循环的全部工作在条件部分就可以完成时,我们通常会用到空语句:
while(cin >> val && val != target); // 空语句
空语句一般来说是无害的,但如果在 if 或者 while 后面跟了一个额外的空语句就可能完全改变程序员的初中。例如:
while(iter != v.end()) ; // 空语句会导致无限循环++ iter;
因此,非必要请不要有额外的空语句,例如:int val = 1;; 这是不好的写法。
59. 语句作用域
可以在 if、switch、while 和 for 语句的控制结构内定义变量。定义在控制结构当中的变量只在相应语句的内部可见,一旦语句结束,变量也就超出其作用范围了:
double get_num()
{double x;cin >> x;return x;
}int main()
{int x = 10;if(double x = get_num()) { // X只在if内部可见cout << x << endl;}cout << x << endl;return 0;
}
60. dangling else
在 C++ 中,dangling else(悬垂else)问题是指在嵌套的条件语句中,由于缺少明确的分组或合适的花括号,编译器可能将 else 语句错误的与离它最近的 if 匹配以避免二义性,而不是开发者希望的那个 if。这个问题主要是因为 C++ 的条件语句语法允许 if-else 结构不适用花括号,从而造成了不确定性。例如:
if (x > 5)if (y > 15)std::cout << "x > 5 and y > 15" << std::endl;
elsestd::cout << "x <= 5" << std::endl;
我们觉得它是:
if (x > 5) {if (y > 15) {std::cout << "x > 5 and y > 15" << std::endl;}
}
else {std::cout << "x <= 5" << std::endl;
}
实际上是:
if (x > 5) {if (y > 15) {std::cout << "x > 5 and y > 15" << std::endl;}else {std::cout << "x <= 5" << std::endl;}
}
因此为了避免 dangling else 问题,一种编程规范是始终在条件语句中使用花括号,尽管可能只有一条语句。
61. switch
61.1 case
case 标签必须是常量、整形表达式。并且每个标签只能有一个值,因此 case 1, 3, 4: 的写法是错误的,正确的写法应该是:
case 1:
case 3:
case 4:
另外,switch 的 case 标签并不是一个独立的作用域,一个标签中的内容对于其他标签是可见的,如果忘记这一点可能导致一些很难排查的错误。
61.2 default
即使不准备在 default 标签下做任何工作,定义一个 default 标签也是有意义的。其目的在于告诉程序的读者,我们已经考虑到了默认的情况,只是目前什么都没做。
61.3 break
对于最后一个 case 标签和 default 标签有必要时都应该加上 break,这是一个好的习惯。
61.4 switch内部的变量定义
首先我们要明确声明和初始化在 runtime 的不同之处。
- 对于声明语句:只需要在编译期进行处理,让编译器能认识这个符号是什么类型的。而在程序实际运行时,这种“单一声明”没有对应的机器指令,不需要跑。
- 对于声明且初始化的语句:在
runtime时也是有指令的,需要运行这些指令才能达到相应的效果。
这一点我们可以从汇编层面更清晰的看到。
int main()
{return 0;
}
对应的汇编代码为:
main:push rbpmov rbp, rspmov eax, 0pop rbpret
int main()
{int val;return 0;
}
对应的汇编代码为:
main:push rbpmov rbp, rspmov eax, 0pop rbpret
可以发现,尽管我们额外声明了一个 int val; ,汇编代码也没有任何变化,说明声明语句并没有带来额外的机器指令。
那么如果我们将其定义了呢?
int main()
{int val = 32;return 0;
}
对应的汇编代码为:
main:push rbpmov rbp, rspmov DWORD PTR [rbp-4], 32 # 初始化机器指令mov eax, 0pop rbpret
可以发现我们需要额外执行一条用于初始化 val 的机器指令。
因此,如果不能运行这条机器指令,int val=32; 的效果就没有达到,那么干脆在编译时,直接禁止你使用。C++ 规定,不允许跨过变量的初始化语句直接跳转到该变量作用域的另一个位置。以此确保程序的安全性和一致性,因为如果我们使用了未经初始化的变量,其行为是未定义的。例如 goto,switch,函数返回等方式都可能直接跳到变量的作用域中而不先执行初始化。
那么对于 switch 来说,我们前面提到过,不同 case 的作用域是一致的,因此说如果我们在 case 1 中定义了一个变量,那么在后面的 case 2 中该变量是可见的。而 case 1 未必会得到执行,这就可能导致该变量的初始化语句未得到执行,编译器就会报错。
为了解决该问题,我们有两种方案:
(1)使用声明而不是初始化
(2)通过花括号将变量定义在块内,从而对其他 case 不可见:
int main()
{int op = 1;switch(op){case 1:{int x = 10;cout << x << endl;}break;case 2:{int x = 20;cout << x << endl;}default:{double x = 3.14;cout << x << endl;}break;}return 0;
}
62. do-while循环内的变量定义
在 do-while 中,由于先执行 do 在执行 while,因此在 while 部分定义变量显然是不合法的。
63. 释放空指针
注意释放空指针和释放一块已经被释放了的内存的区别:
- 一块内存已经被释放,但原来指向该内存地址的指针仍然指向该内存,此时再次 delete 这些指针,会导致 double delete 错误。
- 对于空指针,C++ 在 delete 时会对指针进行检查,当发现指针为 NULL 时什么也不做。同样的,在 C 中,我们也可以 free 空指针
int *p = new int(10);
int *q = p;
delete p;
// q不是空指针,因此这里 double delete
delete q;/////////////////////////////////////////////////////////int *p = new int(10);
int *q = p;
delete p;
// q是空指针,可以delete空指针
q = nullptr;
delete q;
delete 和 free 设计成对空指针安全有很多好处:
- 不用程序员手动检查指针是否为空
- 与构造函数对称:构造函数可以返回一个 NULL 指针
- 提供程序的健壮性:当我们将指针置为 NULL 之后,delete 它是没有效果的
- 标准库的要求:C++ 标准要求 delete 一个空指针是没有任何效果的
64. 隐式初始化陷阱
当我们在程序的任何地方写下 string s; 时,不要误认为我们仅仅只是声明了一个 string 类型的变量 s,它实际上通过默认构造函数完成了“隐式初始化”。也即完成了定义。
65. range-for原理
范围 for 语句的定义来源于与之等价的传统 for 循环:
vector<int> v;for(auto &x : v) {...
}for(auto begin = v.begin(); begin != v.end(); ++ begin) {...
}
通过等价的 for 形式,我们也能理解,为什么不能在范围 for 内部增删容器对象的元素了,因为这样会使 begin 和 end 迭代器失效。
另外,如果是 const vector<int> v,迭代器的形式是 const_iterator。
66. goto和label
在 C++ 中,label(标签)是一种用于标记代码位置的语法元素,通常与 goto 语句配合使用。label 标志了代码中的一个位置,程序可以通过 goto 跳转到这个位置执行后续的代码。
C++ 中的标签作用域是 函数级别的,这意味着你可以在同一个函数内部跳转到任意位置,但标签的跳转不能跨越函数边界。也就是说,goto 不能跳出或跳进其他函数。
goto只能在在当前函数内的 label 中跳转。不可以在不同函数中跳转。
label 独立于程序中的变量和其它标识符的名字。可以理解为 label 是一个独立的命名空间。
67. exception handler
67.1 what()
异常类型只定义了一个成员函数,用于提供关于异常的一些文本信息:
virtual const char* what() const noecept;
例如:
#include <iostream>
#include <exception>
#include <string>class MyException : public std::exception {
private:std::string message;public:MyException(const std::string& msg) : message(msg) {}// what函数不应该抛出异常virtual const char* what() const noexcept override {return message.c_str(); // 返回异常信息}
};int main() {try {throw MyException("This is a custom exception");} catch (const std::exception& e) {std::cout << "Caught exception: " << e.what() << std::endl;}return 0;
}
67.2 异常安全的代码
异常中断了程序的正常执行。这意味着当异常发生时,调用者请求的一部分计算已经完成了,另一部分则尚未完成。通常情况下,略过部分代码意味着某些对象处理到一半就戛然而止,从而导致对象处于无效或未完成的状态,或者资源没有正常释放等等。那些在异常发生期间正确执行了“清理”工作的程序称为“异常安全”的代码。也即即使程序出现了异常,也能安全运行的程序代码。
编写异常安全的代码是非常困难的。我们必须时刻清楚异常何时发生,异常发生后程序如何确保对象有效、资源无泄漏、程序处于合理状态,等等。
67.3 标准异常
C++ 定义了一组类用于报告标准库函数遇到的问题。它们分别定义在 4 个头文件中:
<exception>:定义了最通用的异常类exception,它是所有异常类的基类。只报告异常的发生,获取基础的错误信息,不提供任何额外信息。<new>:定义了bad_alloc异常类型。type_info:定义了bad_cast异常类型。stdexcept:定义了几种常用的异常类,如下表所示:
| 异常 | 说明 |
|---|---|
| exception | 最常见的问题 |
| runtime_error | 只有在运行时才能检测出的错误 |
| range_error | 运行时错误:生成的结果超出了有意义的值域范围 |
| overflow_error | 运行时错误:计算上溢 |
| underflow_error | 运行时错误:计算下溢 |
| logic_error | 程序逻辑错误 |
| domin_error | 逻辑错误:参数对应的结果值不存在 |
| invalid_argument | 逻辑错误:无效参数 |
| length_error | 逻辑错误:试图创建一个超出该类型最大长度的对象 |
| out_of_range | 逻辑错误:使用一个超出有效范围的值 |
我们只能以默认初始化的方式初始化 exception、bac_alloc 和 bad_cast 对象,不允许为这些对象提供初始值。其他类型异常则应该使用 string 或 C 风格字符串初始化以提供含有错误相关的信息。
67.4 计算错误
| 异常类型 | 描述 | 应用场景 |
|---|---|---|
range_error |
表示生成了超出逻辑范围的结果,但与硬件无关。 | 结果超出了业务逻辑的有效范围,但仍在数据类型允许的范围内。 |
overflow_error |
表示上溢,数值超出了数据类型能表示的最大值。 | 数值计算结果超出了类型所能表示的范围,如整数或浮点数上溢。 |
underflow_error |
表示下溢,数值接近零,但小于数据类型能表示的最小值。 | 数值接近零但小于类型的表示范围,通常用于浮点数的下溢情况。 |
67.5 逻辑错误
domain_error (数学上是不合逻辑的) 表示传给函数的参数不在其定义域内(参数在数学或逻辑上属于未定义域)。即,参数的取值在数学或逻辑上是不合法的。应用场景: 当函数的输入参数无法产生有效的输出结果时会抛出该异常。例如,计算一个负数的平方根时,负数不属于实数平方根函数的定义域。
invalid_argument (非数学性问题:参数不匹配)表示传递给函数的参数无效。与 domain_error 不同,invalid_argument 通常用于表示非数学性问题,应用场景: 字符串无法解析为数字;传入负数给要求正数的函数。
length_error 表示在操作一个容器或字符串时,尝试创建或操作的对象长度超出了该类型所能表示的最大长度。应用场景: 当操作的容器或字符串长度超出其类型的允许范围时会抛出该异常。例如,尝试创建一个非常大的字符串或者向容器添加超出其最大容量的元素时。
out_of_range 表示访问了无效的索引或超出了有效范围的值。它通常在尝试访问超出容器边界或给定不合法的索引时抛出。应用场景: 当访问的索引超出容器(如数组、向量、列表等)的边界时会抛出该异常。比如,访问一个空向量中的元素或者超出数组边界的索引。
总结:
domain_error是用于数学定义域外的错误,通常发生在数学函数处理非法参数时。参数在数学或逻辑上属于未定义域(即使参数类型正确,但运算结果无意义)invalid_argument用于参数不符合函数要求的情况,参数值本身无效(不符合函数的基本要求)。length_error是指在创建或操作容器时,长度超过了允许的最大值。out_of_range主要用于索引访问,表示试图使用超出容器有效范围的索引。
67.6 异常类层次结构
std::exception
├── std::logic_error
│ ├── std::invalid_argument
│ ├── std::domain_error
│ ├── std::length_error
│ └── std::out_of_range
├── std::runtime_error
│ ├── std::overflow_error
│ ├── std::underflow_error
│ ├── std::range_error
│ ├── std::ios_base::failure
│ └── std::bad_alloc
├── std::bad_exception
├── std::bad_cast
├── std::bad_typeid
└── std::ios_base::failure
68. 鲁棒性
“鲁棒”一词通常用于描述系统、程序或算法在面对变化、错误或不确定性时的稳定性和可靠性。它源于英语中的“robust”,广泛应用于计算机科学、工程、统计学等多个领域。
68.1 (1) 鲁棒性定义
- 对抗性:鲁棒系统能够抵御输入数据的噪声、错误或异常,而不导致系统崩溃或输出不正确的结果。
- 适应性:鲁棒系统能够在各种环境和条件下保持其功能和性能,即使在面对不完全信息或不确定性时。
- 故障容忍:鲁棒系统能够在发生部分故障时继续运行,提供有限的功能,而不是完全失败。
68.2 (2) 鲁棒性的重要性
- 用户体验:鲁棒性增强了软件的稳定性,使用户在使用时不会频繁遇到错误或崩溃。
- 安全性:在安全关键的应用(如医疗、航空航天等)中,鲁棒性可以降低系统故障的风险,保护人身安全。
- 维护成本:鲁棒系统通常需要更少的维护和支持,降低了长期运行的成本。
68.3 (3) 实现鲁棒性的方法
- 输入验证:确保输入数据的有效性和完整性,防止错误数据导致系统崩溃。
- 异常处理:使用异常处理机制来捕获并处理错误,确保程序能够优雅地处理问题,而不是直接崩溃。
- 冗余设计:通过引入冗余(如备份系统或冗余组件)来增强系统的容错能力。
- 测试和验证:进行充分的测试,包括边界条件、异常情况和极端输入,以确保系统在各种情况下都能正常运行。
68.4 (4) 鲁棒性在不同领域的应用
- 软件开发:鲁棒性体现在软件能够处理无效输入或异常情况而不崩溃。
- 机器学习:鲁棒性意味着模型在面对未见数据、噪声或对抗样本时依然表现良好。
- 网络安全:鲁棒系统能够抵御各种攻击(如拒绝服务攻击),保持服务的可用性和完整性。
68.5 (5) 鲁棒性与强健性
鲁棒性与“强健性”这两个概念常常混用,但有所不同:
- 鲁棒性:强调系统在错误、噪声或不确定性下的表现。
- 强健性:更倾向于系统对变化和干扰的适应能力。
69. 指针的比较
由于指针保存的是地址,因此在 C++ 中,比较指针,即两个地址是无意义的,产生的行为是未定义的。
如果我们确实想比较指针,可以通过标注库定义的函数对象例如 less<int*>(),这些类模板对指针的比较是定义良好的。但其仍然是比较指针的地址。
70. 成员指针
在 C++ 中,.* 和 ->* 是用于通过成员指针(Pointer to Member)访问类成员的特殊运算符。它们的区别在于:
.*:用于对象(直接访问)。->*:用于指针(间接访问)。
不要误认为
.*中的.和*是分开解释的,->*同理。
在 C++ 中,成员指针(Pointer to Member) 是一种特殊类型的指针,它指向类的成员(成员变量或成员函数),而不是指向对象本身。成员指针需要结合类的实例才能访问实际成员。
传统意义上的指针直接指向对象的地址(绝对地址),但是成员指针只是确定了类成员对象距离类对象地址的偏移量(相对地址),我们还需要通过类对象来访问成员指针,从而确定类对象的地址,最终得到类成员对象的绝对地址(类对象地址+成员指针的偏移量)。
struct Foo {int val;void print(int x) {cout << "Class Foo::print(" << x << ")";}
};int main()
{Foo f{1024};int Foo::*ptr = &Foo::val; // 指向变量void (Foo::*fptr)(int) = &Foo::print; // 指向函数cout << f.*ptr << endl;(f.*fptr)(123);return 0;
}
71. <bit>(C++20)
<bit> 是 C++20 新引入的一个头文件,专门用来提供高性能的二进制位操作函数。
71.1 一、<bit> 的作用
在 C++20 之前,C++ 标准库对位操作的支持非常有限,很多操作(比如计算最高位、字节翻转、位宽计算)都需要手写循环或使用编译器内建函数。
从 C++20 开始,标准库引入了 <bit> 头文件,提供了一系列跨平台且 constexpr 的位操作函数,用于:
- 位计数(如有多少个 1)
- 位序变换(字节反转)
- 位宽计算(
bit_width) - 对齐到 2 的幂(
bit_ceil,bit_floor) - 查找最高 / 最低有效位(
countl_zero,countr_zero)
71.2 二、主要函数列表
| 函数 | 功能 | 示例 |
|---|---|---|
std::bit_width(x) |
返回表示 x 所需的二进制位数 |
bit_width(5)=3 |
std::bit_floor(x) |
返回不大于 x 的最大 2 的幂 |
bit_floor(10)=8,bit_floor(4)=4 |
std::bit_ceil(x) |
返回不小于 x 的最小 2 的幂 |
bit_ceil(10)=16,bit_ceil(4)=4 |
std::popcount(x) |
返回 x 中 1 的个数 |
popcount(11)=3 |
std::rotl(x, n) |
向左循环移位 | rotl(0b1001, 1)=0b0011(4位) |
std::rotr(x, n) |
向右循环移位 | rotr(0b1001, 1)=0b1100(4位) |
std::countl_zero(x) |
统计前导 0 的个数 | countl_zero(0b0010)=30(32位) |
std::countr_zero(x) |
统计尾部 0 的个数 | countr_zero(0b1000)=3 |
std::countl_one(x) |
统计前导 1 的个数 | countl_one(0b111000)=3 |
std::countr_one(x) |
统计尾部 1 的个数 | countr_one(0b101111)=4 |
std::byteswap(x) |
翻转整数的字节顺序 | byteswap(0x12345678)=0x78563412 |
需要注意的是,以上这些函数优先使用 无符号整数,避免运算中有符号整数符号拓展带来的问题。
#include <iostream>
#include <bit>int main() {unsigned x = 37; // 0b100101std::cout << "bit_width: " << std::bit_width(x) << '\n'; // 6std::cout << "bit_floor: " << std::bit_floor(x) << '\n'; // 32std::cout << "bit_ceil: " << std::bit_ceil(x) << '\n'; // 64std::cout << "popcount: " << std::popcount(x) << '\n'; // 3std::cout << "rotl: " << std::rotl(x, 3) << '\n'; // 0b100101000, 37*8std::cout << "countr_zero: " << std::countr_zero(x) << '\n'; // 0
}
72. pass by pointer 陷阱
pass by pointer 肯定是不如 pass by reference 好的,这是因为当我们传入指针时,指针本身会被 copy 一份,因此当在函数中修改指针本身时,对传入的实参并无影响。在某些时候我们会忽略这一点,误认为在函数当中对指针的修改会应用到实参上。
例子暂时想不出来好的…
73. 函数
函数的参数分为形参和实参,实参的主要作用是初始化形参。实参出现在函数调用的地方,形参出现在函数定义的地方。形参名是可选的,如果形参没有名字,那么我们就无法使用它。
函数的返回类型不能是数组或函数类型,但可以是指向数组或函数的指针。
函数的声明也称为函数原型,它包含了调用该函数所需要的全部信息。
我们知道函数参数的顶层 const 属性是不作为函数签名的,这是因为在传参数的时候,顶层 const 被忽略掉了。这体现为,无论我们传入的实参是 const 对象还是非 const 对象,函数的行为都是一致的。
void func(int x) {cout << "int &" << endl;
}void func(const int x) {cout << "const int &" << endl;
}// a.cpp:12:6: error: redefinition of 'void func(int)'
// 12 | void func(const int x) {
但要注意对于引用类型来说,其 const 只有底层 const 属性,因此此时 const 是作为函数签名的一部分的。
#include <iostream>
#include <cstring>
#include <algorithm>using namespace std;void func(int &x) {cout << "int &" << endl;
}void func(const int &x) {cout << "const int &" << endl;
}int main()
{const int x = 1;int y = 2;func(1); // const int &func(x); // const int &func(y); // int &return 0;
}
74. 数组作为函数的参数
74.1 形参格式
当我们想把数组作为函数的形参时,有三种可供选择的方案:
- 声明为指针
void func(int *p); - 声明为不限维度的数组
vodi func(int p[]) - 声明为维度确定的数组
void func(int p[size])
由于数组传入函数时实参自动转换成指向数组首元素的指针,因此这三种方式是等价的。编译器只检查传入的参数是否是 int * 类型。 特别的,对于第三种方式,传入的维度 size 只是一个期望,实际数组大小不一定为 size 也是可以的。
74.2 传入数组大小
由于数组不能拷贝,传数组实际上是传指针,所以我们并不知道数组的确切大小。下面有三种方法为函数提供额外的信息:
- 数组本身包含一个结束符号:类似于 C 风格字符串在最后有一个
\0作为结束符。 - 使用标准库规范,通过两个迭代器来表示数组大小:
void func(int *begin, int *end);,end应该为数组最后一个元素的下一个元素。 - 额外传入一个
size_t类型的变量表示数组大小。
74.3 传引用
前面我们提到过,形参可以指定数组的维度,虽然它并没有什么实际的作用。但如果此时形参是一个引用的话,数组实参的维度必须与形参指定的维度严格匹配,这是因为当形参为引用时,数组不会自动转换为指针。
void func(int (&p)[10]) {}int main()
{int a[] = {1, 2, 3};func(a); // 参数类型不匹配return 0;
}
74.4 传递多维数组
我们曾经介绍过,在 C++ 语言中实际上并没有真正的多维数组,所谓多维数组其实就是数组的数组。所以说,数组的第二维(以及后面所有维度)的大小都是数组类型的一部分。所以说,除了第一维,我们必须明确的指定其他维度的大小。
75. 含有可变参数的函数
75.1 initializer_list<T>
如果所有参数的类型相同,可以使用 initializer_list<T>。从形式和使用上看,它似乎也是一个容器,但是它的元素永远是常量,我们无法修改 initializer_list 对象中的元素。不过这也有一个好处,那就是当我们试图拷贝一个 initializer_list 对象的时候,并不会有实际的拷贝动作,而是直接与原始列表共享元素。
initializer_list<int> a{1, 2, 3, 4};
initializer_list<int> b(a);
for(auto &x : a) cout << &x << ' '; cout << endl;
for(auto &x : b) cout << &x << ' '; cout << endl;
输出为:
0x400ad0 0x400ad4 0x400ad8 0x400adc
0x400ad0 0x400ad4 0x400ad8 0x400adc
75.2 省略符 ...
这是一种 C 风格的可变参数形式,一般不推荐在现代 C++ 中使用。因为它不支持类型安全,并且使用复杂。
75.3 可变模板参数
// 递归出口
void print() {}template<typename T, typename... Args>
void print(T arg, Args... args)
{cout << arg << ' ';print(args...);
}int main()
{print('a', 1, 3.14, "string");return 0;
}
在使用可变模板参数时,模板参数包中的每个参数类型在编译时就已经被确定。编译器会为每种类型的组合生成特定的代码,并为每个参数分配合适的存储空间。每个模板参数的值通常存储在栈上(或者可能根据情况存储在寄存器中)。编译器通过参数包的展开处理每个参数。在内存布局上,不同类型的参数将按其顺序依次存储。不同类型的大小由各自的类型决定,编译器根据它们的内存对齐要求来安排它们的存储位置。
75.4 std::any
std::any(C++17 引入)允许你存储任意类型的对象,并且在需要时可以检查和提取类型。因此 std::any 结合容器非常适合用于需要存储多种不同类型的值,但这些类型在编译时并不确定的场景。
主要功能:
- 存储任意类型的值:
std::any可以存储任何拷贝构造可用的对象。 - 类型安全的访问:可以使用
std::any_cast来提取存储的值,并且提供了类型检查。 - 检查类型:可以使用
any.type()来获取存储值的类型。
主要方法:
any_cast<T>(any):提取std::any中存储的值,并转换为T类型。如果类型不匹配,会抛出std::bad_any_cast异常。has_value():判断std::any是否包含值。type():返回std::type_info,可以用来比较存储的类型。
注意事项:
- 性能:由于
std::any需要在运行时进行类型管理和动态内存分配,它的性能通常比直接使用模板或具体类型要慢,因此仅在有必要时才使用。 - 类型安全性:虽然
std::any提供了类型检查功能,但如果在使用std::any_cast时尝试将值转换为错误的类型,会抛出std::bad_any_cast异常。使用type()方法可以先检查类型,确保安全访问。 - 存储的值必须是可拷贝的:
std::any只能存储那些可以拷贝构造的类型,因此不可拷贝的对象不能存储在std::any中。
例如:
void process(vector<any> &args)
{for(auto &arg : args){if(arg.type() == typeid(char)) {cout << "char: " << any_cast<char>(arg) << endl;}else if(arg.type() == typeid(short)) {cout << "int: " << any_cast<int>(arg) << endl;}else if(arg.type() == typeid(double)) {cout << "double: " << any_cast<double>(arg) << endl;}else if(arg.type() == typeid(string)) {cout << "string: " << any_cast<string>(arg) << endl;}else {cout << "Unknown type" << endl;}}
}int main()
{vector<any> args = {'a', 3.14, 123, "hello"};process(args);return 0;
}
std::any 的具体实现(如何存储不同类型的元素,动态内存分配?)依赖于标准库的实现细节。大多数情况下,这种实现并没有直接提供公开的 API 来查询是否进行了动态分配,但你可以通过分析性能和内存占用来推断。
总结来说:
- 小对象:通常不会触发动态内存分配,
std::any使用栈上存储。 - 大对象:会触发动态内存分配,将数据存储在堆上。
这意味着在使用 std::any 时,如果存储的是较大的数据结构,可能会引入动态内存分配的开销。
76. 返回数组指针
因为数组不能被拷贝,所以函数不能直接返回数组。不过,函数可以返回数组的指针或引用。但是,从语法上来说,将函数的返回类型设置为数组的指针或引用比较麻烦,但是我们可以通过类型别名等方法来简化这一操作。
注意这里我们返回的都是大小固定的数组。如果我们想返回大小不定的数组,使用指针或 vector 是更好的选择。
76.1 尾置返回类型(Trailing Return Type)
C++11 引入了 尾置返回类型,可以使复杂的返回类型更加清晰。例如:
#include <iostream>int arr[5] = {1, 2, 3, 4, 5};// 使用尾置返回类型,让编译器先解析参数,再解析返回值
auto getArray() -> int(&)[5] {return arr;
}int main() {int (&ref)[5] = getArray();for (int i : ref) {std::cout << i << " ";}return 0;
}
解析:
auto getArray() -> int(&)[5]让编译器先解析auto,然后再解析-> int(&)[5]作为返回类型。- 返回的是一个
int类型的数组引用(int(&)[5]),避免了数组退化成指针的问题。
76.2 decltype 关键字
decltype 可以根据已有变量推导出正确的返回类型,避免手写复杂的类型声明:
#include <iostream>int arr[5] = {1, 2, 3, 4, 5};// 使用 decltype 自动推导数组类型
decltype(arr)& getArray() {return arr;
}int main() {int (&ref)[5] = getArray();for (int i : ref) {std::cout << i << " ";}return 0;
}
解析:
decltype(arr)&自动推导出arr的类型int[5],并通过&返回其引用。- 这样可以确保返回的仍然是数组,而不是退化后的指针。
76.3 typedef(或 using)
可以用 typedef 或 using 来给数组类型起别名,使代码更简洁:
cpp复制编辑#include <iostream>int arr[5] = {1, 2, 3, 4, 5};// 使用 typedef 定义数组类型
typedef int ArrayType[5];ArrayType& getArray() {return arr;
}int main() {int (&ref)[5] = getArray();for (int i : ref) {std::cout << i << " ";}return 0;
}
76.4 现代 C++ 推荐 using
using ArrayType = int[5];ArrayType& getArray() {return arr;
}
解析:
typedef int ArrayType[5];定义了ArrayType作为int[5]的别名。getArray()返回ArrayType&,即int(&)[5],使函数声明更加清晰。
77. 函数签名,名称重整,extern “C”
函数签名(function signature)用来唯一标识一个函数的特征。通常包括函数名称和参数信息(类型和数量)。在支持函数签名的语言中,编译器使用函数签名来区分同名但参数不同的函数。
在有些语言中,返回类型也会影响函数签名,但在 C++ 中,返回类型不属于函数签名的一部分。
在编译器生成的目标文件中,函数签名不会以原始形式存在。名称重整(name mangling)是编译器在编译过程中,将函数名及其签名转换成一个唯一的标识符的过程。这个标识符在生成的目标文件中使用,用于区分重载函数和其他全局符号。C++ 编译器会通过名称重整将函数签名编码到函数名称中,以便链接器能够正确区分不同的重载函数。
C 语言不支持函数重载,因此其符号名称无需重整。为了在 C++ 中调用 C 的函数,通常需要使用 extern "C" 来禁止名称重整,使得编译器生成与 C 语言兼容的符号。
C 语言不支持函数重载的主要原因与其设计哲学和历史背景有关。以下是具体原因:
1. 设计哲学:简单性和直接性
C 语言的设计目标是提供一种简单、高效且接近硬件的编程语言。为了实现这一目标,C 语言避免引入复杂的特性,以保持语言的简洁性和可预测性。
- 函数重载需要编译器在编译时根据函数名和参数列表来解析具体的函数调用,这会增加编译器的复杂性。
- C 语言更倾向于让程序员显式地控制代码的行为,而不是依赖编译器进行复杂的解析。
2. 名称修饰(Name Mangling)的缺失
函数重载的实现依赖于名称修饰(Name Mangling),即编译器在生成符号时,将函数名和参数类型信息编码到一个唯一的符号中。例如,C++ 编译器会将
void foo(int)和void foo(double)分别修饰为_Z3fooi和_Z3food。
- C 语言没有名称修饰机制,函数名在编译后的符号表中是唯一的。
- 如果 C 语言支持函数重载,就需要引入名称修饰机制,这会破坏 C 语言的简单性和与汇编语言的直接映射关系。
3. 历史背景
C 语言诞生于 20 世纪 70 年代,当时的硬件资源有限,编译器技术也相对简单。
- 函数重载需要更复杂的编译器支持,而早期的 C 编译器无法承担这种额外的开销。
- C 语言的设计目标是成为一种“可移植的汇编语言”,因此它尽量避免引入高级特性。
对于下面 C++ 代码:
void print(int &x, double b) // _Z5printRid
{puts("int, double");
}int print(const int x, const int y) // _Z5printii
{return x + y;
}void print(int const *p) // _Z5printPKi
{puts("int const *p");
}extern "C" {void print(int x) // print{puts("int");}
}
我们可以使用 nm 命令来获取名称重整后的符号:
jyyyyx@SegmentTree:~/cpp$ g++ main.cpp -o app
jyyyyx@SegmentTree:~/cpp$ nm app | grep print
0000000000001188 T _Z5printPKi
0000000000001149 T _Z5printRid
0000000000001170 T _Z5printii
00000000000011aa T print
其中:
_z是特定的前缀,表示这是一个重整过的符号5print是函数名,5表示函数名的字符长度i,d,P和R分别表示int,double,pointer和referencek表示这是一个const修饰符
另外我们还可以发现,extern "C" 中的函数没有名称重整。另外就是顶层 const 属性和返回类型对重整后的名称没有任何影响。
78. 重载与作用域
重载对作用域的一般性质并没有什么改变:如果我们在内层作用域声明名字,它将隐藏外层作用域中声明的所有同名实体。因此我们无法在不同的作用域中重载函数。
尽管在局部作用域声明函数是不明智的选择,但这里仅作为示例:
void print(const string &s) {cout << s << endl;
}void print(double d) {cout << d << endl;
}string isReading();void func()
{bool isReading = false; // 隐藏了外部reading函数string s = isReading(); // 错误void print(int x); // 屏蔽外层的两个print函数print("hello"); // 错误print(3.14); // 正确,调用 print(int)print(16); // 正确,调用 print(int)
}
在 C++ 中,名字查找发生在类型检查之前。这很显然,先找到了名字,才能知道它能接受什么样的参数类型。
79. 模式实参
默认实参只能出现在函数声明或定义之一。不能在两者中都定义默认实参,否则编译器会报错。一般是在头文件的声明中定义默认实参。
79.1 关键词传参
首先,C++ 不能像 Python 那样按照关键词传递参数,因此下面的函数调用是错误的:
void print(const string &s, int a) {cout << s << endl;cout << a << endl;
}int main()
{print(a = 1.1, s = "hello"); // 编译错误return 0;
}
79.2 动态添加默认值
C++ 可以通过函数声明动态修改默认实参的个数,不过只能添加,而不能减少或者修改已有的默认实参。由于定义是一次性的,而声明可以多次,所以通过多次声明即可增加默认实参的个数。
例如:
void print(int a, int b, int c = 3);void print(int a, int b = 2, int c);void print(int a = 1, int b, int c);void print(int a, int b, int c)
{cout << a << ' ' << b << ' ' << c << endl;
}int main()
{print();return 0;
}
注意在后续的声明当中,已经有默认实参的形参不能再给予默认值,即使这个默认值和原来相同。另外默认实参的添加应该是从后往前,确保有默认实参的形参是最后几个参数。
79.3 动态修改默认值
前面我们提到,我们可以通过多次声明来增加默认值的个数,但是不能修改已有的默认值。其实我们有一种办法可以修改默认值,那就是将默认值绑定到一个全局对象上,通过修改全局对象实现默认值的修改。注意不能将默认值绑定到局部对象上。
int val = 10;void print(int x = val)
{cout << x << endl;
}int main()
{print(); // 10val = 1024;print(); // 1024return 0;
}
80. this
this 形参是隐式定义的。事实上,任何自定义的名为 this 的参数或变量的行为都是非法的。
因为 this 总是指向调用函数的那个对象,所以 this 是一个常量指针,我们不允许修改 this 中保存的地址。
也因为 this 是一个指针,因此当我们传入的对象有 const 属性时,this 也应该相应的变为底层 const 属性,不允许修改所指向对象的值。具体的,当成员函数有 const 属性时,隐式定义的 this 指针是一个指向常量的指针。此时我们不可以通过 this 指针修改任何成员变量。而带有 const 修饰的函数称为常量成员函数。
81. unique_ptr传入删除器为什么要指定类型
在回答这个问题之前,先看一段代码:
template<typename T>
void deletor(T *p)
{delete p;
}template<typename T>
void test()
{unique_ptr<T> up(new T(10));cout << sizeof(up) << endl; // 8unique_ptr<T, decltype(&deletor<T>)> up2(new T(10), deletor<T>);cout << sizeof(up2) << endl; // 16shared_ptr<T> sp(new T(10));cout << sizeof(sp) << endl; // 16shared_ptr<T> sp2(new T(10), deletor<T>);cout << sizeof(sp2) << endl; // 16
}int main()
{test<double>();test<int>();test<char>();return 0;
}
首先我们可以发现,对于 unique_ptr 来讲,删除器是作为一个模板参数存在的,我们需要在模板中指定其类型;而对于 shared_ptr 来说,我们无需在模板中指定删除器的类型。
其次,无论我们指定底层数据是 char、int 还是 double 类型,上面四个 sizeof 的结果都是一样的。
在不指定删除器的情况下,unique_ptr 的大小为 8,而 shared_ptr 的大小为 16(;指定了删除器之后,unique_ptr 的大小为 16,shared_ptr 的大小为 16。可以发现,在不指定删除器时,unique_ptr 的大小和原始的大小一样大。之所以会有这种差异,主要是因为 C++ 针对 unique_ptr 和 shared_ptr 的设计哲学(理念)不同:unique_ptr 是轻量级的智能指针,而 shared_ptr 是为共享而设计的一个较为复杂的智能指针。
shared_ptr 的大小比 unique_ptr 大 8,主要是因为
std::shared_ptr使用引用计数来管理对象的生命周期。引用计数和其他管理信息(如自定义删除器、分配器等)存储在一个单独的控制块中。std::shared_ptr需要存储一个指针,指向这个控制块。
81.1 参考
- https://www.cnblogs.com/fuzhe1989/p/7763623.html
- Why does unique_ptr take two template parameters when shared_ptr only takes one?
- Why does unique_ptr have the deleter as a type parameter while shared_ptr doesn't?
81.2 总结
总而言之,unique_ptr 出于时间和空间的考虑,默认在它的内部是没有成员变量来保存构造器对象的。unique_ptr 的默认构造器对象是一个空基类,它的大小被优化为 0。如果我们想自己指定构造器对象,就需要修改 unique_ptr 继承的这个基类,看代码:
template <class _Tp, class _Dp = default_delete<_Tp> >
class unique_ptr
{
public:typedef _Tp element_type;typedef _Dp deleter_type;typedef typename __pointer_type<_Tp, deleter_type>::type pointer;
private:__compressed_pair<pointer, deleter_type> __ptr_;...
};template <class _T1, class _T2>
class __compressed_pair: private __libcpp_compressed_pair_imp<_T1, _T2> {...
};// 特化版本
// _T1: pointer
// _T2: deleter_type
template <class _T1, class _T2>
class __libcpp_compressed_pair_imp<_T1, _T2, 0>
{
// 该版本没有做任何优化直接将 _T1 和 _T2 作为私有成员变量 __first_ 和 __second_
// 这种情况适用于 _T1 和 _T2 都是非空对象时,它们正常存储在类的成员变量中
private:_T1 __first_;_T2 __second_;...
};template <class _T1, class _T2>
class __libcpp_compressed_pair_imp<_T1, _T2, 1>: private _T1
{
// 这个版本通过私有继承 _T1 来优化存储
// 这里利用了空基类优化(EBO):如果 _T1 是一个空类,那么它在类的对象实例中将不会占用额外的空间
// 因为 C++ 允许空基类不占空间,因此,这个版本可以减少存储的空间开销
// 这种特化会在 _T1 是一个空类型(如 std::allocator 这样的空类)时被使用
// 使得 _T1 不占用空间,从而节省内存
private:_T2 __second_;...
};template <class _T1, class _T2>
class __libcpp_compressed_pair_imp<_T1, _T2, 2>: private _T2
{
// 这个版本与前一个类似,但反过来继承了 _T2
// 如果 _T2 是一个空类,它将不会占用额外的内存空间,而 _T1 则作为成员变量存储
// 这种特化通常用于 _T2 是空类的情况
private:_T1 __first_;...
};template <class _T1, class _T2>
class __libcpp_compressed_pair_imp<_T1, _T2, 3>: private _T1,private _T2
// 这个版本通过同时私有继承 _T1 和 _T2,如果这两个类型都是空类,那么它们都不会占用任何空间
// 即使只有其中一个是空类,继承的机制仍能减少空间开销。
// 这是最优化的版本,适用于 _T1 和 _T2 均为空类的情况,通过空基类优化,可以让整个类在内存中占用零空间。
{...
};
unique_ptr 只有一个成员函数 __ptr__,它的类型 __compressed_pair 继承自 __libcpp_compressed_pair_imp<_T1, _T2>,而最终 __ptr__ 的大小就是由这个类的特化版本决定的。
当我们没有显式指定删除器的类型 _T2 时,它是默认的空类,此时我们会 private 继承它,它的大小就被优化为 0 了。但如果我们显式指定它,那么此时继承的 _T1 就不是空基类了。它内部需要为删除器分配空间,所以我们在引言中发现在指定删除器之后,unique_ptr 的大小变了。
总而言之:unique_ptr 指定删除的直接原因是,我们需要通过传入的删除器类型去替换空基类类型,由此为删除器对象分配内存空间。而之所以 unique_ptr 默认没有为删除器分配空间,是因为它的设计哲学(理念)—— 在性能上和内置类型的指针相当!对于 shared_ptr,由于我们本身在其内部为删除器对象分配了空间,不需要传入删除器类型来告诉它需要为删除其对象分配空间,所以也就不需要在它的类模板中传入删除器的类型了,不过由于我们没有显式指定删除器类型,所以我们需要在构造函数中进行推导。
template <class T>
class shared_ptr {
public:...template <class U, class D>// 编译器自动推导删除器的类型Dshared_ptr (U* p, D del);...
};// 自行传入删除器的类型,默认为default_delete<T>是一个空基类
template <class T, class D = default_delete<T>>
class unique_ptr {
public:...unique_ptr (pointer p,typename conditional<is_reference<D>::value,D,const D&> del) noexcept;...
};
82. 预处理宏
assert 是一种预处理宏。所谓预处理宏其实是一个预处理变量,他的行为有点类似于内联函数。预处理名字由预处理器而非编译器管理,因此我们可以直接使用 assert 而不是 std::assert。
- 预处理器(Preprocessor) 处理
#include、#define、#if等指令,在编译之前执行。- 编译器(Compiler) 负责解析、编译 C++ 代码。
assert是#define预处理宏,它会在编译前被替换:#define assert(condition) ((condition) ? (void)0 : __assert_fail(...))由于
#define宏不属于 C++ 命名空间机制,因此它不会放入std::命名空间。
我们还可以通过其它预处理器定义的预处理名字来调试程序:
__FILE__:存放文件名的字符串字面值__func__:存放当前函数名的字符串字面值__LINE__:存放当前行号的字符串字面值__TIME__:存放文件编译时间的字符串字面值__DATE__:存放文件编译日期的字符串字面值
83. 函数匹配
函数匹配的过程:
- 确定候选函数:根据函数名查找
- 确定可行函数:参数相同并且类型相同或者类型可以隐式转换过去
- 寻找最佳匹配:(最佳体现在每个参数都是最佳的)
- 该函数每个实参的匹配都不劣于其它可行函数需要的匹配
- 至少有一个实参的匹配优于其它可行函数提供的匹配
编程建议:调用重载函数时应尽量避免强制类型转换(很容易导致函数匹配二义)。如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。
那么,如何判断参数匹配的优劣性呢?
编译器将实参类型到形参类型的转换划分为几个等级:
- 精确匹配
- 实参类型和形参类型相同
- 向实参添加顶层 const 或从实参中删除顶层 const:例如实参可以是 const int,形参可以是 int(或反之)。
- 实参从数组类型或函数类型转换为对应的指针类型
- 通过 const 转换实现的匹配:为复合类型添加底层 const 属性
- 通过类型提升实现的匹配:把 char、unsigned char、short、unsigned short 转换成 int 类型
- 通过算术类型转换或指针转换实现的匹配: long double、double、float、unsigned long long、long long、unsigned long、long、unsigned int、int 之间的转换称为类型转换
- 通过类类型转换实现的匹配
注意区分好“类型提升”和“算术类型转换”。对于 char 类型来说,如果没有 char 类型与它精确匹配,它会自动提升到 int 而不是 short。这是因为相较于算术类型转换,类型提升的优先级更高。
void f(short) {puts("short");}
void f(int) {puts("int");}
int main()
{char a = 'a';f(a); //intreturn 0;
}
84. 函数指针
函数的“类型”是指除去变量名的其余部分,包括返回类型和参数信息。指向不同函数类型的指针间不存在转换规则,这意味着在匹配重载函数时必须精确匹配。
声明数组指针很简单,对于函数 ret_type func(args); 只需要将函数名 func 替换为指针 *fp ,就声明了一个函数指针 fp:ret_type (*fp)(args); 注意需要用圆括号括起来。
类似于数组,当我们直接使用函数名时,该函数会自动转换为一个指针。这体现在:
- 直接用函数名给指针赋值
- 可以像函数一样使用函数指针
- 类似于数组的直接
int a[]作为形参一样,我们也可以直接用函数原型作为形参,但要注意此时虽然像是声明了一个函数,但依然是一个函数指针
void func(int x)
{cout << "x: " << x << endl;
}// cmp是一个函数指针
int func1(int a, int b, int cmp(int a, int b))
{return cmp(a, b);
}// func2和func1是等价的
int func2(int a, int b, int (*cmp)(int a, int b))
{return cmp(a, b);
}int main()
{void (*fp)(int) = func;fp(1); // fp更像一个函数(*fp)(2); // fp更像一个指针return 0;
}
有时函数类型可能比较复杂,我们可以利用 using 来指定别名,不过要注意函数和函数指针的区别:
int add(int a, int b)
{return a + b;
}//using funcType = int(int,int); // 声明了一个函数类型
using funcType = decltype(add); // 等价形式//using funcPtr = int(*)(int,int); // 声明了一个指针类型(指向函数的指针)
using funcPtr = decltype(&add); // 等价形式int func(int a, int b, funcType cmp)
{return cmp(a, b);
}int main()
{//funcType f1 = add; // 编译错误,funcType是个函数类型而不是指针类型funcPtr f2 = add;cout << func(3, 4, f2) << endl;cout << func(3, 4, add) << endl;return 0;
}
使用 decltype 时,不会自动把函数和数组转换为对应指针。
85. 继承体系中基于指针和引用的查找
无论是单继承还是多继承,对象的静态类型决定了我们能够使用那些成员。不要误认为,我们定义了一个基类的指针或引用指向派生类对象,就可以调用派生类独有的成员了。
struct Foo {void f() {puts("Foo::f");}void g() {puts("Foo::g");}virtual void v() {puts("Foo::v");}
};struct Bar : public Foo {void f() {puts("Bar::f");} // 隐藏Foo中的同名函数void h() {puts("Bar::h");} // 派生类特有的成员函数virtual void v() {puts("Bar::v");} // 覆盖Foo中的虚函数
};void func(Foo &f)
{f.f(); // Foo::ff.g(); // Foo::gf.v(); // Bar::vf.h(); // class "Foo" has no member "h"
}int main()
{Bar b;func(b);return 0;
}
在函数 func 中,可以发现,除了虚函数外,调用的都是其静态类型 Foo 的版本。对于派生类的成员函数 h,基类不可见。这是因为我们只是可以对基类的虚函数进行动态绑定,而不是可以直接调用派生类的成员。
86. 在class外部定义类时可见作用域扩大了
struct Foo {void f();
};int val = 10; // 虽然在类Foo之后定义,但是对于类外定义的函数是可见的void Foo::f() {cout << "val: " << val << endl;
}int main()
{Foo().f(); // val: 10return 0;
}
对于类外定义的 Foo::f 来说,在类定义之后,该函数定义之前的作用域都是可见的。
87. Zero-cost exception handling
Zero-cost exception handling 是一种异常处理策略,旨在让程序在正常执行期间不因异常机制引入额外的运行时开销,即“零成本”。这种方法特别适用于 C++,因为 C++ 是一门重视性能的语言,零开销的异常处理设计符合其高性能需求。Zero-cost exception handling 的核心思想是:在没有异常的正常执行路径上不会引入额外开销,而仅在真正抛出和处理异常时才会有额外的代价。
87.1 关键特性
- 常规代码无开销
在没有发生异常的情况下,zero-cost exception handling 保证异常处理不会影响程序的正常性能。这是通过避免为每个可能抛出异常的函数进行额外检查来实现的。只有在真正抛出异常时,才会引发额外的处理开销。(意思就是说,我们不会通过一堆 if 语句来判断是否发生了异常,因为这样即使没有异常发生,也会带来 if 条件判断的开销) - 分离异常处理代码
使用该机制时,异常处理代码通常与主代码流分离。例如,在 C++ 中,通过编译器生成的额外数据结构(如栈展开表)来支持异常抛出和处理,而不影响主代码流。常见的实现方式有使用 表驱动的栈展开 或 DWARF(Debugging With Attributed Record Formats)表。 - 只在异常发生时支付成本
在出现异常时,才会使用异常处理表进行栈展开(stack unwinding),这会引发额外的开销。这种方式可以在异常出现的过程中,通过异常处理表进行逐步匹配和捕获。
87.2 实现原理
在现代 C++ 实现中,zero-cost exception handling 主要通过表驱动的栈展开机制来实现。该机制分为两部分:
- 异常处理表
编译器在编译时会为程序中的每个函数生成异常处理表。这些表记录了函数调用的栈帧信息、异常处理位置以及捕获的类型等信息。表存储在程序的额外段中,仅在出现异常时使用,因此不会影响正常执行的性能。 - 栈展开(Stack Unwinding)
当抛出异常时,系统会根据异常处理表回溯栈帧,即“展开栈”。栈展开会逐层检查调用栈中的函数,找到合适的catch块来处理异常。这个过程通常由操作系统或运行时支持。
例如,GNU 的 C++ 编译器(GCC)使用的 DWARF 异常处理表 在抛出异常时,通过栈展开表来找到适当的异常处理代码,这样异常处理的开销仅在真正抛出异常时才会发生。
87.3 优缺点
- 优点
- 正常执行路径上几乎没有额外开销。
- 避免了在普通代码中添加异常处理所需的检查,从而提高了性能。
- 缺点
- 当异常被抛出时,栈展开的过程可能比较耗时。
- 生成异常处理表会增加程序的二进制大小。
87.4 使用情景
Zero-cost exception handling 非常适合低延迟、高性能要求的场景,例如实时系统、游戏开发和嵌入式系统。在这些环境中,程序大多数时间都不会抛出异常,但仍需要健壮的异常处理能力。在这种情况下,Zero-cost exception handling 的设计可以让程序在正常操作时达到最高效率。
总之,zero-cost exception handling 在 C++ 中确保了异常处理机制在正常代码执行时不带来开销,仅在异常发生时引入额外的成本。
88. 类成员在内存中的存储方式
class Foo {
public:void printA() {cout << "printA" << endl;}virtual void printB() {cout << "printB" << endl;}
};int main()
{Foo *f = nullptr;f->printA();f->printB();return 0;
}
问题:以上代码的输出结果是什么?
答案是输出 printA 后,程序崩溃。类中可以包括成员变量和成员函数。new 出来的只是成员变量,成员函数始终存在,所以如果成员函数未使用任何成员变量的话,不管是不是static的,都能正常工作。
printB 为虚函数,虚函数表指针存在类的对象中,此处类对象未实例化,所以无法获得虚函数表指针,从而无法获得printB()函数的指针,无法运行 printB 函数。
C++程序的内存格局通常分为四个区:全局数据区,代码区,栈区,堆区。
- 全局数据区存放全局变量,静态数据和常量;
- 所有类成员函数和非成员函数代码存放在代码区;
- 为运行函数而分配的局部变量、函数参数、返回数据、返回地址等存放在栈区;
- 动态分配内存的空间都被称为堆区。
根据这个解释,我们可以得知在类的定义时,类成员函数是被放在代码区,而类的静态成员变量在类定义时就已经在全局数据区分配了内存,因而它是属于类的。对于非静态成员变量,我们是在类的实例化过程中(构造对象)才在栈区或者堆区为其分配内存,是为每个对象生成一个拷贝,所以它是属于对象的。
静态成员函数和非静态成员函数的区别:
- 静态成员函数和非静态成员函数都是在类的定义时放在内存的代码区的,因而可以说它们都是属于类的,但是类为什么只能直接调用静态类成员函数,而非静态类成员函数(即使函数没有参数)只有类对象才能调用呢?
原因是:
- 类的非静态成员函数的参数中会隐式包含一个指向类对象的指针类型参数(即
this指针),此时只有类对象才能调用非静态类成员函数(此时this指针有实值)。
需要说明,不论成员函数在类内定义还是在类外定义,成员函数的代码段都用同一种方式存储。不要将成员函数的这种存储方式和 inline 函数的概念混淆。不要误以为用 inline 声明(或默认为 inline)的成员函数,其代码段占用对象的存储空间,而不用 inline 声明的成员函数,其代码段不占用对象的存储空间。不论是否用 inline 声明(或默认为inline),成员函数的代码段都不占用对象的存储空间。用 inline 声明的作用是在调用该函数时,将函数的代码段复制插人到函数调用点,而若不用 inline 声明,在调用该函数时,流程转去函数代码段的入口地址,在执行完该函数代码段后,流程返回函数调用点。inline 与成员函数是否占用对象的存储空间无关,它们不属于同一个问題,不应搞混。
89. ctor、dtor和const
不同于其它函数,ctor 不能被声明为 const 属性。因为在 构造函数执行期间,对象尚未完全构造完成,const 属性还未真正生效。只有当 构造函数执行完毕后,对象才会被视为真正的 const。这意味着: 构造函数内部可以修改成员变量,即使最终对象是 const,因为 const 约束在构造完成后才生效。
我们可以将类的构造函数声明为 constexpr 属性。
析构函数同样不能声明为 const。析构函数的作用是清理对象资源,可能需要修改成员变量(例如 delete 指针)。const 限制成员函数不能修改成员变量,所以析构函数也不能是 const。
90. ctor初始值列表
90.1 性能更高
为什么说推荐使用初始值列表进行初始化,它比在构造函数体内初始化更快?
这是因为对于没有类内初始值的变量,如果在初始值列表中没有对其初始化,那么初始值列表会对其默认初始化。因此说当构造函数体开始执行时,所有成员变量都得到了初始化,而此时构造函数体所谓的“初始化”实际上是再进行赋值操作。这意味着成员变量先再初始值列表被默认构造,然后在构造函数体内又被重新赋值,等于是做了两次工作。例如:
class Bar {
public:Bar() { cout << "Bar::ctor" << endl; }Bar(int x) { cout << "Bar::ctor(int)" << endl; }
};class Foo {
public:Foo() { cout << "Foo:ctor" << endl; }Foo(int x) { b = Bar(10); cout << "Foo::ctor(int)" << endl; }
private:Bar b;
};int main()
{Foo f(1);return 0;
}
输出为:
Bar::ctor
Bar::ctor(int)
Foo::ctor(int)
对于 Foo 的构造函数 Foo(int x),它实际上相当于:
Foo(int x) : b(Bar())
{ b = Bar(10); cout << "Foo::ctor(int)" << endl; }
可见在初始值列表对 b 进行了一次默认初始化。
90.2 不可复制对象
对于不可复制对象,例如 const 对象,引用等,我们只能在初始值列表中对其初始化,而不能在函数体中赋值。
90.3 初始化顺序
构造函数的初始值列表只能用于初始化成员的值,而不限定初始化的具体执行顺序。事实上,成员的初始化顺序与它们在类定义中从出现的顺序一致。可以看下面的例子:
struct A {A() {cout << "A::ctor" << endl;}A(const A &a) {cout << "A::copy ctor" << endl;}
};struct B {B() {cout << "B::ctor" << endl;}B(const B &b) {cout << "B::copy ctor" << endl;}
};struct C {
public:C(A _a = A(), B _b = B()) : b(_b), a(_a) {}
private:A a;B b;
};
输出:
B::ctor
A::ctor
A::copy ctor
B::copy ctor
尽管我们在初始化列表中先初始化 b,但 a 依然先进行了初始化。
因此说,最好令构造函数的初始值的顺序与成员的声明顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其它成员,这样做的好处是可以不必考虑成员的初始化顺序。例如 X(int val) : i(val), j(val) 要优于 X(int val) : i(val), j(i)。
91. 类的基本思想
类的基本思想是“数据抽象”。
所谓数据抽象,就是说通过将复杂的数据结构隐藏起来,向外界提供简单一致的接口,使开发者专注于如何使用这些数据,而不是关心于它们是如何实现的。
数据抽象主要通过两种方式实现:
- 抽象数据类型:抽象数据类型是一种理论上的概念,定义了某种数据结构以及可对该数据结构执行的操作,但不涉及这些操作的具体实现。例如,堆栈(Stack)或队列(Queue)就是抽象数据类型,定义了插入、删除、访问等操作,而具体的实现方式可能是基于数组或链表。定义数据成员和函数成员的能力
- 封装:封装是面向对象编程中的一个核心原则,它将数据和操作封装在对象中,并提供公共接口(如方法)供外部访问,但隐藏了对象内部的实现细节。用户只需要知道如何调用这些接口,而不需要了解内部是如何实现的。保护类的成员不被随意访问的能力
如果我们将类的实现细节设置为 public,那么这并不算是一种封装。因为用户可以自行使用类中的变量和函数实现自己想要的需求,从形式上看类的实现细节是公开的,可修改的,自然谈不上封装了。
92. 友元的声明
友元声明只能出现在类内部,但是在类中的那个位置并无要求。并且由于友元函数不是类的成员,因此也不受它所在区域访问控制级别的约束。不过一般来说,最好在类定义的开头或结束位置集中声明友元。
尽管我们实际上可以在类内部定义友元函数,但友元函数在类内部的声明仅仅指定了访问的权限,而非一个传统意义上的函数声明。也可以这么理解,在 class 内部的友元声明,仅仅声明了这是一个友元,而不是声明了这是一个函数。
因此我们有必要在类的外部专门对函数进行一次声明,即使我们在类的内部给出了友元的定义(在类内定义的友元也是隐式 inline 的)。虽然许多编译器并未强制要求友元函数必须在使用之前在类的外部声明,但最好还是这样做,因为即使更换了一个强制要求这样做的编译器,也不必修改代码。我们可以看下面的代码:
struct Foo {friend void f() { cout << "f" << endl; }void g();
};// void f();void Foo::g() {f(); // 编译器会报错,找不到f()的定义cout << "g" << endl;
}
我们必须在类的外部声明友元函数 f() 才能使用它。
除了将一个非成员函数声明为友元之外,我们也可以把一个类声明为友元,也可以把其他类的成员函数声明为友元。
最后,友元不会被继承或传递。
93. 类型成员
所谓类型成员,就是在 class 中定义的类型别名。由于它是类成员,因此它也受到访问级别的限制。
我们可以直接使用作用域运算符(::)访问类型成员,而无需使用对象访问。我认为这主要是因为类型成员并不占用内存,因此无需定义一个类对象,也能找到类成员的定义。
类型成员必须先定义后使用。我们知道编译器处理类时会先处理所有声明再处理所有定义(函数体),但要注意对于函数来说,返回类型以及参数类型并不属于定义的一部分,它们属于函数的声明。而编译器在处理声明时是按顺序执行的。因此如果函数声明用到了类型成员而类型成员在函数声明之后,就会导致函数声明看不到类型成员的声明。因此推荐把类型成员放到类的开头。
struct Foo {
public:// 返回类型和参数类型会报错// 函数体内的类型标识符不会报错vi_t get(vi_t vec) {vi_t vec;}typedef vector<int> vi_t;
private:vi_t vec;
};
94. mutable
通过 mutable 修饰的成员是可变类型成员:
- 可变类型成员不受
const对象的限制,即使类对象是const,也可以修改。 - 可变数据成员不影响对象的
const性,其他非mutable成员仍然不能被修改。
例如,有一个 const 对象 obj,obj 有三个对象 a,b,c,其中 c 是 mutable 属性,那么对于 obj 来说,a,b 是不可修改的,因为 c 的 mutable 属性并不能影响 a,b。
95. most vexing parse
在 C++ 中,声明 A a(); 这样的语法并不会创建一个对象,相反,它被解释为一个函数声明,这是 C++ 语法的一个常见陷阱,通常被称为最令人困惑的解析(Most Vexing Parse)。
当我们希望创建的是一个对象时,最好使用 C++11 引入的 {} 初始化。
96. 从const成员函数返回this
对于 const 成员函数,隐式的 this 指针是一个常量指针,此时如果我们想要返回对象的引用,必须返回常量引用。
97. 对于成员函数中的公共代码使用私有功能函数
即,如果多个成员函数中有公共代码,那么应该将这部分代码拿出来放到一个访问级别为 private 的函数当中。由于类内部的成员函数隐式 inline 的特性,调用私有功能函数并不会带来额外的函数调用开销。
98. 成员函数中名字的查找顺序
- 在成员函数体内查找
- 在类内查找
- 在成员函数定义之前的作用域内查找
99. 委托构造函数
C++11 新标准拓展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数。一个委托构造函数使用它所属类的其它构造函数执行自己的部分或全部初始化过程。例如:
struct A {A(int _a, int _b, int _c): a(_a), b(_b), c(_c){}A(int _a) : A(_a, 0, 0) {} // 委托三参数的构造函数int a, b, c;
};
100. 类外定义static成员
为什么类外定义静态成员时不需要再次使用 static?
- 类内部的声明已经表明是静态的: 当你在类内部声明成员时,
static关键字已经告知编译器这是一个静态成员。静态成员在整个类中是唯一的,并且在所有实例中共享。由于你已经通过类内声明指定它是静态的,在类外定义时不需要再次告知编译器它是静态的。编译器会根据类内的声明来处理。 - 类外定义的重点是完成初始化: 类外定义静态成员的目的是对静态变量进行初始化,而不是再次声明它的属性。在类内声明的静态成员只是在类的范围内声明了存在性,但它并未被真正分配空间或初始化。类外的定义就是完成这些操作,所以无需再使用
static关键字。
101. 为什么非类型模板参数不能使用浮点类型和局部变量?
101.1 非类型模板参数不能使用局部变量
template <int T>
void print() {cout << x << endl;
}int main() {int x = 10;print<x>(); // the value of ‘x’ is not usable in a constant expression
}
非类型模板参数不能接受局部变量,这是因为非类型模板参数必须在编译期就确定其值。
- 编译期:在程序被编译成可执行文件时。编译器会处理所有的模板代码,并根据模板参数生成具体的类或函数。
- 运行期:程序被执行时。局部变量是在程序运行时才被创建和赋值的。
对于常量来说,它的地址在编译期是确定的,因此值也是确定的。
101.2 非类型模板参数不能使用浮点类型
101.2.1 唯一实例问题
double 类型是浮点数,其表示方式在计算机内部是二进制浮点数(例如 IEEE 754 标准)。浮点数在二进制表示上可能存在精度问题,例如 0.1 + 0.2 不等于 0.3。这种不精确性使得编译器很难在编译时对两个浮点数进行精确的相等比较。
如果允许 double 作为非类型模板参数,那么对于以下两种情况,编译器将无法区分它们是否代表同一个模板实例:
template<double d>
class MyClass {};// 编译器如何判断这两个实例是否是同一个?
MyClass<3.14> obj1;
MyClass<3.1400000000000001> obj2;
由于浮点数的表示不精确,编译器在编译时无法确定 3.14 和 3.1400000000000001 是否应该被视为相同的值,从而导致模板实例化和重载解析的复杂性和不确定性。
101.2.2 链接器和 ABI 问题
模板实例化是在编译时进行的,而模板实例的名称(mangle name)包含了模板参数的信息。如果非类型模板参数是浮点数,那么它的值也会被编码到最终的符号名称中。
比如:MyClass<3.14> 可能会被编码为 _ZN7MyClassILd3_14EE。
不同的编译器、不同的编译选项,甚至不同版本的编译器,都可能对浮点数的表示和编码方式有所不同,这会导致应用程序二进制接口(ABI)的不兼容。如果一个库是用 MyClass<3.14> 编译的,另一个程序也想使用它,但由于浮点数编码不同,两者可能无法正确链接,从而引发链接错误或运行时问题。
101.2.3 历史原因
C++ 标准委员会在设计模板特性时,就基于实用性和可行性的考虑,将非类型模板参数限制在那些可以轻松在编译时确定和比较的类型上。最初,只允许整数类型、枚举、指针和引用。
101.3 C++ 20 非类型模板参数的改变
C++20 对非类型模板参数(Non-Type Template Parameters, NTTPs)的规则进行了重大扩展,使其能够接受更多类型的参数,从而提高了模板的灵活性和表达能力。
核心改变:
在 C++20 之前,非类型模板参数的类型被严格限制在:
- 整数类型(包括
bool和枚举) - 指针、左值引用类型
std::nullptr_t
这些限制主要是为了保证非类型模板参数在编译时具有唯一且可比较的值,因为模板实例化是基于这些值的。例如,template<int N> 和 template<int M> 是两个不同的模板实例,如果 N 和 M 的值不同。如果允许使用浮点数,可能会因为浮点数的精度问题导致相同的字面量产生不同的二进制表示,从而使模板匹配和实例化变得复杂和不可预测。
C++20 的突破:
C++20 扩展了非类型模板参数的类型范围,现在它们可以是:
-
浮点数类型:这是最显著的改变。现在可以直接使用
double或float作为非类型模板参数。例如:C++
template<double Value> void process() { /* ... */ }process<3.14159>();为了解决前面提到的精度问题,编译器会对浮点数 NTTPs 进行精确的位模式比较,确保
3.14和3.14会生成相同的模板实例。 -
更广泛的类类型:C++17 引入了对某些类类型作为 NTTPs 的支持,但限制非常严格。C++20 进一步放宽了这些限制,现在任何具有字面量类型 (literal type) 和有效
==运算符的类都可以作为 NTTPs。字面量类型大致可以理解为可以在编译时求值的类型。C++
#include <string_view>// 可以在编译时求值的 string_view 可以作为 NTTP template<std::string_view S> class MyClass {// ... };MyClass<"hello world"> my_obj;这使得我们可以直接在模板参数中使用字符串字面量(通过
std::string_view),这在 C++17 中是不可能的。
为什么做出这些改变?
这些改变的核心目标是:
- 提高通用性:使得模板能够处理更多类型的数据,让模板编程更加灵活。
- 简化代码:开发者无需再使用复杂的变通方法(如将浮点数转换为整数位模式)来在模板中使用某些值。
- 支持新的语言特性:例如,新的标准库特性
std::string_view就可以作为 NTTP 使用,从而在编译时处理字符串。
总的来说,C++20 的这些改进让非类型模板参数变得更加强大和直观,消除了许多历史遗留的限制,使现代 C++ 模板编程更具表现力。
102. 隐式的类类型转换
能通过单一实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。这类构造函数又称为“转换构造函数”。
但要注意,编译器只会自动执行一步类类型转换(内置类型转换除外)。例如:
struct Foo {Foo() = default;Foo(string _x) : x(_x) {}string x;
};void func(Foo f)
{cout << f.x << endl;
}int main()
{// func("hello"); // 需要先从const char*转换为string再从string转换为Foofunc(string("hello"));return 0;
}
我们可以通过关键字 explicit 禁止这种自动类型转换。
只允许在类内声明使用 explicit 关键字,对于在类外定义的 ctor,无需再次执行 explicit,这一点和 static 是一样的。
103. aggregate class
聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。聚合类的定义:
- 所有成员都是 public 的
- 没有任何构造函数
- 没有类内初始值
- 没有基类,没有 virtual 函数
从形式上看,聚合类真的就是“多个数据的聚合”。
聚合类型的主要特点是它允许你使用聚合初始化,即我们可以通过一个花括号括起来的成员初始值列表来初始化聚合类的数据成员,注意初始值的顺序要与类中声明的顺序一致。如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。
另外,从定义上看,聚合类型是可以有普通成员函数的。
104. 字面值常量类
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但他符合下面要求,则它也是一个字面值常量类:
- 数据成员必须是字面值类型
- 类必须至少含有一个 constexpr 构造函数
- 类必须使用析构函数的默认定义
- 如果一个数据成员含有类内初始值,则内置数据类型的初始值必须是一条常量表达式;如果成员属于某种类类型,则初始值必须使用成员自己的 constexpr 构造函数
注意,字面值常量类并不要求所有成员都是 public 访问权限
另外,constexpr 成员函数是隐式 const 的。
虽然我们不能定义 const 构造函数,但是可以将构造函数声明为 constexpr 。constexpr 构造函数可以声明为 =default 或 =delete。constexpr 构造函数的函数体一般为空,这是因为 constexpr 函数只允许在返回语句进行计算,而构造函数没有返回语句。
105. 类的静态成员
首先,静态成员是类成员,它是属于类的。因此和其它成员一样,静态成员也有访问权限限制。并且,虽然静态成员不属于类的某个对象,但我们依然可以通过类的对象、指针或引用来访问静态成员。在成员函数中也无需作用域运算符就能访问静态成员。
正因为静态成员不属于类对象而是属于类,因此有以下特性:
- 静态成员没有 this 指针。也不能将静态成员函数声明为 const,因此 const 函数的含义是将 this 指针修饰为 const。
- 静态数据成员并不是在创建对象的时候初始化的,这意味着它不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。
- 静态成员只能使用静态成员,而不能使用非静态成员。因为当我们使用非静态成员时,编译器需要知道你是在操作那个具体的对象,如果没有指定对象而直接访问非静态成员,编译器无法知道你想操作的是那个对象的成员。
struct Foo {static int val;static int sret(int x);const static int i = 1024;int ret(int x) {return x;}
};int Foo::sret(int x) {return i - x;
}
int Foo::val = sret(10);int main()
{cout << Foo::val << endl;return 0;
}
在上面的代码中,我们利用静态成员函数 sret 来初始化静态成员变量 val。和函数的类外定义一样,只要出现了 Foo::,编译器就知道后面内容的作用域是 class Foo。
通常情况下,类的静态成员不应该在类的内部初始化。而然,我们可以为静态常量成员提供类内初始值。在 C++ 中,const static 的整型变量(如 int)可以直接在类内部进行初始化,而对于非整型的静态常量(如 double 或 float),则需要使用 constexpr 。
整型静态常量成员的值在编译时是已知的,可以直接嵌入到代码中。这种特性使得整型静态常量成员可以用于数组大小、模板参数等需要常量表达式的场景。
1. 为什么整型静态常量成员可以用
const?
- 历史原因:在 C++11 之前,
constexpr并不存在,而整型静态常量成员的类内初始化是一个常见的需求(用于数组大小等需要整形常量表达式的场景)。因此,C++ 标准允许整型静态常量成员使用const进行类内初始化。- 编译时常量:整型静态常量成员的值通常是编译时常量(如
const static int MAX_VALUE = 100;),因此使用const是足够的。- 兼容性:为了保持与旧代码的兼容性,C++ 标准保留了这一特性。
2. 为什么非整型静态常量成员需要用
constexpr?
- 编译时常量要求:非整型静态常量成员(如
double、float等)的值也必须是编译时常量。在 C++11 之前,无法直接在类内初始化非整型静态常量成员。constexpr的引入:C++11 引入了constexpr,明确表示“编译时常量”。对于非整型静态常量成员,使用constexpr可以确保其值在编译时确定。- 一致性:通过
constexpr,C++ 提供了一种统一的方式来定义编译时常量,无论类型是整型还是非整型。
和友元一样,即使我们在类内部定义了静态成员,也应该在类外部声明一下。
和其它对象一样,静态数据成员也只能初始化一次,因此最好将静态数据成员的定义与其它非内联函数的定义放在同一个“源文件”而不是头文件中,以避免重复定义错误。
和 explicit 关键字一样,static 关键字只能出现在 class 内部声明,在 class 外部定义时无需重复 static 关键字。
由于静态成员独立于任何对象。因此,在某些非静态成员可能非法的场合,静态成员却可以正常使用:
- 静态数据成员可以是不完全类型,非静态数据成员想要实现这个效果只能使用指针或引用
struct Foo {static Foo sf; // acceptFoo *pf; // acceptFoo &rf; // acceptFoo f; // error: field ‘f’ has incomplete type ‘Foo’
};
- 静态数据成员可以作为默认实参,非静态数据成员在构造函数执行时还未初始化,因此不能作为默认实参
struct Foo {Foo(int _max = MAX) : cur(_max) {}int cur;static const int MAX = 1024;
};
106. 不完全类型
不完全类型是已经声明但是尚未定义的类型。在 C++ 中,类(struct 或 class)中不能包含自身类型的“非指针”或“非引用”成员,因为这会导致不完全类型(incomplete type)问题。
显然的,对于尚未定义的对象,编译器不知道它的大小,所以无法分配存储空间,这也是我们不能使用不完全类型的本质原因。而指针和引用所占存储空间大小与类型无关,所以编译器可以计算分配空间,所以正确。
不过有一个问题就是,如果类中包含了自身的引用,那我们就必须在构造函数的初始值列表当中对其初始化。这就会导致“我们无法创建第一个类对象”,因为创建类对象就需要初始化自身的引用,而要初始化自身的引用就需要先创建一个类对象,这就会导致这两个条件“死锁”。例如:
class Foo {
public:Foo(int _val, Foo &f) : val(_val), rf(f) {}void print() {cout << val << endl;}private:Foo &rf;int val;
};
这里我们永远无法创建第一个 Foo 对象。一个解决方法时,默认当引用指向当前类对象本身:
class Foo {
public:Foo(int _val) : val(_val), rf(*this) {} // 默认初始化rf指向自己Foo(int _val, Foo &f) : val(_val), rf(f) {}void print() {cout << "self: " << val << endl;cout << "ref: " << rf.val << endl;}private:Foo &rf;int val;
};int main()
{Foo f0(16);Foo f1(32, f0);f0.print(); // 16 16f1.print(); // 32 16return 0;
}
但 rf(*this) 可能并不是我们真正想要的行为,使用时要谨慎。
107. 含有初始值的成员初始化
对于含有类内初始值的成员变量,它的初始化发生在那一步呢?是在初始值列表,还是构造函数体?相较于其它变量,不同变量的初始化顺序又是怎样的呢?
首先,在 C++ 中,成员变量的初始化顺序 由 它们在类中声明的顺序决定,而 不是 由构造函数初始化列表的顺序决定。因此,无论初始值列表采用何种初始化顺序,事实上的初始化顺序都是不变的,一个好的变成规范是按照生命顺序编写初始化顺序。
并且,如果一个变量没有在初始值列表中显式初始化,那么它们会被隐式默认初始化,这也是我们推荐在初始值列表进行初始化的原因,因为进入到构造函数体之后,进行的就是赋值而不是初始化了。
#include <iostream>using namespace std;class A {
public:A() { cout << "A::ctor" << endl; }A(const A &a) { cout << "A::copy ctor" << endl; }
};class B {
public:B() { cout << "B::ctor" << endl; }B(const B &b) { cout << "B::copy ctor" << endl; }
};class Foo {
public:Foo(const B &_b) : b(_b) {cout << "assignment" << endl;}
private:A a = A{};B b;
};B b;
// B::ctorFoo f(b);
// A::ctor
// B::copy ctor
// assignment
通过上面代码我们可以发现,对于 f 的初始化,首先初始化成员变量 a,然后初始化成员变量 b,并且由于 a 在类内有初始值,因此这里会使用初始值进行初始化。
108. 为什么shared_ptr不支持管理动态数组
108.1 std::shared_ptr 设计的初衷是管理单个对象
std::shared_ptr 的设计主要是为了管理 单个对象,而不是数组。其核心机制基于引用计数,以确保当没有 std::shared_ptr 实例再指向托管对象时,才会释放该对象。
管理数组引入了额外的复杂性,若多个 shared_ptr 指向同一个数组,引用计数只针对整个数组,而不是单独的数组元素。这种设计会导致一些不便,因为无法对数组的某个元素进行独立管理,只能对整个数组做引用计数管理。
此外,引用计数机制本身在数组场景下需要确保数组的所有元素在引用计数归零时能正确销毁。数组元素类型可能包含复杂的析构逻辑,这也给引用计数带来了额外的复杂性。
108.2 C++ 提供了其他更合适的工具来管理数组
对于管理动态数组,C++ 标准库提供了更合适的工具,如 std::unique_ptr<T[]> 和 std::vector<T>:
std::unique_ptr<T[]>:它专门设计用于管理动态分配的数组,并会自动使用delete[]来释放内存。std::vector<T>:是一个更加灵活的数组替代方案,自动管理内存,并且还提供了许多额外的功能,如动态调整大小、迭代器等。
相比之下,std::shared_ptr 主要用于管理需要引用计数的单个对象,而不是数组。
108.3 标准库的设计意图
C++ 标准库的设计原则之一是尽量保证类型和行为的统一性、直观性。std::shared_ptr 本质上是为管理单个对象设计的,而不是为动态数组设计。如果直接允许 shared_ptr 管理数组,会导致接口设计和使用上的不一致,破坏了 shared_ptr 管理单对象的语义。
为了解决这些问题,C++11 引入了 std::shared_ptr<T[]>,这是一个特化版本,专门用于动态数组的管理,并支持正确的内存释放机制(使用 delete[])和数组元素访问接口。这种分开处理的设计避免了统一接口引入的复杂性,也让用户的意图更加明确。
109. 动态生存期的资源的类
程序使用动态内存多出于以下三种原因之一:
- 程序不知道自己需要使用多少对象:vector,string
- 程序不知道所需对象的准确类型:多态
- 程序需要在多个对象间共享数据:指针
110. 为什么不需要检查下标是否大于0?
class String {
private:std::shared_ptr<vector<string>> data;void check(size_type idx, const string &msg) const {if(idx >= data->size()) {throw(out_of_range(msg));}}
};
在上面的代码中,check 函数为何没有检查下标是否为负数?
- 因为 check 是我们的私有成员函数,它只会被 String 的成员函数调用而不会被用户调用。因此,我们可以很容器的确保传递给它的 idx 的值符合要求,而不必进行检查。
111. 对const char* 的比较
"ab" == "abc"; // false
"abc" == "abc"; // true
上面的比较不是字符串之间的比较,而是两个指向 const char* 的指针的比较,也就是说,这里比较的是两个 const char* 对象的地址。
不过,字符串字面量通常存储在程序的只读数据段(.rodata)中。编译器可能会对相同的字符串字面量进行优化(字符串池化,string pooling),使得相同的字符串字面量共享同一个地址。因此,这里虽然理论上是比较两个字符串的地址,但起到的效果和比较字符串是相同的,
但是如果我们不仅仅想得到相等关系,还希望得到大小关系,就需要使用库函数 strcmp() 了。
112. 引用
引用的意义:
- 引用可以看作某个变量的 “别名”,作为某个变量的别名而存在,因此在一些场合可以代替指针。
- 引用相对于指针来说具有更好的可读性和实用性,能起到指针的部分作用,但是比指针安全。
- 引用在 C++ 里面可以说是一把利器,引用用的好的话可以写出非常精妙的程序。
引用的本质:
-
引用在 C++ 中的内部实现是一个常量指针。
Type& name <=> Type* const name -
C++ 编译器在编译过程中使用常量指针作为引用的内部实现,因此引用所占用的空间大小与指针相同。
-
从使用的角度,引用会让人误会其只是一个别名,没有自己的存储空间。这是 C++ 为了实用性而做出的细节隐藏。
我们在写操作符重载的时候都是用引用作为函数的返回值,我们来看一段代码:
int temp;int fun1()
{temp = 10;return temp;
}int& fun2()
{temp = 10;return temp;
}int main()
{int a = 0, b = 0;// 1. 返回普通类型a = fun1();// 2. 返回引用b = fun2();// 3. 返回引用去初始化一个新的引用int &c = fun2();cout << "a = " << a << endl;cout << "b = " << b << endl;cout << "c = " << c << endl;return 0;
}
// 编译结果:a = 10 b = 10 c = 10
-
返回普通类型
返回普通类型对象其实是返回这个对象的拷贝,C++ 其实会创建一个临时变量,这个临时变量被隐藏了,它会把 temp 的值拷贝给这个临时变量,当执行语句 “a = fun1();” 的时候就会把临时变量的值再拷贝给 a,假设这个临时变量是 t,相当于做了这两个赋值的步骤:t = temp; a = t;
-
返回引用
返回引用实际返回的是一个指向返回值的隐式指针,在内存中不会产生副本,是直接将 temp 拷贝给 a,这样就避免产生临时变量,相比返回普通类型的执行效率更高,而且这个返回引用的函数也可以作为赋值运算符的左操作数,但是这时候需要注意以下两个问题:
- 千万别返回局部对象的引用,当函数执行完局部对象也就被销毁,栈空间被释放,从而返回的地址已经不存在,导致后面执行出错。
- 返回堆区对象的引用,这种情况要特别注意,这时候返回函数的引用是作为一个临时变量出现,没有将它赋值给一个实际存在的变量,那么这个堆区对象的内存空间就没有释放,可能造成内存泄漏。有人说这样做是非法的?其实不是绝对的,只能说这种编程习惯很不好,这样做只是容易造成内存泄漏,只是我们要记住,我们在申请堆内存以后必须记得去释放这块内存。
-
返回引用去初始化一个新的引用
这个和前面一样,都是不会产生副本,但是现在是用返回值去初始化一个引用声明 c,也就是说这时候变成了变量 temp 的别名,在 c 的生命周期内 temp 是一直有效的,这样做完全可以。
113. 对常量成员函数的误解
const 修饰的成员函数就是常量成员函数。
误认为,
const对象只能调用普通成员函数;非const对象只能调用const成员函数。
当你定义一个 const 成员函数时,编译器保证该函数不会修改对象的成员变量(除非它们被标记为 mutable),并且它可以被任何对象调用,无论是 const 还是非 const 对象。
- 对于非
const对象,既可以调用const成员函数,也可以调用非const成员函数。 - 对于
const对象,只能调用const成员函数,因为它们保证不会修改对象的状态。
例如对于下面的类和函数调用:
class Foo {
public:void func() const {std::cout << "const" << std::endl;}void func() {std::cout << "non-const" << std::endl;}
};void test()
{Foo f;const Foo cf;f.func();cf.func();
}
(1)当 const 成员函数和非 const 成员函数都存在时:
- const 对象调用 const 成员函数
- 非 const 对象调用非 const 成员函数。
(2)当只有 const 成员函数时:
- const 对象调用 const 成员函数
- 非 const 对象调用 const 成员函数。
(3)当只有非 const 成员函数时:
- 非 const 对象调用非 const 成员函数
- const 对象调用失败
虽然通过非 const 对象调用 const 成员函数可能看起来“不安全”,但实际上,在 C++ 的设计中,这是有其合理性和安全性的。
核心原理是,const 修饰于函数只保证在该函数体中不会修改类成员。至于你通过其它形式修改了类成员,这就与 const 无关了。
例如我们可以通过返回引用这种形式来间接修改对象的值,但我们并没有在该函数内修改,const 的修饰依然有效。
-
const成员函数的核心作用:const成员函数的定义是不会修改对象的成员变量(除非这些变量是mutable)。因此,即使从非const对象调用了const成员函数,编译器也会确保这个函数不会改变对象的状态。这是它的安全保障。
例如:
class Foo { public:int getVal() const {return val;} private:int val = 10; };在这里,
getVal()是一个const成员函数,不管你是在const还是非const对象上调用它,它都无法修改对象的内部状态。这是由const成员函数的性质决定的。因此它是安全的。 -
非
const对象调用const成员函数的合理性:- 对于非
const对象,你可以调用任何成员函数,包括const和非const成员函数。因为非const对象的状态是可以修改的,调用const成员函数是安全的——即使你不打算修改对象状态,还是可以访问它的属性。
示例:
class Foo { public:int getVal() const { return val; }void setVal(int v) { val = v; } private:int val; };int main() {Foo f;f.getVal(); // 调用 const 成员函数,没有修改对象f.setVal(20); // 调用非 const 成员函数,修改了对象 }在这个例子中,
f是非const对象。你可以调用getVal()来读取对象的值,调用setVal()来修改对象的状态。这完全是安全的,因为 C++ 保证getVal()不会改变f的状态。 - 对于非
为什么不能反过来:const对象不能调用非const成员函数?
-
const对象的状态应该保持不可变。允许它调用非const成员函数会破坏这个语义,因为非const成员函数可能会改变对象的状态。这就是为什么 C++ 明确规定:const对象只能调用const成员函数。const Foo cf; cf.setVal(20); // 错误:不能在 const 对象上调用非 const 成员函数如果允许
const对象调用非const成员函数,那就意味着对象状态有可能被修改,这将导致不安全的行为。因此,C++ 编译器会阻止这种情况发生。
114. 宽字符
wchar_t、char16_t 和 char32_t 都用于存储宽字符(Unicode),但它们在 大小、用途和标准支持 方面有所不同。
114.1 wchar_t vs char16_t vs char32_t 对比
| 特性 | wchar_t |
char16_t |
char32_t |
|---|---|---|---|
| 大小 | 平台相关(2 字节或 4 字节) | 2 字节(16 位) | 4 字节(32 位) |
| 存储编码 | Windows: UTF-16,Linux/macOS: UTF-32 | UTF-16 | UTF-32 |
| 是否标准化 | 非标准化(平台相关) | 标准化(C++11 引入) | 标准化(C++11 引入) |
| 适用场景 | 处理本机宽字符,GUI API(如 Windows API) | UTF-16 编码存储 Unicode(Java, Windows API, JSON) | UTF-32 编码存储 Unicode(完整支持所有 Unicode 码点) |
| 推荐使用 | 不推荐,因其大小依赖平台 | 推荐用于 UTF-16 兼容系统 | 推荐用于 UTF-32 直接存储字符 |
| 对应字符串 | wstring s = L"hello" |
u16string s = u"hello" |
u32string s = U"hello" |
114.2 详细说明
114.2.1 ① wchar_t(宽字符类型)
- 大小: 依赖平台(Windows 2 字节(UTF-16),Linux/macOS 4 字节(UTF-32))。
- 历史:
wchar_t早期用于国际化,C++98 引入,但缺点是 跨平台行为不一致,导致可移植性问题。 - 使用场景:
- Windows API(Windows 使用
wchar_t作为 Unicode 字符) - 旧代码库兼容(如
std::wstring)
- Windows API(Windows 使用
示例:
#include <iostream>
#include <locale>int main() {std::wcout.imbue(std::locale("")); // 使 wcout 能够正确显示本地化宽字符std::wstring wstr = L"你好";std::wcout << L"宽字符字符串: " << wstr << std::endl;return 0;
}
📌 缺点:
- 跨平台不兼容(Windows 是 2 字节,Linux/macOS 是 4 字节)。
- 不适合直接用于 Unicode 处理(因大小不固定)。
114.2.2 ② char16_t(固定 2 字节,UTF-16 编码)
- 大小: 2 字节(16 位),适用于 UTF-16。
- C++11 标准引入,专门用于 Unicode 处理,大小固定,不受平台影响。
- 使用场景:
- 处理 UTF-16 字符串(Windows、Java、XML、JSON)
- 适用于 UTF-16 编码格式(如 Windows
std::u16string)
示例:
#include <iostream>
#include <string>int main() {std::u16string u16str = u"你好"; // UTF-16 编码std::cout << "UTF-16 字符串长度: " << u16str.size() << std::endl;return 0;
}
📌 缺点:
- UTF-16 采用变长编码,部分 Unicode 字符(如表情符号)需要 2 个
char16_t(代理对) 来存储。 - 在处理不定长字符时,UTF-16 可能比 UTF-8 复杂。
114.2.3 ③ char32_t(固定 4 字节,UTF-32 编码)
- 大小: 4 字节(32 位),适用于 UTF-32。
- C++11 标准引入,专门用于 Unicode 处理,大小固定。
- 使用场景:
- 直接存储 完整 Unicode 码点(每个字符固定 4 字节)
- 适合高效处理 Unicode 字符(无需处理变长编码)
示例:
#include <iostream>
#include <string>int main() {std::u32string u32str = U"你好"; // UTF-32 编码std::cout << "UTF-32 字符串长度: " << u32str.size() << std::endl;return 0;
}
📌 缺点:
- 占用更多空间(4 字节存储一个字符,比 UTF-8 和 UTF-16 更大)。
- 较少使用,因为 UTF-8 和 UTF-16 更常见。
114.3 推荐使用哪种?
| 需求 | 推荐类型 |
|---|---|
| 仅处理英文(ASCII) | char (std::string) |
| 需要兼容 UTF-8 | char + std::string |
| 需要 UTF-16(如 Windows API、Java) | char16_t + std::u16string |
| 需要直接存储 Unicode 码点(无变长编码) | char32_t + std::u32string |
| 旧代码兼容(Windows GUI 应用) | wchar_t + std::wstring |
📌 现代 C++ 推荐:
- 跨平台开发 → UTF-8(
std::string),因为它节省空间、兼容 ASCII,适用于文件存储、网络传输。 - Windows 编程 → UTF-16(
char16_t),因 Windows API 使用 UTF-16。 - 完整 Unicode 处理 → UTF-32(
char32_t),适合字符处理(但占用空间大)。 - 不推荐
wchar_t,因为它在不同平台的大小不一样,不利于跨平台开发。
114.4 总结
| 类型 | 是否跨平台 | 适用编码 | 适用场景 |
|---|---|---|---|
char |
✅(推荐) | ASCII / UTF-8 | 兼容性好,适用于文件存储、文本处理 |
wchar_t |
❌(平台相关) | Windows: UTF-16, Linux/macOS: UTF-32 | 旧代码、Windows API |
char16_t |
✅(C++11) | UTF-16 | 适用于 Windows、Java、XML |
char32_t |
✅(C++11) | UTF-32 | 适用于 Unicode 直接存储 |
🚀 结论:
- 如果不确定用哪个,推荐
char+ UTF-8(std::string)。 - 跨平台开发避免使用
wchar_t,建议改用char16_t或char32_t。 - Windows GUI API(Win32)仍然大量使用
wchar_t(UTF-16),在 Windows 开发时可以用char16_t适配。
114.5 宽字符串不是字面量
在 C++ 中,std::wstring、std::u16string 和 std::u32string 不能直接定义为 constexpr 的原因主要有以下几点:
- 动态内存分配
- 这些字符串类型通常使用动态内存分配来管理它们的字符数据。
constexpr对象必须在编译时完全确定,且不能涉及任何动态内存分配(因为堆内存操作是运行时行为)。 - 在 C++20 之前,标准库的字符串(如
std::string、std::wstring等)的实现依赖于动态分配,因此无法在编译时构造。
- 这些字符串类型通常使用动态内存分配来管理它们的字符数据。
- 非字面类型(Literal Type)
constexpr变量必须是字面类型(Literal Type)。在 C++17 及之前,std::wstring、std::u16string和std::u32string的析构函数不是constexpr,因此它们不符合字面类型的要求。- C++20 对
std::string和std::u8string进行了改进,允许它们的析构函数是constexpr,但其他宽字符字符串类型(如wstring、u16string、u32string)并未完全支持。
如果你需要在编译时使用宽字符或 Unicode 字符串,可以考虑以下方法:
- 使用
constexpr字符数组
constexpr wchar_t wstr[] = L"Hello, wide world!";
constexpr char16_t u16str[] = u"Hello, UTF-16!";
constexpr char32_t u32str[] = U"Hello, UTF-32!";
115. reserve、size、capacity
首先看一下下面的代码:
vector<int> v;
v.reserve(10);
fill_n(v.begin(), 10, 1024);
//cout << v.size() << ' ' << v.capacity() << endl; // 0 10
for(auto &x : v) cout << x << endl;
对于上面的代码,你以为会输出 10 个 1024?错误的,实际上代码的行为是未定义的。并且我们给了你提示,v.size() 和 v.capacity() 的输出结果是 0 和 10。
问题解释:
reserve()只分配内存,不初始化(构造)元素v.reserve(10)只会增加capacity()(预分配内存),但不会改变size()。- 此时
v.size() == 0,v.capacity() >= 10。 v仍然是空的,没有任何元素被构造。
fill_n(v.begin(), 10, 1024)是未定义行为v.begin()返回的迭代器指向v的第一个有效位置,但由于v.size() == 0,v.begin() == v.end()。fill_n会尝试向v.begin()开始的 10 个位置写入1024,但这些位置不属于v的有效范围(因为v的size()是 0)。- 这是典型的越界访问,会导致未定义行为(可能崩溃、数据损坏或看似“正常”但实际错误的结果)。
for(auto &x : v)不会执行- 因为
v.size() == 0,范围for循环的区间是[v.begin(), v.end()),而v.begin() == v.end(),所以循环体不会执行。
- 因为
我们先回忆一下 new 的原理,大约分为三个步骤:
void *p = operator new(sizeof(T)); // 分配内存
p::T(args); // 调用构造函数
T *obj = static_cast<T*>(p); // 指针类型转换
我们可以理解为,reserve 相当于 allocate 了一块 raw memory,对应 new 的第一步,此时这块内存上什么也没有,我们还需要调用构造函数,才在这个内存上创建了一个对象。因此说 v.reserve(10); 只是创建了可以容纳 10 个容器元素的 raw memory,但此时并没有任何元素被构造。
现在我们也能更好的理解 size 和 capacity 了:
- *capacity: returns the number of elements that the container has currently allocated space for.*
- *size: returns the number of elements in the container.*
capacity 表示理论上我们可以容纳的元素个数,如果我们容纳的元素个数超出了这个范围,就需要对容器扩容;size 表示实际上我们已经容纳的元素个数。
在具体的写代码过程中,实际上我们是感知不到 capacity 的,因为 capacity 涉及到的容器扩容是容器底层的自动的行为。而 size 是我们真真切切感知到的,如果我们对器元素的访问超过 size 的范围,就会发生溢出错误。
116. the difference between erase and remove
STL 容器中,成员函数 remove 和 erase 的分布情况如下:
有 erase 成员函数的容器:
vectorlistdequestringmapsetmultimapmultisetunordered_mapunordered_setunordered_multimapunordered_multiset
有 remove 成员函数的容器:
listforward_list
需要注意的是:
- 虽然
vector没有remove成员函数,但可以使用算法std::remove配合erase实现"删除-擦除惯用法"(erase-remove idiom) remove和erase的功能不同:remove会移动元素但不改变容器大小erase实际删除元素并减小容器大小
如果需要删除符合特定条件的元素,list 还提供了 remove_if 成员函数,而其他容器可以使用 std::remove_if 算法。
116.1 为什么只有链表提供了 remove 成员函数
只有 list 和 forward_list 提供 remove 成员函数的原因与这些容器的内部实现和性能特性有关:
主要原因:
- 链表结构特性
list(双向链表)和forward_list(单向链表)是基于节点的链式结构- 对这类结构来说,移除元素只需重新连接指针,不需要移动其他元素
- 因此可以高效实现 O(n) 时间复杂度的实际删除操作
- 连续存储容器的限制
vector、deque等连续存储的容器在移除中间元素时,需要移动后续所有元素,这意味着每一个元素的remove都需要 O(m) 的时间复杂度(m 为容器大小)- 这使得真正的删除操作代价很高,特别是对大型容器而言
- 效率考虑
- 对链表结构,
remove可以在一次遍历中完成所有元素的实际删除 - 对连续存储容器,将实际删除操作拆分为"移动"(通过
std::remove算法)和"擦除"(通过erase成员函数)更为高效
- 对链表结构,
- 设计哲学
- STL 设计者选择只在能高效实现的容器上提供
remove成员函数 - 对其他容器,提供了泛型算法
std::remove作为替代方案
- STL 设计者选择只在能高效实现的容器上提供
关联容器没有 remove 成员函数是因为:
- 它们基于键进行查找和删除,通常使用
erase基于特定键删除 - 这些容器内部结构(通常是树或哈希表)不适合
remove的语义(remove实际上并没有删除元素,它只是将元素放到容器末尾)
这种分工体现了 STL 的设计原则:容器提供最适合其内部结构的操作,通用需求则通过算法解决。
116.2 c.erase 和 c.remove 的区别
116.2.1 erase 成员函数
-
功能:真正删除迭代器指定的元素,减小容器大小
-
返回值:不同容器的返回值并不相同,甚至同一容器的不同
erase版本的返回值也不相同。具体的需要自己去看 API:- vector 和 list 返回最后一个被删除元素的下一个元素的迭代器
- string 返回自己的引用
- map 按键删除时返回被删除元素的个数
- map 按迭代器删除时返回
-
广泛支持:几乎所有 STL 容器都提供此成员函数
-
时间复杂度:
- 关联容器(map/set):通常是O(log n)
- 连续容器(vector/deque):O(n),因为需要移动后续元素
- 链表(list):O(1),仅删除指定位置的元素
-
用法示例:
container.erase(iterator); // 删除单个元素 container.erase(begin_iterator, end_iterator); // 删除范围
116.2.2 remove 成员函数
-
功能:真正删除等于特定值的所有元素,减小容器大小
-
返回值:void
-
有限支持:仅
list和forward_list提供此成员函数 -
时间复杂度:O(n)
-
用法示例
list_container.remove(value); // 删除所有等于value的元素
116.2.3 std::remove 算法(非成员函数)
-
功能:重排元素,将要删除的元素移到末尾,返回新的逻辑结束迭代器
-
返回值:void
-
不改变容器大小:仅重排元素
-
广泛适用:可用于所有支持前向迭代器的容器
-
通常与 erase 结合:形成 [Erase–remove idiom]( https://en.wikipedia.org/wiki/Erase–remove_idiom#:~:text=The erase–remove idiom is a common C%2B%2B technique ,criterion%20from%20a%20C%2B%2B%20Standard%20Library%20container.%20%5B1%5D%5B2%5D%5B3%5D)
-
用法示例
// erase-remove习惯用法 vec.erase(std::remove(vec.begin(), vec.end(), value), vec.end());
这种设计体现了 STL 的"效率优先"哲学,允许开发者根据具体容器类型选择最优的删除策略。
116.3 测试
void test_vector()
{// erase 用法vector<int> vec = {1, 2, 3, 4, 5};// 删除单个元素vec.erase(vec.begin() + 1); // 删除第二个元素,结果:{1, 3, 4, 5}// 删除范围vec.erase(vec.begin() + 1, vec.begin() + 3); // 删除索引1到2的元素,结果:{1, 5}
}void test_list()
{list<int> lst = {1, 2, 3, 2, 4, 2};// erase 用法auto it = std::next(lst.begin(), 2); // 指向第三个元素3lst.erase(it); // 删除单个元素,结果:{1, 2, 2, 4, 2}it = lst.erase(lst.begin(), std::next(lst.begin(), 2)); // 删除范围,结果:{2, 4, 2},返回指向第一个2的迭代器lst = {1, 2, 3, 2, 4, 2};// remove 用法lst.remove(2); // 删除所有值为2的元素,结果:{1, 3, 4}lst = {1, 2, 3, 2, 4, 2};lst.remove_if([](int n){ return n % 2 == 0; }); // 删除所有偶数,结果: {1,3}
}void test_forward_list()
{forward_list<int> flst = {1, 2, 3, 2, 4};// erase_after 用法(注意不是erase)auto it = flst.erase_after(flst.before_begin()); // 删除第一个元素,结果:{2,3,2,4},返回指向第一个元素2的迭代器it = flst.before_begin();std::advance(it, 1); // it指向flst.begin()it = flst.erase_after(it, std::next(it, 2)); // 删除范围,删除 (it+1,it+2),结果:{2,2,4},返回指向第二个元素2的迭代器// remove 用法flst.remove(2); // 删除所有值为2的元素,结果:{4}flst.remove_if([](int n){ return n > 3; }); // 删除所有大于3的元素,结果:{}
}void test_deque()
{deque<int> dq = {1, 2, 3, 4, 5};// erase 用法dq.erase(dq.begin() + 1); // 删除第二个元素,结果:{1,3,4,5}dq.erase(dq.begin() + 1, dq.begin() + 3); // 删除范围,结果:{1,5}// deque 没有 remove 成员函数
}void test_string()
{string str = "hello,world!";// erase 用法str.erase(2, 3); // 从位置2开始删除3个字符 "llo",结果 "he,world!"auto &it = str.erase(3); //从位置3删除到末尾,结果:"he,",返回自己本身cout << it << endl; // "he,""it = "fuck"; // 修改原字符串 str 为 "he,"cout << str << endl; // "he,"
}void test_map_and_set()
{{ // test mapmap<int, string> m = {{1, "one"}, {2, "two"}, {3, "three"}};// erase 用法size_t cnt = m.erase(2); // 按键删除,删除键为2的元素,返回删除元素的个数:1auto it = m.find(1);auto ne = m.erase(it); // 按迭代器删除,返回被删除元素下一个元素的迭代器cout << (*ne).first << ":" << ne->second << endl; // 3:three}{ // test setset<int> s = {1, 2, 3, 4, 5};size_t cnt = s.erase(3); // 删除值为3的元素,返回删除元素的个数:1,结果为 {1,2,4,5}auto it = s.erase(s.begin(), std::next(s.begin(), 2)); // 删除范围,结果为 {4,5},返回指向4的迭代器}
}void test_multiple_map_and_set()
{multimap<int, string> mm = {{1, "one"}, {1, "uno"}, {2, "two"}};// erase 用法size_t cnt = mm.erase(1); // 删除所有键为1的元素,返回2,结果为:{{1, "one"}}mm = {{1, "one"}, {1, "uno"}, {2, "two"}};auto range = mm.equal_range(1);mm.erase(range.first, range.second); // 删除特定键的范围,结果为:{{1, "one"}}multiset<int> ms = {1, 2, 2, 3, 3, 3};ms.erase(3); // 删除所有值为3的元素
}void test_unordered_map_and_set()
{unordered_map<int, string> um = {{1, "one"}, {2, "two"}};unordered_set<int> us = {1, 2, 3, 4};// erase 用法与对应的有序容器相同size_t cnt = um.erase(1); // 按键删除auto it = us.erase(us.find(2)); // 按迭代器删除
}
116.4 [Erase–remove idiom]( https://en.wikipedia.org/wiki/Erase–remove_idiom#:~:text=The erase–remove idiom is a common C%2B%2B technique ,criterion%20from%20a%20C%2B%2B%20Standard%20Library%20container.%20%5B1%5D%5B2%5D%5B3%5D)
在前面我们介绍过,std::remove 和 std::remove_if 并不实际删除元素,我们可以看一下在 CPlusPlus 上给出的 std::remove 在 C++ 11 标准下的一种实现:
template<class ForwardIterator, class T>
ForwardIterator Remove(ForwardIterator first, ForwardIterator last, const T& val)
{ForwardIterator result = first;while(first != last) {if(!(*first == val)) { // *first != valif(result != first) {*result = move(*first); // C++ 11, using move syntax// *result = *first; // C++ 98}++ result;}++ first;}return result;
}int main()
{vector<int> v{1, 2, 1, 4, 1, 6};auto last = Remove(v.begin(), v.end(), 1); // 2 4 6 4 1 6cout << *last << endl; // 4return 0;
}
我们可以发现,它的实现和 std::unique 有点类似。在这里,std::remove 所做的其实就是将不需要删除的元素依次放到容器的头部,并且返回尾后迭代器。在这里,[v.begin(), last) 其实就是我们希望得到的序列,但是 std::remove 并没有将 [last, v.end()) 这一部分的元素删除掉。
std::remove 返回第一个需要删除元素的迭代器这一特性可以和 c.erase 很好的结合起来。因此说,如果我们希望在非链表容器中高效的删除指定值的元素,一种习惯用法是 erase-remove:
- 先将不需要删除的元素放在序列的开头,并且保持相对位置不变,返回指向第一个需要删除元素的迭代器,时间复杂度是 O(N)
- 删除这些元素,取决于具体的容器,时间复杂度为 O(N)
也即,我们实现了一种时间复杂为 O(N) 的算法。利用此算法,我们可以高效的删除 vector 中指定值的元素:
vector<int> v{1, 2, 1, 4, 1, 6};
v.erase(std::remove(v.begin(), v.end(), 1), v.end()); // 结果为 {2, 4, 6}
116.5 std::erase 和 std::remove
虽然说 erase-remove idiom 很巧妙,也很高效,但其实还是有一点麻烦的,因为我们需要调用两个函数做一个应该一个函数就能解决的问题。因此,C++20 引入了两个非成员函数模板 std::erase 和 std::erase_if,它们为容器元素删除操作提供了更简洁、更统一的接口,是对传统 erase-remove 惯用法的现代化替代。
116.5.1 std::erase
template<class C, class T>
typename C::size_type erase(C& c, const T& value);
删除容器中所有等于 value 的元素,返回删除的元素数量。
116.5.2 std::erase_if
template<class C, class Pred>
typename C::size_type erase_if(C& c, Pred pred);
删除容器中所有满足谓词 pred 的元素,返回删除的元素数量。
116.5.3 优势
- 更简洁的语法:无需手动组合
remove和erase - 更安全的操作:减少了迭代器错误使用的可能性
- 更一致的接口:为不同容器提供了统一的删除方式
- 返回删除数量:直接知道删除了多少元素
117. 数组的连续性
在计算机系统中,程序所能看到的地址都是虚拟地址(也称逻辑地址),这是操作系统提供的一层抽象。关于数组在内存中的存储特性,需要理解以下几点:
117.1 数组的连续性特征
- 虚拟地址空间中的连续性:数组在虚拟地址空间中总是连续的,这是编程语言规范保证的。
- 物理地址空间中的连续性:数组在物理地址空间中的连续性则取决于系统的内存管理机制:
- 当数组较小,完全位于同一个物理页内时,物理地址通常是连续的
- 当数组跨越多个页面时,相邻的虚拟页面可能映射到不相邻的物理页面,导致物理地址不连续
117.2 对程序的影响
- 用户程序的透明性:用户空间程序通常不需要关心也无法感知物理地址的连续性,因为:
- 内存访问都是通过虚拟地址进行的
- 操作系统和硬件透明地处理虚拟到物理地址的转换
- 唯一可能的影响是当访问跨页数组时可能产生 TLB 缺失(TLB miss),带来轻微的性能损失
118. ios::binary
在C++中,ios::binary 是 ios 类中定义的一个打开模式标志(open mode flag),用于文件操作。这个标志主要用于指示文件流以二进制模式打开文件,而不是默认的文本模式。
当你使用二进制模式时:
- 数据按原始字节读写,不进行任何转换
- 没有特殊字符的处理(比如在Windows中,文本模式会将"\n"转换为"\r\n")
- 适合处理非文本文件,如图片、音频、可执行文件等
118.1 字符与字节的关系
ASCII/扩展ASCII编码:一个字符对应一个字节(8位)
- 基本 ASCII 只使用 7 位,范围是 0-127
- 扩展 ASCII 使用 8 位,范围是 0-255
多字节编码:
- UTF-8:变长编码,英文字符占 1 字节,中文等其他字符通常占 2-4 字节
- UTF-16:大多数字符占 2 字节,某些特殊字符占 4 字节
- UTF-32:所有字符统一占 4 字节
- GBK/GB2312/GB18030:中文字符通常占 2 字节
118.2 原始字节
原始字节(Raw Bytes) 指的是计算机中最底层的二进制数据,未经任何编码、格式化或解释的纯粹 0 和 1 的序列。它是所有数字信息(如文件、网络数据、内存内容)的物理存储形式。以下是详细解析:
118.2.1 2.1 原始字节的核心特性
- 无类型:字节本身没有固有含义,其意义取决于上下文(如文件格式、程序逻辑)。
- 例如:字节
0x41可以是:- 字母
'A'(ASCII 编码) - 数字
65(十进制) - 图像像素的亮度值
- 机器指令的一部分
- 字母
- 例如:字节
- 不可见性:直接查看文件或内存时,看到的是二进制数据,需工具或程序解析为可读形式。
118.2.2 2.2 原始字节的常见表现形式
| 形式 | 示例(字节 0x3F) |
说明 |
|---|---|---|
| 二进制 | 00111111 |
8位二进制序列 |
| 十六进制 | 0x3F |
编程中常用的简洁表示法 |
| 十进制 | 63 |
对应的无符号整数 |
| ASCII字符 | '?' |
0x3F 在ASCII表中对应问号 |
118.3 如何查看和编辑原始字节?
-
命令行工具:
-
xxd(十六进制转储):xxd -g 1 file.bin # 每字节显示为十六进制 -
hexdump:hexdump -C file.bin
-
-
图形化工具:
- Windows:HxD、010 Editor
- Linux/macOS:GHex、Hex Fiend
-
编程读取(Python):
with open("file.bin", "rb") as f:bytes_data = f.read() # 获取原始字节print(bytes_data.hex()) # 输出十六进制字符串
这里主要介绍一下 hexdump。
hexdump 是一个命令行工具,用于以 十六进制(hex)、十进制、八进制或 ASCII 格式显示文件内容。它常用于 二进制文件分析、数据调试、逆向工程 等场景。
118.3.1 3.1 hexdump 基本用法
(1)查看文件十六进制内容
hexdump -C 文件名
-
-C:规范化输出(Canonical),显示 十六进制 + ASCII 格式。 -
示例:
hexdump -C example.bin输出:
00000000 48 65 6c 6c 6f 20 57 6f 72 6c 64 0a |Hello World.| 0000000c- 左侧:偏移地址(
00000000表示第 0 字节)。 - 中间:每行 16 字节的十六进制值(
48=H,65=e...)。 - 右侧:ASCII 可打印字符(
.表示不可见字符)。
- 左侧:偏移地址(
(2)指定显式格式
hexdump -v -e '16/1 "%02x " "\n"' 文件名
-v:禁用压缩(显示所有字节)。-e:自定义格式:16/1:每行 16 个字节。"%02x ":每个字节以 2 位十六进制显示,并且后面跟一个空格。"\n":每行换行。
示例:
48 65 6c 6c 6f 20 57 6f 72 6c 64 0a
(3)查看特定偏移的数据
hexdump -s 4 -n 4 -C 文件名
-
-s 4:跳过前 4 字节。 -
-n 4:仅读取 4 字节。 -
示例:
hexdump -s 4 -n 4 -C example.bin输出:
00000004 6f 20 57 6f |o Wo|
(4)以不同进制显示
| 格式 | 命令 | 示例输出 |
|---|---|---|
| 十六进制 | hexdump -C |
48 65 6c 6c |
| 十进制 | hexdump -d |
18484 27756 |
| 八进制 | hexdump -o |
110 145 154 |
| 二进制 | hexdump -b |
01001000 01100101 |
118.3.2 3.2 与其他工具对比
| 工具 | 特点 | 适用场景 |
|---|---|---|
hexdump |
灵活,支持自定义格式 | 二进制分析、调试 |
xxd |
简单易用,支持反向转换(xxd -r) |
快速查看十六进制 |
od |
支持多种格式(八进制、十六进制等) | 兼容性要求高的场景 |
118.3.3 3.3 实际应用
(1)检查二进制文件头
hexdump -C -n 16 /bin/ls
输出:
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
7f 45 4c 46是 ELF 文件 的魔数(Magic Number),表示这是一个 Linux 可执行文件。
(2)分析网络抓包数据
tcpdump -w packet.pcap
hexdump -C packet.pcap | less
可查看原始网络包的十六进制内容。
118.4 示例
void read_binary_data(const string &filename, size_t size)
{ifstream file(filename, ios_base::in);char ch[4];file.read(ch, size);for(int i = 0; i < size; i ++ ) {cout << ch[i] << ":" << hex << (unsigned int)(ch[i]) << dec << endl;}// $:24// ^:5e// A:41// a:61
}void write_binary_data(const string &filename, int value, size_t size)
{ofstream file(filename, ios_base::binary | ios_base::out);file.write(reinterpret_cast<char*>(&value), size);
}int main()
{// a: 0x61// A: 0x41// ^: 0x5e// $: 0x24string file_name = "data.bin";int value = 0x61415e24; // aA^$size_t size = sizeof(int);write_binary_data(file_name, value, size);read_binary_data(file_name, size);return 0;
}
119. accidental hiding
意外的函数隐藏(accidental hiding) 是 C++ 中一个容易让开发者困惑的现象,指的是 派生类中定义的名称(如成员函数、变量)无意中“遮盖”了基类中的同名名称,导致基类版本无法直接访问。这种现象与作用域规则相关,通常发生在继承和重载的场景中。
119.1 1. 核心概念
- 名称隐藏(Name Hiding):
当派生类中声明了与基类同名的名称(无论参数是否相同),基类的所有同名名称会被隐藏,除非显式用作用域运算符::或using声明引入。 - “意外”的含义:
开发者可能并未意识到派生类的定义会影响基类名称的可见性,尤其是当参数列表不同时(误以为会形成重载)。
119.2 2. 典型示例
示例 1:隐藏基类函数
struct Base {void foo() { cout << "Base::foo()" << endl; }void foo(int) { cout << "Base::foo(int)" << endl; } // 重载
};struct Derived : Base {void foo(double) { cout << "Derived::foo(double)" << endl; } // 隐藏基类所有 foo
};int main() {Derived d;d.foo(3.14); // 正确:调用 Derived::foo(double)d.foo(42); // 正确:调用 Derived::foo(double)(隐式转换 int→double)d.foo(); // 错误:Base::foo() 被隐藏!
}
- 发生了什么?
Derived::foo(double)隐藏了Base中的所有foo版本(包括foo()和foo(int)),即使参数不同。- 调用
d.foo()会报错,因为基类的foo被隐藏,编译器不会尝试在基类中查找。
示例 2:误以为会重载
struct Base {void bar(int) { cout << "Base::bar(int)" << endl; }
};struct Derived : Base {void bar(double) { cout << "Derived::bar(double)" << endl; } // 开发者可能想重载
};int main() {Derived d;d.bar(42); // 调用 Derived::bar(double),而非预期的 Base::bar(int)
}
- 开发者意图:可能希望
Derived::bar(double)和Base::bar(int)形成重载。 - 实际结果:基类的
bar(int)被隐藏,d.bar(42)调用派生类版本(int隐式转为double)。
119.3 3. 为什么 C++ 这样设计?
119.3.1 (1) 作用域规则一致性
- C++ 的名称查找遵循 作用域嵌套规则:内层作用域的名称会隐藏外层作用域的同名名称。
- 派生类的作用域嵌套在基类作用域内,因此派生类的名称自然隐藏基类的同名名称。
119.3.2 (2) 避免意外行为
- 如果允许派生类的名称自动与基类名称形成重载,基类后续新增的同名函数可能 silently 改变派生类的行为(违反“开闭原则”)。
- 强制开发者显式处理隐藏问题(通过
using或::),提高代码的明确性。
119.3.3 (3) 历史原因
- 早期 C++ 的名称查找规则较为简单,后续版本为保持兼容性未修改此行为。
119.4 4. 如何避免意外的函数隐藏?
119.4.1 方法 1:使用 using 声明引入基类名称
struct Base {void foo() { cout << "Base::foo()" << endl; }void foo(int) { cout << "Base::foo(int)" << endl; } // 重载void foo(double) { cout << "Base::foo(double)" << endl; } // 重载
};struct Derived : Base {using Base::foo; // 引入基类的所有 foo 重载// 隐藏基类中的同名同参版本void foo(double) { cout << "Derived::foo(double)" << endl; }
};int main()
{Derived d;d.foo(); // Base::foo()d.foo(42); // Base::foo(int)d.foo(3.14); // Derived::foo(double)
}
- 效果:基类和派生类的
foo形成重载集,按参数匹配最佳版本。 - 注意,如果此时派生类的某个版本和基类的版本参数相同,基类版本会被隐藏。
119.4.2 方法 2:显式用作用域运算符 ::
d.Base::foo(); // 显式调用基类的某个版本
119.4.3 方法 3:在派生类中手动转发
struct Derived : Base {void foo(double) { cout << "Derived::foo(double)" << endl; }void foo() { Base::foo(); } // 显式转发基类版本
};
119.5 5. 隐藏 vs. 重载 vs. 覆盖(重写)
| 概念 | 触发条件 | 效果 |
|---|---|---|
| 隐藏(hide) | 派生类定义同名名称(函数/变量) | 基类同名名称被隐藏,必须显式指定作用域访问 |
| 重载(overload) | 同一作用域中同名函数参数不同 | 编译器根据参数选择最佳匹配版本 |
| 覆盖/重写(override) | 派生类重写基类虚函数(参数相同) | 通过虚函数表动态绑定,运行时调用派生类版本 |
119.6 6. 总结
- 意外的函数隐藏是 C++ 作用域规则的直接结果,可能导致基类成员被无意遮盖。
- 关键点:
- 派生类的名称会隐藏基类的所有同名名称(即使参数不同)。
- 使用
using Base::name;可以显式引入基类名称,形成重载。 - 始终检查派生类是否意外隐藏了基类的重要接口。
- 设计哲学:C++ 倾向于让开发者显式表达意图,而非隐式处理潜在冲突。
120. 通过 gdb 调试理解 C++ 对象的内存布局
121. #pragma
pragma:希腊文,意思是“行动”,“实践”的意思,引申义有附注、标记,在这里就用来作为编译指示。
在 C++ 中,#pragma 是一种预处理指令(Preprocessor Directive),用于向编译器提供特定的控制命令或提示(影响编译器的行为)。它不属于标准 C++ 的核心语法,而是由编译器厂商扩展实现的,因此不同编译器可能支持不同的 #pragma 指令。
121.1 1. #pragma 的基本作用
#pragma 通常用于:
- 控制编译器行为(如优化、警告抑制)
- 设置特定功能(如内存对齐、代码段布局)
- 提供非标准扩展(如 OpenMP 并行编程)
121.2 2. 常见 #pragma 指令
121.2.1 (1) #pragma once
用途:防止头文件被重复包含(替代传统的 #ifndef 宏)。
示例:
// myheader.h
#pragma once
// 以下内容只会被包含一次
class MyClass { /* ... */ };
✅ 优点:
-
比传统方式更简洁:
#ifndef MYHEADER_H // 传统方式 #define MYHEADER_H /* ... */ #endif -
被大多数现代编译器支持(GCC、Clang、MSVC)。
121.2.2 (2) #pragma message
用途:在编译时输出自定义消息。
示例:
#pragma message("hello,world")
输出:
test.cpp:1:30: note: ‘#pragma message: hello,world’1 | #pragma message("hello,world")|
121.2.3 (3) #pragma warning(MSVC 特有)
用途:控制编译器的警告行为(常用于 Visual Studio)。
示例:
#pragma warning(disable : 4996) // 禁用 "不安全函数" 警告(如 scanf)
#pragma warning(push) // 保存当前警告状态
#pragma warning(pop) // 恢复之前的警告状态
121.2.4 (4) #pragma pack
用途:控制结构体的内存对齐方式(用于优化或兼容性)。
示例:
#pragma pack(push, 1) // 设置 1 字节对齐
struct node1 {char c; // 正常情况下可能对齐到 4 字节int i;
};#pragma pack(pop) // 恢复默认对齐
struct node2 {char c;int i;
};int main()
{cout << sizeof(node1) << endl; // 5cout << sizeof(node2) << endl; // 8return 0;
}
应用场景:
- 网络数据包解析(避免因对齐产生填充字节)。
- 硬件寄存器映射(确保精确的内存布局)。
121.2.5 (5) #pragma omp(OpenMP 并行编程)
用途:启用多线程并行计算(需编译器支持 OpenMP)。
示例:
#include <omp.h>
#pragma omp parallel for
for (int i = 0; i < 100; i++) {printf("Thread %d: i=%d\n", omp_get_thread_num(), i);
}
编译时需加参数:
bash
g++ -fopenmp mycode.cpp -o mycode
121.3 3. 编译器差异
| 指令 | GCC/Clang | MSVC (Visual Studio) | 说明 |
|---|---|---|---|
#pragma once |
✅ | ✅ | 通用 |
#pragma message |
✅ | ✅ | 通用 |
#pragma warning |
❌ | ✅ | MSVC 特有 |
#pragma GCC optimize |
✅ | ❌ | GCC 优化控制 |
#pragma clang optimize |
✅ | ❌ | Clang 优化控制 |
121.4 4. 注意事项
- 可移植性问题:
#pragma是编译器相关的,跨平台代码应谨慎使用。例如:- 用
#ifdef _MSC_VER包裹 MSVC 特有的指令。 - 用
#ifdef __GNUC__包裹 GCC/Clang 的指令。
- 用
- 替代方案:
- C++11 引入了
alignas和alignof替代#pragma pack的部分功能。 - 使用 CMake 或编译选项控制全局优化,而非
#pragma。
- C++11 引入了
122. auto 关键字作为返回值的演变
122.1 C++ 11:尾置返回类型
在 C++11 中,实际上并不能直接使用 auto 作为返回值,但引入了"尾置返回类型"(trailing return type)的语法,允许结合 auto 和 decltype 来推导返回类型:
auto func(int x, int y) -> decltype(x + y) {return x + y;
}
这种语法使用auto作为占位符,真正的返回类型在->后面指定。这特别适用于以下场景:
- 返回类型依赖于参数
- 返回类型非常复杂
- 模板编程中返回类型需要推导
122.2 C++14:纯 auto 返回类型推导
C++14 允许直接使用 auto 作为返回类型(无需尾置返回类型),并支持多返回语句的类型推导(但所有类型必须完全一致)。
122.2.1 特性:
- 直接使用
auto:无需尾置返回类型,编译器直接从return表达式推导类型。 - 多返回语句需类型一致:所有
return必须返回相同的具体类型(忽略顶层const和引用)。 - 支持
decltype(auto):用于完美转发返回类型(保留引用和值语义)。
122.2.2 示例:
// C++14: 直接使用 auto
auto foo(int x) {return x * 2; // 返回 int
}// 多返回语句(类型必须一致)
auto bar(bool flag) {if (flag) return 1; // intelse return 2; // int
}// decltype(auto): 保留引用语义
decltype(auto) baz(int& x) {return x; // 返回 int&
}
122.2.3 使用限制和注意事项:
即使在C++14中,使用auto作为返回类型也有一些限制:
- 所有的
return语句必须返回相同类型或可以转换为同一类型的值 - 必须有明确的
return语句供编译器分析(不能只有声明没有定义) - 不适用于递归函数,除非先有前向声明指定具体返回类型
- 对于类成员函数,定义必须在类定义内,否则需要使用尾置返回类型
122.3 C++17 auto 返回类型的进一步拓展
C++17 通过 结构化绑定(Structured Binding) 和 constexpr if 间接增强了 auto 返回类型的灵活性,但对函数返回类型的规则与 C++14 基本一致。
122.3.1 新增特性:
constexpr if支持多返回类型:通过编译时分支返回不同类型。- 结构化绑定:
auto可以用于返回元组或结构体的解构。
122.3.2 示例:
// C++17: constexpr if 模拟多返回类型(实际返回类型需一致)
template<bool flag>
auto foo() {if constexpr (flag) {return 1; // 编译时确定分支} else {return 2.0; // 错误:实际仍要求返回类型一致}
}// 结构化绑定 + auto 返回元组
auto get_tuple() {return std::make_tuple(1, "hello", 3.14);
}int main()
{auto x = foo<true>();auto y = foo<false>();cout << typeid(x).name() << endl; // icout << typeid(y).name() << endl; // d// 调用时解构auto [a, b, c] = get_tuple();return 0;
}
122.3.3 注意:
- 直接返回类型的规则与 C++14 相同(所有
return必须类型一致)。 - 通过元组或结构体包装可以实现“多类型返回”的效果。
123. Structured Bindings
C++17 进一步扩展了 auto 的功能,其中最显著的改进就是引入了结构化绑定(Structured Bindings)。结构化绑定允许你将复合类型(如元组、数组、结构体)的各个成员同时解包到独立的变量中。
123.1 结构化绑定的基本语法
结构化绑定使用 auto 关键字,语法为:
auto [变量1, 变量2, ...] = 表达式;
或者指定引用类型:
auto& [变量1, 变量2, ...] = 表达式;
const auto& [变量1, 变量2, ...] = 表达式;
123.2 结构化绑定的主要应用场景
123.2.1 (1) 使用元组(std::tuple)
#include <tuple>
#include <string>
#include <iostream>int main() {std::tuple<int, std::string, double> person{42, "John", 3.14};auto [id, name, value] = person;std::cout << "ID: " << id << ", Name: " << name<< ", Value: " << value << std::endl;
}
123.2.2 (2) 使用结构体/类
struct Point {int x;int y;
};int main() {Point p{10, 20};auto [x, y] = p; // x = 10, y = 20
}
123.2.3 (3) 使用数组
int main() {int arr[3] = {1, 2, 3};auto [a, b, c] = arr; // a = 1, b = 2, c = 3
}
123.2.4 (4) 配合 map 的迭代
#include <map>
#include <string>int main() {std::map<std::string, int> ages{{"Alice", 25}, {"Bob", 30}};for(const auto& [name, age] : ages) {// 使用 name 和 age}
}
123.2.5 (5) 处理函数返回多个值的情况
std::tuple<bool, std::string, int> process() {return {true, "Success", 42};
}int main() {auto [success, message, code] = process();// 直接获取返回的三个值
}
123.3 结构化绑定的工作原理
结构化绑定对不同类型的支持方式如下:
- 数组类型:直接按索引顺序绑定
- std::tuple 和 std::pair:使用
std::get<N>()获取元素 - 类/结构体:必须是所有成员都是 public 的聚合类型,按声明顺序绑定
123.4 注意事项和限制
- 绑定的变量数量必须与成员数量完全匹配
- 结构化绑定声明的变量在当前作用域内是新的变量
- 默认按值复制,可以使用
auto&或const auto&创建引用 - 不支持嵌套解包(C++20之前)
124. do-while 应用场景
do-while 的特性就是先执行一次,然后下一轮执行时再判断条件。在我自己写代码时很少用到 do-while,因此稍微整理了一些 do-while 在实际需求中的应用。
124.1 redis 过期数据的定时删除
redis 中有一个经典例子:过期数据的定期删除
具体算法执行逻辑:
- 从数据库中随机选取 20 个 key,并删除过期的 key
- 如果过期的 key 的数量超过 25%,也就是 4 个,那么下一轮将继续执行上面的删除过程
- 并且为了放置删除执行时间过长,还会设置一个执行时间上限
这个算法就非常适合 do-while,先无条件执行一次删除,然后如果过期的 key 的数量超过 25%,继续执行。
124.2 2. 输入验证
当需要至少执行一次输入验证时,比如用户登录系统。
do {userInput = getUserInput();validInput = validateInput(userInput);if (!validInput) {displayErrorMessage();}
} while (!validInput);
124.3 菜单驱动的应用
至少显示一次菜单,然后根据用户选择继续或退出。
int choice;
do {displayMenu();choice = getMenuChoice();processMenuChoice(choice);
} while (choice != EXIT_OPTION);
124.4 游戏循环
游戏至少需要运行一个帧,然后检查是否继续。
do {updateGameState();renderFrame();processInput();
} while (!gameOver);
124.5 资源分配尝试
在竞争资源的环境中,至少尝试一次获取资源。
bool resourceAcquired;
do {resourceAcquired = tryAcquireResource();if (!resourceAcquired) {Thread.Sleep(100); // 等待一段时间再重试}
} while (!resourceAcquired && !timeout);
124.6 数据解析
处理至少一个数据块,然后检查是否还有更多数据。
do {data_chunk = read_next_chunk()process_data(data_chunk)
} while data_available()
124.7 数据库连接重试
在网络不稳定环境中至少尝试一次连接。
int retries = 0;
boolean connected;
do {try {dbConnection = connectToDatabase();connected = true;} catch (ConnectionException e) {retries++;connected = false;wait(exponentialBackoff(retries));}
} while (!connected && retries < MAX_RETRIES);
124.8 事务处理
至少尝试一次事务,失败时根据策略重试。
bool transactionComplete;
do {beginTransaction();try {performOperations();commitTransaction();transactionComplete = true;} catch (Exception e) {rollbackTransaction();transactionComplete = shouldRetry(e);}
} while (!transactionComplete && retryCount < maxRetries);
125. 继承体系下同名函数成员的关系
继承体系下同名函数成员的关系:
-
重载(overload):函数根据参数(成员函数还可以根据
const和引用属性)的不同进行重载- 在同一作用域
- 函数名相同,参数列表不同
- 返回值类型可相同可不同
-
覆盖/重写(override/overwrite):类继承体系下,派生类覆盖基类的虚函数,此时我们可以显式指定
override关键字让编译器替我们检查我们覆盖的是否是虚函数- 不在同一作用域内(分别在父类和子类)
- 函数名相同,参数列表相同,返回值类型相同(协变除外)
- 基类函数必须有
virtual修饰 - 父子类中函数的访问修饰符可以不同
-
隐藏(hide):派生类定义了一个与基类同名的非虚函数,此时积累的所有同名函数被隐藏,我们无法在派生类中直接访问
- 在不同的作用域(分别在父类和子类)
- 函数名相同
- 基类函数没有
virtual修饰
在 C++ 中,协变(Covariance) 主要涉及 返回类型协变(Return Type Covariance),这是面向对象编程中多态性的一种特殊形式:
- 允许派生类的虚函数覆盖基类虚函数时,返回类型可以是基类函数返回类型的派生类。
- 仅适用于 指针或引用 类型(不能用于值类型)。
示例:
#include <iostream>class Base {
public:virtual Base* clone() const {std::cout << "Base::clone()\n";return new Base(*this);}virtual ~Base() = default;
};class Derived : public Base {
public:// 协变返回类型:返回 Derived* 而不是 Base*Derived* clone() const override {std::cout << "Derived::clone()\n";return new Derived(*this);}
};int main() {Base* b = new Derived();Base* b_clone = b->clone(); // 调用 Derived::clone(), 返回 Derived*delete b;delete b_clone;return 0;
}
126. companion class
伴随类是 C++ 中一种设计模式,它将一个类的非模板行为与模板行为分开实现,通常用于创建更灵活、更易于维护的库和框架。虽然这个概念在 C++ 标准中没有正式定义,但它在 C++ 库设计中被广泛使用。
126.1 基本概念
伴随类通常有以下几种形式:
- 模板类与非模板伴随类:一个模板类配合一个非模板的基类或辅助类,用于共享与模板参数无关的功能。
- 非模板类与模板伴随类:一个非模板类配合一个或多个模板扩展类,用于提供类型特定的功能。
- 特性类(Traits class):一种特殊的伴随类,用于提供关于其他类型的信息和行为。
优势:
- 代码复用:共享的功能只需实现一次
- 分离关注点:接口与实现的清晰分离
- 灵活性:通过不同的伴随类可以改变类的行为
- 优化机会:非模板部分可以预编译,减少编译时间
最佳实践:
- 将类型无关的代码移至非模板基类
- 使用伴随类来提供类型特定的行为和策略
- 考虑使用伴随类进行策略注入而非虚函数,可以获得更好的性能
- 利用特性类来表达类型之间的关系
伴随类是高级C++库设计中强大的工具,可以帮助创建灵活、易于维护且高效的代码。
126.2 常见用途
126.2.1 (1) 减少代码膨胀
将模板类中与类型无关的功能移至非模板基类,这样这些功能代码只会生成一次,从而缓解模板的代码膨胀问题;并且非模板基类可以预编译,减少模板实例化的编译时间。
// 非模板基类,包含与类型无关的共同功能
class ContainerBase {
protected:size_t size_;// 共享的实现细节void commonOperation() { /* ... */ }
};// 模板派生类
template <typename T>
class Container : public ContainerBase {
private:T* data_;
public:// 特定于类型的操作T& operator[](size_t index) { return data_[index]; }
};
126.2.2 (2) 提供元数据和类型特化
非模板类配合模板伴随类,从而提供类型特定的功能。
// 主模板
template <typename T>
struct TypeTraits {static constexpr bool is_numeric = false;
};// 针对int的特化
template <>
struct TypeTraits<int> {static constexpr bool is_numeric = true;static constexpr int max_value = INT_MAX;
};// 使用特性类
template <typename T>
void process(T value) {if (TypeTraits<T>::is_numeric) {// 进行数值操作} else {// 其他操作}
}
126.2.3 (3) 分离接口与实现
// 公共接口(非模板)
class Window {
public:virtual void draw() = 0;virtual ~Window() = default;// 共享的窗口操作
};// 平台特定实现(模板)
template <typename PlatformTraits>
class WindowImpl : public Window {
private:typename PlatformTraits::NativeHandle handle_;
public:void draw() override {// 类似工厂方法// 将特定平台的行为下放到具体的平台类PlatformTraits::draw(handle_);}
};// Windows平台的特性类
struct WindowsTraits {// 定义Windows平台特有的窗口句柄类型using NativeHandle = HWND; // Windows中的窗口句柄类型// 平台特定的绘制函数static void draw(NativeHandle handle) {// Windows特定的绘制实现}
};// Linux平台的特性类
struct LinuxTraits {// 定义Linux平台特有的窗口句柄类型using NativeHandle = Window; // X11中的窗口类型// 平台特定的绘制函数static void draw(NativeHandle handle) {// Linux特定的绘制实现}
};// 使用
using WindowsWindow = WindowImpl<WindowsTraits>;
using LinuxWindow = WindowImpl<LinuxTraits>;
126.3 标准库中的例子
126.3.1 (1) std::basic_string 和 std::char_traits
标准库的字符串不是简单的一个类,而是一个精心设计的类族:
std::basic_string<CharT, Traits, Allocator>是核心模板类std::char_traits<CharT>是伴随类,定义字符操作
我们可以看一下标准库源文件 <string> 的源码:
#if __cplusplus >= 201703L && _GLIBCXX_USE_CXX11_ABI
_GLIBCXX_BEGIN_NAMESPACE_VERSIONnamespace pmr{template <typename _Tp>class polymorphic_allocator;template <typename _CharT, typename _Traits = char_traits<_CharT>>using basic_string = std::basic_string<_CharT, _Traits,polymorphic_allocator<_CharT>>;using string = basic_string<char>;
#ifdef _GLIBCXX_USE_CHAR8_Tusing u8string = basic_string<char8_t>;
#endifusing u16string = basic_string<char16_t>;using u32string = basic_string<char32_t>;
#ifdef _GLIBCXX_USE_WCHAR_Tusing wstring = basic_string<wchar_t>;
#endif} // namespace pmr
// ...
其中,basic_string 是一个模板类,支持不同的字符类型(如 char、wchar_t、char16_t 等)和自定义分配器。而string 是 basic_string<char> 的别名,这是一种对用户友好的简写。
- 早期 C++(如 C++98)直接定义了
std::string作为basic_string<char>的实例化。 - C++11 引入别名声明(
using)后,标准库改用更现代的语法,但保持功能不变。
除了一些别名的定义,其中还有一个很关键的信息:
template <typename _CharT, typename _Traits = char_traits<_CharT>>
using basic_string = std::basic_string<_CharT, _Traits, polymorphic_allocator<_CharT>>;
这就很有意思了,我们可以发现 char_traits<_charT> 是作为 basic_sting 的一个默认模板参数,那这个模板参数有什么用呢?我们可以进一步看看 <basic_string.h> 的源代码:
template <typename _CharT, typename _Traits, typename _Alloc>
class basic_string
{
//...// Types:public:typedef _Traits traits_type;typedef typename _Traits::char_type value_type;
//...
};
这里是对 basic_string 类的部分摘录,主要看一下这里的两个 typedef,我们将模板类型参数 _Traits 重命名为 traits_type,进一步,我们可以看一下 traits_type 有什么作用:
template <typename _CharT, typename _Traits, typename _Alloc>
class basic_string
{
//...static void_S_move(_CharT *__d, const _CharT *__s, size_type __n){if (__n == 1)traits_type::assign(*__d, *__s);elsetraits_type::move(__d, __s, __n);}static void_S_assign(_CharT *__d, size_type __n, _CharT __c){if (__n == 1)traits_type::assign(*__d, __c);elsetraits_type::assign(__d, __n, __c);}// _S_copy_chars is a separate template to permit specialization// to optimize for the common case of pointers as iterators.template <class _Iterator>static void_S_copy_chars(_CharT *__p, _Iterator __k1, _Iterator __k2){for (; __k1 != __k2; ++__k1, (void)++__p)traits_type::assign(*__p, *__k1); // These types are off.}voidpush_back(_CharT __c){const size_type __size = this->size();if (__size + 1 > this->capacity())this->_M_mutate(__size, size_type(0), 0, size_type(1));traits_type::assign(this->_M_data()[__size], __c);this->_M_set_length(__size + 1);}
//...
};
可以发现,实际上 _Traits 其实就是提供了字符操作的策略,默认使用 std::char_traits<_CharT>。
既然如此,我们再看一看 char_traits<_charT> 的实现,在 char_traits.h 源文件中:
template<typename _CharT>
struct char_traits
{typedef _CharT char_type;typedef typename _Char_types<_CharT>::int_type int_type;typedef typename _Char_types<_CharT>::pos_type pos_type;typedef typename _Char_types<_CharT>::off_type off_type;typedef typename _Char_types<_CharT>::state_type state_type;#if __cpp_lib_three_way_comparisonusing comparison_category = std::strong_ordering;#endifstatic _GLIBCXX14_CONSTEXPR voidassign(char_type& __c1, const char_type& __c2){ __c1 = __c2; }static _GLIBCXX_CONSTEXPR booleq(const char_type& __c1, const char_type& __c2){ return __c1 == __c2; }static _GLIBCXX_CONSTEXPR boollt(const char_type& __c1, const char_type& __c2){ return __c1 < __c2; }static _GLIBCXX14_CONSTEXPR intcompare(const char_type* __s1, const char_type* __s2, std::size_t __n);static _GLIBCXX14_CONSTEXPR std::size_tlength(const char_type* __s);static _GLIBCXX14_CONSTEXPR const char_type*find(const char_type* __s, std::size_t __n, const char_type& __a);static _GLIBCXX20_CONSTEXPR char_type*move(char_type* __s1, const char_type* __s2, std::size_t __n);static _GLIBCXX20_CONSTEXPR char_type*copy(char_type* __s1, const char_type* __s2, std::size_t __n);static _GLIBCXX20_CONSTEXPR char_type*assign(char_type* __s, std::size_t __n, char_type __a);static _GLIBCXX_CONSTEXPR char_typeto_char_type(const int_type& __c){ return static_cast<char_type>(__c); }static _GLIBCXX_CONSTEXPR int_typeto_int_type(const char_type& __c){ return static_cast<int_type>(__c); }static _GLIBCXX_CONSTEXPR booleq_int_type(const int_type& __c1, const int_type& __c2){ return __c1 == __c2; }static _GLIBCXX_CONSTEXPR int_typeeof(){ return static_cast<int_type>(_GLIBCXX_STDIO_EOF); }static _GLIBCXX_CONSTEXPR int_typenot_eof(const int_type& __c){ return !eq_int_type(__c, eof()) ? __c : to_int_type(char_type()); }
};
//...
} // namespace
可以发现,char_traits 以 _charT 作为模板参数,并且将其 typedef 为 char_type,后面提供了很多操作字符的函数,字符类型都是 _charT。
126.3.2 (2) 容器与分配器
设计概述:
std::vector<T, Allocator>是模板容器std::allocator<T>是伴随类,负责内存管理
伴随类的作用:allocator 将内存分配与对象构造分离
// 标准分配器的简化版本
template <typename T>
class allocator {
public:using value_type = T;using pointer = T*;using size_type = std::size_t;// 分配未初始化的内存pointer allocate(size_type n) {return static_cast<pointer>(::operator new(n * sizeof(T)));}// 释放内存,但不销毁对象void deallocate(pointer p, size_type n) {::operator delete(p);}// 在已分配内存上构造对象template <typename... Args>void construct(pointer p, Args&&... args) {::new(static_cast<void*>(p)) T(std::forward<Args>(args)...);}// 销毁对象但不释放内存void destroy(pointer p) {p->~T();}
};
vector 使用分配器进行内存管理:
template <typename T, typename Allocator = std::allocator<T>>
class vector {
private:T* data_;size_t size_;size_t capacity_;Allocator alloc_; // 分配器实例public:// 使用分配器增加容量void reserve(size_t new_cap) {if (new_cap <= capacity_) return;// 使用分配器分配内存T* new_data = alloc_.allocate(new_cap);// 移动现有元素for (size_t i = 0; i < size_; ++i) {alloc_.construct(new_data + i, std::move(data_[i]));alloc_.destroy(data_ + i);}// 释放旧内存alloc_.deallocate(data_, capacity_);data_ = new_data;capacity_ = new_cap;}// ...其他使用分配器的方法
};
优势:
- 内存管理策略解耦:容器专注于数据结构,分配器负责内存管理
- 自定义内存分配:用户可以提供特殊的分配器(如池分配器、共享内存分配器)
- 类型无关的算法优化:可以优化特定类型的内存分配
126.3.3 (3) std::iterator_traits
设计概述:
std::iterator_traits是一个元编程工具,用于提取迭代器的特性。
伴随类的作用:iterator_traits 提供统一的方式访问迭代器的属性:
// 主模板定义
template <typename Iterator>
struct iterator_traits {using value_type = typename Iterator::value_type;using pointer = typename Iterator::pointer;using reference = typename Iterator::reference;using difference_type = typename Iterator::difference_type;using iterator_category = typename Iterator::iterator_category;
};// 针对指针类型的特化
template <typename T>
struct iterator_traits<T*> {using value_type = T;using pointer = T*;using reference = T&;using difference_type = ptrdiff_t;using iterator_category = std::random_access_iterator_tag;
};// 使用示例 - 计算距离的算法
template <typename Iterator>
typename std::iterator_traits<Iterator>::difference_type
distance(Iterator first, Iterator last) {using category = typename std::iterator_traits<Iterator>::iterator_category;// 根据迭代器类别选择不同的实现if constexpr (std::is_same_v<category, std::random_access_iterator_tag>) {// O(1) 实现return last - first;} else {// O(n) 实现typename std::iterator_traits<Iterator>::difference_type n = 0;while (first != last) {++first;++n;}return n;}
}
优势:
- 统一接口:为所有迭代器提供统一的属性访问方式
- 类型萃取:从迭代器中提取相关类型信息
- 算法优化:根据迭代器类别选择最佳算法实现
- 指针兼容性:通过特化支持原生指针作为迭代器
127. std::is_same
127.1 介绍
std::is_same 是 C++11 引入的类型特性(type trait),它是一个模板类,用于编译时判断两个类型是否完全相同。
基本用法:
template <class T, class U>
struct is_same;
std::is_same提供了一个静态常量成员value,当 T 和 U 是相同类型时,value的值为true;否则为false。
使用示例:
#include <type_traits>
#include <iostream>int main()
{bool result1 = std::is_same<int, int>::value; // truebool result2 = std::is_same<int, double>::value; // falsebool result3 = std::is_same<int, const int>::value; // falsestd::cout << boolalpha << endl;std::cout << "int 和 int 是否相同: " << result1 << std::endl;std::cout << "int 和 double 是否相同: " << result2 << std::endl;std::cout << "int 和 const int 是否相同: " << result3 << std::endl;
}
std::is_same_v 是 C++17 引入的变量模板,是对 std::is_same 的封装,提供了更简洁的语法。
template <class T, class U>
inline constexpr bool is_same_v = is_same<T, U>::value;
使用示例:
#include <type_traits>
#include <iostream>int main() {bool result1 = std::is_same_v<int, int>; // truebool result2 = std::is_same_v<int, double>; // falsebool result3 = std::is_same_v<int, const int>; // falsestd::cout << "int 和 int 是否相同: " << result1 << std::endl;std::cout << "int 和 double 是否相同: " << result2 << std::endl;std::cout << "int 和 const int 是否相同: " << result3 << std::endl;
}
std::is_same_v实际上是对std::is_same的语法糖。
127.2 在实际开发中的应用
127.2.1 (1) 条件编译(特别是与 if constexpr 结合)
template <typename T>
void process(T value) {if constexpr (std::is_same_v<T, int>) {// 整数专用处理std::cout << "处理整数: " << value << std::endl;} else if constexpr (std::is_same_v<T, std::string>) {// 字符串专用处理std::cout << "处理字符串: " << value << std::endl;} else {// 默认处理std::cout << "处理其他类型" << std::endl;}
}template <typename Iterator>
typename std::iterator_traits<Iterator>::difference_type
distance(Iterator first, Iterator last) {using category = typename std::iterator_traits<Iterator>::iterator_category;if constexpr (std::is_same_v<category, std::random_access_iterator_tag>) {// 随机访问迭代器可以直接相减,O(1) 实现return last - first;} else {// 其他迭代器需要逐个遍历,O(n) 实现typename std::iterator_traits<Iterator>::difference_type n = 0;while (first != last) {++first;++n;}return n;}
}
127.2.2 (2) SFINAE (替换失败不是错误) 和模板特化
template <typename T,typename = std::enable_if_t<!std::is_same_v<T, void>>>
void foo(T t) {std::cout << "非void类型" << std::endl;
}template <typename T,typename = std::enable_if_t<std::is_same_v<T, void>>>
void foo() {std::cout << "void类型" << std::endl;
}int main()
{foo("hello");// 注意下面要显示指定 void,因为当参数为空时,T 不存在foo<void>();return 0;
}
128. std::partial_sort 时间复杂度
128.1 结论先行
std::partial_sort 的时间复杂度通常为 O(N log K),其中:
- N 是序列中元素的总数。
- K 是你要部分排序的元素个数(即你要排序到正确位置的前 K 个最小元素)。
在最坏情况下,其复杂度也是 O(N log K)。这比对整个序列进行完整排序 std::sort(复杂度为 O(N log N))要高效得多,尤其是在 K << N(K 远小于 N)的情况下。
128.2 详细解释和工作原理
要理解为什么是 O(N log K),我们需要了解 std::partial_sort 通常是如何实现的。其核心算法是 堆排序(Heap Sort) 的一个变体。
它主要分为两个阶段:
128.2.1 阶段一:构建一个最大堆(Max-Heap) — O(K)
- 首先,算法会取序列中的前 K 个元素。
- 然后,将这 K 个元素构建成一个最大堆(Max-Heap)。
- 最大堆的特点是:堆顶(第一个元素)是整个堆中最大的元素。
- 构建一个包含 K 个元素的最大堆的时间复杂度是 O(K)。
此时,这个堆包含了目前找到的最小的 K 个元素中的最大值(即堆顶)。
128.2.2 阶段二:遍历剩余元素并维护堆 — O((N - K) * log K)
- 接着,算法开始遍历序列中剩下的 N - K 个元素(从第
K+1个到最后一个)。 - 对于每一个剩余元素
elem,将其与当前最大堆的堆顶元素(也就是当前已知的第 K 小的元素)进行比较:- 如果
elem>= 堆顶元素:说明它肯定不在最小的 K 个元素之列,直接忽略它。操作时间为 O(1)。 - 如果
elem< 堆顶元素:说明它应该进入“最小的 K 个元素”俱乐部。- 将堆顶元素(当前最大的成员)踢出俱乐部,用
elem替换它。 - 由于堆的结构被破坏了,需要执行一次
max_heapify操作来重新维护这个最大堆的性质,确保堆顶仍然是新的最大值。这个操作的时间复杂度是 O(log K)。
- 将堆顶元素(当前最大的成员)踢出俱乐部,用
- 如果
对于最坏的情况,我们需要对剩下的 N-K 个元素中的每一个都执行一次堆调整(O(log K)),所以第二阶段的最坏情况时间复杂度是 O((N - K) * log K)。
128.2.3 阶段三:对前 K 个元素进行排序 — O(K log K)
- 经过前两个阶段,序列的前 K 个位置(即我们维护的那个堆)现在确实包含了整个序列中最小的 K 个元素。
- 但是,这 K 个元素在堆中只是部分有序(满足堆的性质,但不是完全排序的)。所以,最后一步需要对这 K 个元素进行排序。
- 通过对最大堆执行 K 次
pop操作(每次取出当前最大值并调整堆)可以完成排序。每次pop操作的时间是 O(log K),因此对整个堆进行排序的时间复杂度是 O(K log K)。
128.3 复杂度汇总
现在我们将三个阶段的复杂度相加:
- 建堆:O(K)
- 遍历和维护堆:O((N - K) * log K)
- 对堆排序:O(K log K)
由于通常 K << N(K 远小于 N),O((N - K) * log K) 是主导项。并且在大 O 表示法中,我们忽略低阶项和常数系数,所以总的时间复杂度为:
O(N log K)
128.4 一个简单的类比
你可以把 std::partial_sort 想象成一场比赛:
- 你先随机找 K 个人作为“暂时的前 K 名”(建堆)。
- 然后让剩下的所有人依次来挑战。
- 如果来的人比你这 K 个人里最差的那个(堆顶)还要差,就让他滚蛋。
- 如果他比最差的那个好,就把最差的淘汰,让他加入,然后重新选出新的“最差”成员(调整堆)。
- 最后,等所有人都挑战完了,你手里的 K 个人就是最强的 K 个人。但你不知道他们内部的排名,所以最后你需要再给他们排个名次(对前 K 个排序)。
128.5 总结
| 特性 | 说明 |
|---|---|
| 时间复杂度 | O(N log K) |
| 空间复杂度 | O(1) (原地操作,仅使用常数级别的额外空间) |
| 适用场景 | 当你只需要序列中最小(或最大)的 K 个元素,并且要求这 K 个元素是有序的时。例如,获取排行榜前10名。 |
对比 std::nth_element |
std::nth_element 只保证第 K 个元素的位置正确,它两边的元素无序(但一边全小于,一边全大于)。它的复杂度是 O(N),更快,但结果无序。 |
对比 std::sort |
std::sort 对整个序列排序,复杂度为 O(N log N)。当 K 很小时,std::partial_sort 优势巨大。当 K 接近 N 时,不如直接使用 std::sort。 |
需要注意的是,构建最大堆的过程是原地构建,因此空间复杂度是 \(O(1)\)。
129. built-in pointer type
注意关于内置指针类型,强调的是指针,而不是指针所指向的对象。也就是说,对于 T *obj; 无论 T 是什么类型,obj 的类型都是一个内置指针类型。
- 换言之,对于一个声明,只要我们显式带
*,它就是一个内置指针; - 反之,如果不带
*声明,但仍然可以对该对象执行*和->,那我们说它具有了指针的语义,可以当作一个指针使用,但本质上并不是一个内置指针,我们一般称其为类指针。
class A {
public:A() { x = 1; }void action() { cout << "Action in class A!" << endl; }public:int x;
};class B {
public:B() { x = 2; }A* operator->() { return &a; }void action() { cout << "Action in class B!" << endl; }
private:int x;A a;
};class C {
public:C () { x = 3; }B operator->() { return b; }void action() { cout << "Action in class C!" << endl; }
private:int x;B b;
};int main(int argc, char **argv)
{C *pc = new C;pc->action(); // Action in class C!// 这个调用会被当作内置指针类型的箭头运算符调用// 相当于 (*pc).action();C c;c->action(); // Action in class A!// 由于 c 不是内置指针类型,会调用重载了的 operator->()// 相当于 c.operator->()->action// 根据 C::operator->() 的定义// 相当于 b->action,其中 b 不是内置指针类型// 根据 B::operator->()的定义// 相当于 a->action,其中 a 是内置指针类型// 故最终解析为 (*a).action()int x = c->x; // 1// 和 c->action() 的分析一样// 这里最终解析为 (*a).x;cout << x << endl;return 0;
}
在上面的例子中,class B 和 class C 都可以作为指针使用,但如果我们是 C *pc,那么 pc 是一个内置指针对象;如果我们是 C c,c 就是一个类指针对象。
上面例子同样说明了 operator-> 的解析规则,它会递归解析直到遇到一个内置类型指针然后转换为 (*T).mem。
130. user-define conversion 优先级相同
在 C++ 中,当调用重载函数时,如果多个用户定义的类型转换(即自定义的转换构造函数或类型转换运算符)都能提供可行的匹配,编译器会认为这些转换是“一样好”的,从而引发二义性错误。此时,编译器不会进一步考虑标准类型转换的级别(如 int → double 的算术转换、派生类到基类的转换等)来打破平局。
130.1 1. 关键规则回顾
- 用户定义的类型转换优先级相同
- 如果多个用户定义的类型转换(如
A::A(const B&)和B::operator A())都能匹配,它们的优先级被视为完全相同,编译器不会尝试通过标准类型转换的规则来选择“更好”的匹配。 - 这与内置类型之间的标准转换(如
int→long的提升)不同,后者有明确的优先级。
- 如果多个用户定义的类型转换(如
- 二义性必然导致编译错误
- 如果编译器发现多个“一样好”的用户定义转换路径,它会直接报错,而不是随机选择其中一个。
- 标准类型转换在用户定义转换之后考虑
- 只有在用户定义转换完成之后,才会考虑标准类型转换(如参数类型调整)。但如果在用户定义转换阶段已经存在二义性,编译器不会继续分析后续的标准转换。
130.2 2. 代码示例分析
struct B;struct A {A() = default;A(const B&) { cout << "A::A(const B&)" << endl; } // 用户定义隐式类型转(单参数构造函数)
};struct B {operator A() const { // 用户定义显式类型转换cout << "B::operator A()" << endl;return A();}
};void func(A a) {}int main()
{B b;func(b); // 二义性错误!return 0;
}
注意,用户除了可以自定义显式类型转换意外,单参数的非 explicit 构造函数(single-argument non-explicit constructor)也是一种用户定义类型转换,这一点常常被忽略:
- 转换路径 1:
B→B::operator A()→A。 - 转换路径 2:
B→A::A(const B&)→A。 - 两条路径都是用户定义的转换,优先级相同,因此编译器报错。
130.3 3. 对比:标准类型转换的优先级
如果是内置类型或标准转换,编译器会优先选择更优的匹配:
void foo(int);
void foo(double);foo(42); // 调用 foo(int),因为 int → int 比 int → double 更优
但用户定义转换没有这种优先级区分!
130.4 4. 如何解决二义性?
130.4.1 方法 1:显式指定转换路径
func(A(b)); // 强制调用 A 的构造函数
func(b.operator A()); // 强制调用 B 的转换运算符
130.4.2 方法 2:禁用其中一个转换(C++11 起)
explicit 可以用于显式的和隐式的类型转换:
// 禁用 B::operator A() 的隐式调用
explicit operator A() const { ... }// 或禁用 A::A(const B&) 的隐式调用
explicit A(const B&) { ... }
- 此时
func(b)只能选择剩下的非explicit转换。
130.4.3 方法 3:使用 static_cast
func(static_cast<A>(b)); // 优先尝试 A::A(const B&)
130.5 5.总结
| 场景 | 结果 |
|---|---|
| 多个用户定义转换可行 | 二义性错误 |
| 用户定义转换 + 标准转换可行 | 优先用户定义转换 |
| 仅标准转换可行 | 按标准转换优先级选择 |
始终记住:用户定义转换的优先级是“平局”,必须通过显式方式解决冲突!
131. 删除析构函数
删除析构函数是一种 极端控制手段,通常用于特殊场景,例如:
131.1 1. 禁止栈分配(强制使用 new 堆分配)
析构函数被删除意味着我们无法创建此类型的临时对象(内存分配在栈上)。因此,如果类必须在堆上分配(例如某些资源管理类),可以 删除析构函数,防止用户直接创建栈对象。此外,为了避免内存泄漏,需要专门写一个用于内存回收的函数:
class HeapOnly {
public:static HeapOnly* create() { return new HeapOnly(); } // 必须通过工厂方法创建void destroy() { delete this; } // 必须手动调用销毁private:HeapOnly() = default;~HeapOnly() = delete; // ❌ 禁止栈自动执行对象析构
};int main() {// HeapOnly obj; // ❌ 错误:析构函数被删除,无法栈分配HeapOnly* p = HeapOnly::create(); // ✅ 只能堆分配p->destroy(); // ✅ 必须手动释放return 0;
}
适用场景:
- 某些底层库(如自定义内存池、硬件资源管理)。
- 需要精确控制对象生命周期的场景。
131.2 2. 单例模式(防止用户手动析构)
如果单例对象必须由程序生命周期管理(如全局日志系统),可以 删除析构函数,防止用户误删:
class Singleton {
public:static Singleton& get() {static Singleton instance; // 全局唯一实例return instance;}void log(const std::string& msg) { /* ... */ }~Singleton() = delete; // ❌ 禁止手动析构private:Singleton() = default;Singleton(const Singleton&) = default;//Singleton(Singleton&&) = default;
};int main() {Singleton::get().log("Hello");// Singleton::get().~Singleton(); // ❌ 错误:析构函数被删除return 0;
}
适用场景:
- 全局管理器(如日志系统、配置管理器)。
131.3 3. 接口类(子类实现析构)
如果基类是纯接口(无实际数据),可以 删除析构函数,强制子类实现自己的析构逻辑:
class Interface {
public:virtual void doSomething() = 0;// 声明为 protected 允许派生类销毁,阻止外部直接实例化
protected:virtual ~Interface() = default;
};class Impl : public Interface {
public:void doSomething() override { /* ... */ }// 不需要显式定义析构函数(除非需要特殊处理)
};int main() {// Interface obj; // ❌ 错误:抽象类(因为有纯虚函数)Impl impl; // ✅ 正确return 0; // ✅ 自动调用 ~Impl() 和 ~Interface()
}
适用场景:
- 纯虚接口类(如插件系统)。
131.4 4. 关键注意事项
- 内存泄漏风险
- 如果析构函数被删除,必须提供 替代的清理机制(如
destroy()方法)。
- 如果析构函数被删除,必须提供 替代的清理机制(如
- 与 RAII 冲突
- C++ 的 RAII(资源获取即初始化)依赖析构函数,删除它会破坏自动资源管理。
- 谨慎使用
- 99% 的情况下 不应删除析构函数,除非有非常特殊的需求。
最佳实践:除非你在写底层库或极端优化,否则 不要随意删除析构函数。大多数情况下,可以通过 私有析构函数 + 工厂模式 实现类似效果,同时保持代码安全。
132. 抽象类可以定义纯虚函数
An abstract class is a class that either defines or inherits at least one function for which the final overrider is pure virtual.
抽象类(Abstract Class)是 C++ 中一种特殊的类,它不能被直接实例化(即不能创建该类的独立对象),其主要目的是作为基类定义接口规范,强制派生类实现特定的行为。
抽象类的本质特征是:
- 包含(继承/声明)至少一个纯虚函数(
virtual func() = 0)。 - 不能直接实例化(即不能创建该类的独立对象)。
比较罕见的是,我们可以在类外定义抽象类的纯虚函数,并且如果我们为纯虚函数提供了定义,派生类还可以调用此定义。但无论是否给纯虚函数提供实现,只要存在 = 0 的声明,该类就是抽象类,这意味着我们仍然无法为定义了纯虚函数的抽象类定义一个类对象。
class Graph {
public:virtual void draw() = 0;
};
void Graph::draw() { cout << "Graph::draw" << endl; }class Circle : public Graph {
public:virtual void draw() override { Graph::draw(); }
};int main()
{Circle c;c.draw(); // Graph::drawGraph g; // error: cannot declare variable ‘g’ to be of abstract type ‘Graph’return 0;
}
133. 计算机语言的分类
参考资料:
- 作者:Spongecaptain
- 链接:https://www.zhihu.com/question/414256612/answer/1411098018
133.1 1. 编译、解释、混合型
133.1.1 定义
- 编译型:需通过编译器(compiler)将源代码编译成机器码,然后链接为可执行文件。
- 编译的工作流程大致为:源代码 → 词法分析 → 语法分析 → 语义分析 → 目标代码(编译) → 链接 → 可执行文件,其中:
- 编译:把源代码编译成机器码;
- 链接:把各个模块的机器码和依赖库串连起来生成可执行文件;
- 解释型:解释性语言的程序不需要编译,相比编译型语言省了道工序,解释性语言在运行程序的时候才逐行翻译。
- 混合型:编译器将源码编译成中间码而不再是二进制机器码,然后中间码需要被即时编译器翻译成目标平台的本地代码;
133.1.2 分类
- 编译型语言:C、C++、Object-C、swift、Java;
- 解释型语言:JavaScript、Python、PHP;
- 混合型语言:C#;
133.1.3 局限性
这种方式的分类是被认为是不科学的,因为很多语言既可以认为是解释型、也可以认为是编译型,这种分类方式被指出是不科学的,见于:RednaxelaFX 在 https://www.iteye.com/blog/rednaxelafx-492667 中提到的:我是倾向于避开把编程语言描述为“编译型”或者“解释性”的。
133.1.4 详细地,下面以 Java 和 Python 为例子来解释这个问题
Java 是这样从源码到被执行的(大致地~):
- Java 源代码 -> javac(java compiler) 将其转为字节码(二进制码)->虚拟机中执行。
Java 按这种分类方式难以分类的原因就如上所示,首先编译其次在虚拟机中解释执行。为何说后者是解释?因为传统上我们认为从字节码到对应平台的机器码需要不同平台上的 JVM 提供支持,我们认为这个动作就是解释。
这样一来 Java 就难以按照这个分类方式进行分类了。实际上,我还是倾向于将 Java 称之为编译型语言,因为完全可以将 JVM 看做底层实现。这里粗粒度不宜过细,因为本质上说机器码被 CPU 接收然后运行,其中也涉及一段解释的过程。如此一来,世上只有解释型语言。
Python 虽然被普遍认为一门解释型语言,按理说应当不涉及编译过程。事实上,Python 解释器会将源代码转换为字节码,然后再由 Python 解释器来执行这些字节码。本质上,Python 解释器不就是完成了编译器+执行器这个模块的任务,既然含有编译过程,那么其被称为解释型语言就具有一定不合理性。
RednaxelaFX 大所认为的:语言一般只会定义其抽象语义,而不会强制性要求采用某种实现方式。而编译、解释只是实现方式的一个步骤或者方式,按这种分类是不合理的。
下面是 Claude 3.7 Sonnet 给出的回答:
(1) 编译型语言
编译型语言是指在程序运行前,源代码需要通过编译器转换成机器码(二进制代码)的语言。整个转换过程被称为"编译"。
特点:
- 一次编译,多次运行
- 执行速度快(因为已转换为计算机可直接执行的机器码)
- 脱离编译环境后仍可执行
- 跨平台性较差(通常需要针对不同平台重新编译)
- 开发调试周期较长(修改后需要重新编译)
典型例子: C、C++、Go、Rust、Fortran
(2) 解释型语言
解释型语言是指在程序运行时,代码逐行由解释器分析并执行的语言,不需要事先将整个程序转换为机器码。
特点:
- 一边解释,一边执行
- 执行速度相对较慢(因为运行时需要解释)
- 依赖解释器环境
- 跨平台性好(只要有对应平台的解释器即可运行)
- 开发调试周期短(修改后无需重新编译)
典型例子: Python、JavaScript、Ruby、PHP
(3) 混合型语言
现代编程语言往往采用混合方式:
- Java、C#:先编译成中间字节码,再由虚拟机执行(JVM、CLR),有些还使用JIT编译提升性能
- JavaScript:现代浏览器使用JIT编译技术优化执行
因此,编译型和解释型的界限已经变得越来越模糊,很多语言采用了混合策略来平衡开发效率和运行性能。
133.2 2. AOT and JIT
AOT(Ahead-of-Time)编译是指在程序执行前将源代码全部编译成机器码的编译方式。与之相对的是 JIT(Just-In-Time)编译,后者在程序运行过程中动态地将代码编译成机器码。
133.2.1 AOT 编译和 JIT 编译的主要区别:
- AOT编译:
- 在程序运行前完成全部编译
- 执行速度通常更快
- 启动时间更短
- 不需要运行时编译消耗资源
- 例如:C、C++、Rust 等语言的典型编译方式
- JIT编译:
- 在程序运行时动态编译
- 可以根据运行时信息进行优化
- 启动较慢但长时间运行可能更优化
- 例如:Java、C# 等语言的虚拟机实现
133.2.2 2.2 Why JIT?
JIT 编译虽然看起来不如 AOT 编译,但它有一些独特的优势,这就是为什么它存在并被广泛使用:
- 优化能力更强
- JIT 编译可以根据实际运行时的信息进行更精确的优化
- 例如,它知道哪些代码路径被频繁使用,可以重点优化这些"热点"代码
- 可以根据实际数据类型优化,而不是假设所有可能性
- 平台独立性
- 同一份中间代码可以在不同平台上运行,由各平台的JIT 编译器负责生成本地机器码
- 开发者只需维护一份代码,"一次编写,到处运行"
- 动态特性支持
- 支持动态类型、动态加载、反射等高级语言特性
- 能够处理运行时才能确定的代码结构
- 内存使用效率
- 只为实际执行的代码路径生成机器码,节省空间
- 可以丢弃不常用的编译结果,动态管理内存
- 适应变化
- 对于网络加载的代码或插件系统,JIT 可以动态处理新加入的代码
- 在长时间运行的服务中,可以根据变化的工作负载特性调整优化策略
举个实际例子:Java 虚拟机的 JIT 编译器会监控代码运行,发现某个函数被频繁调用时,会将其编译成高度优化的机器码。如果这个函数内部大多数时间走的是某个特定分支,JIT 可以专门针对这个分支进行优化,而 AOT 编译器必须为所有可能的分支生成代码。
因此,对于需要高度动态性、跨平台能力和长时间运行中持续优化的场景,JIT 编译往往是更好的选择。而对于嵌入式系统、启动性能要求高或资源受限的环境,AOT 编译则更有优势。
133.2.3 2.3 Python的情况
Python 是一种解释型语言,但其执行过程实际上包含了一个中间步骤:
- 首先将源代码编译成字节码(bytecode,.pyc文件)
- 然后由 Python 虚拟机解释执行这些字节码
这使得 Python 既有解释型语言的特点,又采用了一定的编译优化。
Python(特别是其标准实现 CPython)既不是 AOT 编译也不是 JIT 编译,而是一种解释型语言。
Python 的执行过程通常是:
- 将源代码编译成字节码(bytecode)
- 由 Python 虚拟机(PVM)解释执行这些字节码
这个过程与 AOT 和 JIT 都不同:
- 与 AOT 不同:Python 不会提前将代码编译成平台特定的机器码
- 与 JIT 不同:标准 Python 也不会在运行时动态地将字节码编译成机器码
当然,Python 生态系统中确实有一些变种实现采用了这些编译技术:
- PyPy:使用了 JIT 编译技术的 Python 实现
- Nuitka:尝试将 Python 代码 AOT 编译成 C++ 代码再编译成机器码
- Cython:将 Python 代码转换为 C 并编译成机器码
但从标准实现(CPython)的角度来说,Python 主要是一种解释型语言。
133.3 3. 动态类型、静态类型
定义
这种分类方式关注点是类型确定的时机,以运行开始为分类的时间节点。
- 动态类型语言(Dynamic Typing):含义是类型只有在程序运行期间才能确定;
- 静态类型语言(Static Typing):在程序运行前类型已知且会进行类型安全检查。这一步通常被语言解释器完成。
分类
- 动态类型语言:Python、Ruby、JavaScript;
- 静态类型语言:C、C++、C#、Java、Go;
以 Python 为例来说明动态类型语言所具备的特性:
# coding: utf-8
def sum(a, b):return a + b;print sum(1,2);
print sum("Hello ", "Word")
运行结果自然是:
> 3
> Hello Word
133.4 强类型、弱类型
定义
学术层面上对此分类方式有争议,不过粗浅地可以认为是如下这般:
- 强类型:偏向于不容忍隐式类型转换。譬如说 haskell 的 int 就不能变成 double;
- 弱类型:偏向于容忍隐式类型转换。譬如说 C 语言的int可以变成double;
分类
- 强类型:Java、Python、Go;
- 弱类型:Javascript、C、C++;
实际上,如果不搞编程界的学术,不必执拗于一本编程语法书上告知你的该语言属于何种类型,如果你遍历地学习了某种语言,实际上已经知道语言具有那种语法特性。分类只是为了更好的抽象,而你已经掌握了具体,况且学术界对这类抽象没有准确的定义。
一个经典的编程语言动静态类型、强弱分类图如下:

134. ODR
ODR(One Definition Rule,单一定义规则)是 C++ 的核心规则之一,它规定:
- 任何变量、函数、类模板、枚举等,在同一个程序(整个可执行文件)中只能有一个定义。
如果违反这条规则,就会导致 ODR 违规(ODR Violation),通常表现为 链接错误(Linker Error)。
135. #define NDEBUG
135.1 程序的 Debug 和 Release 版本
程序的 Debug 和 Release 版本是软件开发的两种基本构建配置,它们针对不同的目的进行了优化。
让我用一个简单的比喻开始:
- Debug 版本:像是一个建筑工地——有脚手架、安全网、监控摄像头,一切都是为了建设和调试
- Release 版本:像是完工的大楼——干净、高效、优化,准备交付给用户使用
135.1.1 🔧 Debug 版本 (调试版本)
主要特点:
- 包含调试信息:包含符号表、行号信息等,方便调试器使用
- 禁用优化:编译器优化被关闭,代码执行顺序与源代码一致
- 启用断言:
assert()宏有效,用于捕捉程序错误 - 包含调试代码:可能有额外的日志输出、检查代码
用途:
// Debug版本中有效的调试代码
#define DEBUG_MODE
#ifdef DEBUG_MODEstd::cout << "Debug: Entering function XYZ" << std::endl;
#endif
编译示例(GCC):
g++ -g -O0 -DDEBUG -o program_debug program.cpp
-g:生成调试信息-O0:禁用优化-DDEBUG:定义调试宏
135.1.2 🚀 Release 版本 (发布版本)
主要特点:
- 高度优化:编译器进行激进优化,提高运行速度,减小体积
- 移除调试信息:不包含调试符号,文件更小
- 禁用断言:通过
-DNDEBUG禁用assert()宏 - 移除调试代码:预处理移除调试专用的代码段
用途:
// Release版本中这些代码会被移除
#ifdef DEBUG_MODE// 这整段代码在Release中不存在log_debug_information();
#endif
编译示例(GCC):
g++ -O3 -DNDEBUG -o program_release program.cpp
-O3:最高级别优化-DNDEBUG:禁用断言
135.1.3 📊 对比表格
| 特性 | Debug 版本 | Release 版本 |
|---|---|---|
| 编译优化 | 无优化 (-O0) |
高度优化 (-O2/-O3) |
| 调试信息 | 包含 (-g) |
不包含 |
| 断言检查 | 启用 | 禁用 (-DNDEBUG) |
| 运行速度 | 慢 | 快 |
| 文件大小 | 大 | 小 |
| 错误信息 | 详细(含行号) | 简略 |
| 适用场景 | 开发、测试 | 生产环境 |
135.1.4 💡 为什么需要两种版本?
- 开发阶段:使用Debug版本便于调试、查找bug
- 测试阶段:使用Debug版本进行详细测试
- 发布阶段:使用Release版本获得最佳性能
- 维护阶段:遇到问题时可以用Debug版本重现和修复
现代IDE(如Visual Studio、CLion、Xcode)都提供了方便的方式来切换这两种构建配置。
理解这两种版本的区别对于成为专业的软件开发人员至关重要!
135.2 NDEBUG 宏
如果我们在 #include <cassert>/#include <assert.h> 之前定义 NDEBUG 宏,那么程序中的 assert 断言会被忽略,注意 C++ 引入的静态断言 static_assert 会照常执行。
实际上,NDEBUG 宏起作用的原理是来自于 <cassert> 里面的定义:
// assert.h#if defined __cplusplus && __GNUC_PREREQ (2,95)
# define __ASSERT_VOID_CAST static_cast<void>
#else
# define __ASSERT_VOID_CAST (void)
#endif/* void assert (int expression);If NDEBUG is defined, do nothing.If not, and EXPRESSION is zero, print an error message and abort. */#ifdef NDEBUG
# define assert(expr) (__ASSERT_VOID_CAST (0))/* void assert_perror (int errnum);If NDEBUG is defined, do nothing. If not, and ERRNUM is not zero, print anerror message with the error text for ERRNUM and abort.(This is a GNU extension.) */# ifdef __USE_GNU
# define assert_perror(errnum) (__ASSERT_VOID_CAST (0))
# endif
当我们定义了 NDEBUG 宏之后,它会将 assert(expr) 替换为 (__ASSERT_VOID_CAST (0)),也即 static_cast<void>(0)。
136. 侵入式链表
C++侵入式链表实现——采用CRTP实现
136.1 介绍
非侵入式链表是常见的链表实现方式,其特点是链表节点与数据完全分离。在这种设计中,链表节点包含两部分:
- 指向用户数据的指针
- 指向下一个节点的指针(单向链表)或指向前后节点的指针(双向链表)
例如,一个典型的非侵入式链表节点结构如下:
struct Node {void* data; // 指向实际数据的指针struct Node* next; // 指向下一节点的指针
};
非侵入式链表的优势
- 数据与结构分离:数据结构不需要考虑如何被链表管理
- 通用性强:可以存储任何类型的数据,无需修改数据结构
- 设计简单:概念容易理解,适合初学者或简单应用
非侵入式链表的缺点
- 额外内存开销:每个节点需要额外分配内存(在为 Node 分配内存之后还需要为节点内部的 data 分配内存)👎
- 性能损失:额外的内存分配和指针间接访问会影响性能👎
- 缓存不友好:数据和节点分离导致缓存效率降低👎
侵入式链表则将链表的连接信息直接嵌入到用户数据结构中。用户数据结构必须包含指向其他节点的指针。
例如:
struct ListNode {struct ListNode* next;struct ListNode* prev;
};struct User {struct ListNode node; // 链表节点信息嵌入在用户数据中// 或者写为下面的形式:// ListNode *prev;// ListNode *next;int id;char name[50];// 其他用户数据...
};
侵入式链表的优势
- 内存效率高:不需要为节点单独分配内存(只需要为 User 分配一次内存即可)😀
- 性能更好:减少了额外的内存分配和释放操作
- 缓存友好:数据和链表信息位于连续内存,提高缓存命中率😀
- 支持多重链接:同一数据可以同时属于多个不同的链表
侵入式链表的缺点
- 侵入性强:需要修改用户数据结构,增加耦合👎
- 使用复杂:需要额外的宏或辅助函数处理节点与数据的转换👎
- 不够直观:设计更复杂,理解和维护成本较高👎
适用场景
- 非侵入式链表:适用于教学、简单应用、数据结构需要高度通用的场景
- 侵入式链表:适用于操作系统内核、嵌入式系统、高性能应用等对内存和性能要求高的场景
Linux 内核、BSD 等高性能系统广泛使用侵入式链表,而许多高级编程语言的标准库实现则倾向于使用非侵入式链表。
选择哪种设计主要取决于应用场景的具体需求,特别是对性能、内存效率和代码复杂度的权衡考量。
136.2 实例
brpc/blob/master/src/butil/containers/linked_list.h就是一个很好的侵入式链表的例子
// Copyright (c) 2009 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.#ifndef BUTIL_CONTAINERS_LINKED_LIST_H_
#define BUTIL_CONTAINERS_LINKED_LIST_H_// Simple LinkedList type. (See the Q&A section to understand how this
// differs from std::list).
//
// To use, start by declaring the class which will be contained in the linked
// list, as extending LinkNode (this gives it next/previous pointers).
//
// class MyNodeType : public LinkNode<MyNodeType> {
// ...
// };
//
// Next, to keep track of the list's head/tail, use a LinkedList instance:
//
// LinkedList<MyNodeType> list;
//
// To add elements to the list, use any of LinkedList::Append,
// LinkNode::InsertBefore, or LinkNode::InsertAfter:
//
// LinkNode<MyNodeType>* n1 = ...;
// LinkNode<MyNodeType>* n2 = ...;
// LinkNode<MyNodeType>* n3 = ...;
//
// list.Append(n1);
// list.Append(n3);
// n2->InsertBefore(n3);
//
// Lastly, to iterate through the linked list forwards:
//
// for (LinkNode<MyNodeType>* node = list.head();
// node != list.end();
// node = node->next()) {
// MyNodeType* value = node->value();
// ...
// }
//
// Or to iterate the linked list backwards:
//
// for (LinkNode<MyNodeType>* node = list.tail();
// node != list.end();
// node = node->previous()) {
// MyNodeType* value = node->value();
// ...
// }
//
// Questions and Answers:
//
// Q. Should I use std::list or butil::LinkedList?
//
// A. The main reason to use butil::LinkedList over std::list is
// performance. If you don't care about the performance differences
// then use an STL container, as it makes for better code readability.
//
// Comparing the performance of butil::LinkedList<T> to std::list<T*>:
//
// * Erasing an element of type T* from butil::LinkedList<T> is
// an O(1) operation. Whereas for std::list<T*> it is O(n).
// That is because with std::list<T*> you must obtain an
// iterator to the T* element before you can call erase(iterator).
//
// * Insertion operations with butil::LinkedList<T> never require
// heap allocations.
//
// Q. How does butil::LinkedList implementation differ from std::list?
//
// A. Doubly-linked lists are made up of nodes that contain "next" and
// "previous" pointers that reference other nodes in the list.
//
// With butil::LinkedList<T>, the type being inserted already reserves
// space for the "next" and "previous" pointers (butil::LinkNode<T>*).
// Whereas with std::list<T> the type can be anything, so the implementation
// needs to glue on the "next" and "previous" pointers using
// some internal node type.namespace butil
{template <typename T>class LinkNode{public:// LinkNode are self-referential as default.LinkNode() : previous_(this), next_(this) {}LinkNode(LinkNode<T> *previous, LinkNode<T> *next): previous_(previous), next_(next) {}// Insert |this| into the linked list, before |e|.void InsertBefore(LinkNode<T> *e){this->next_ = e;this->previous_ = e->previous_;e->previous_->next_ = this;e->previous_ = this;}// Insert |this| as a circular linked list into the linked list, before |e|.void InsertBeforeAsList(LinkNode<T> *e){LinkNode<T> *prev = this->previous_;prev->next_ = e;this->previous_ = e->previous_;e->previous_->next_ = this;e->previous_ = prev;}// Insert |this| into the linked list, after |e|.void InsertAfter(LinkNode<T> *e){this->next_ = e->next_;this->previous_ = e;e->next_->previous_ = this;e->next_ = this;}// Insert |this| as a circular list into the linked list, after |e|.void InsertAfterAsList(LinkNode<T> *e){LinkNode<T> *prev = this->previous_;prev->next_ = e->next_;this->previous_ = e;e->next_->previous_ = prev;e->next_ = this;}// Remove |this| from the linked list.void RemoveFromList(){this->previous_->next_ = this->next_;this->next_->previous_ = this->previous_;// next() and previous() return non-NULL if and only this node is not in any// list.this->next_ = this;this->previous_ = this;}LinkNode<T> *previous() const { return previous_; }LinkNode<T> *next() const { return next_; }// Cast from the node-type to the value type.const T *value() const { return static_cast<const T *>(this); }T *value() { return static_cast<T *>(this); }private:LinkNode<T> *previous_;LinkNode<T> *next_;// DISALLOW_COPY_AND_ASSIGN(LinkNode);};template <typename T>class LinkedList{public:// The "root" node is self-referential, and forms the basis of a circular// list (root_.next() will point back to the start of the list,// and root_->previous() wraps around to the end of the list).LinkedList() {}// Appends |e| to the end of the linked list.// equals to insert before root_void Append(LinkNode<T> *e) { e->InsertBefore(&root_); }// Prepend |e| to the head of the linked list.// equals to insert after root_void Prepend(LinkNode<T> *e) { e->InsertAfter(&root_); }LinkNode<T> *head() const { return root_.next(); }LinkNode<T> *tail() const { return root_.previous(); }const LinkNode<T> *end() const { return &root_; }bool empty() const { return head() == end(); }private:LinkNode<T> root_; /*=head= =tail=[root_] → (next_) → [task1] → (next_) → [task2] → (next_) → [root_]← (previous_) ← (previous_) ← (previous_)*/// DISALLOW_COPY_AND_ASSIGN(LinkedList);};} // namespace butil#endif // BUTIL_CONTAINERS_LINKED_LIST_H_
在这个实现当中,对插入操作的接口设计比较有意思,一般来说,NodeA.insert(NodeB) 是将 Nodeb 插入到 NodeA 中,但是在这里,对于函数 LinkNode::InsertBefore,是将 NodeA 插入到 NodeB 中。
我们可以具体的测试一下这份代码:
#include "./LinkNode.h"// 通过继承的方式将链表节点"侵入"到数据当中
class Task : public butil::LinkNode<Task> {
public:Task(int id, const std::string& name) : id_(id), name_(name) {}int id() const { return id_; }std::string name() const { return name_; };// private:int id_;std::string name_;
};// 正向遍历链表
void print_task_list(const butil::LinkedList<Task> &list) {cout << "正向遍历: " << endl;butil::LinkNode<Task> *node;for(node = list.head(); node != list.end(); node = node->next()) {const Task *task = node->value();cout << task->id() << endl;// cout << "Id: " << task->id() << ", Name: " << task->name() << endl;}// cout << node->value()->id() << endl;
}// 创建一个任务链表
butil::LinkedList<Task> task_list;// 创建任务
Task task1(1, "编写代码");
Task task2(2, "测试功能");
Task task3(3, "修复bug");
Task task4(4, "部署系统");// 为了在 debug 时让 root_ 默认初始化,这里要定义成全局的而不是局部对象??
butil::LinkedList<Task> urgent_tasks;
Task urgent5(5, "紧急修复");
Task urgent6(6, "客户支持");int main()
{// 将任务添加到链表task_list.Append(&task1);task_list.Append(&task3);task_list.Prepend(&task4);// 当前链表:task4 -> task1 -> task3// 在特定位置插入task2.InsertBefore(&task3); // 在 task3 之前插入 task2// 当前链表:task4 -> task1 -> task2 -> task3print_task_list(task_list);// 从链表中移除节点task2, O(1) 时间复杂度task2.RemoveFromList();// 当前链表: task4 -> task1 -> task3print_task_list(task_list);// 检查某个节点是否还在链表当中bool task2_in_list = !(task2.next() == &task2 && task2.previous() == &task2);bool task1_in_list = !(task1.next() == &task1 && task1.previous() == &task1);cout << boolalpha << "task2 in list: " << task2_in_list << endl << "task1 in list: " << task1_in_list << endl;// 批量插入功能urgent_tasks.Append(&urgent5);urgent_tasks.Append(&urgent6);print_task_list(urgent_tasks);urgent_tasks.head()->InsertAfterAsList(&task1); // 将 urgent_tasks 中的所有节点插入到 task_list 中的 task1 之前print_task_list(task_list);// BUG HERE// 原链表: task4 -> task1 -> task3// 插入后链表: task4 -> task1 -> urgent5 -> urgent6 -> task3// 我们希望将 urgent 这个链表整体插入到链表 task_list 当中// 但是这里似乎产生了 bug? 输出为 4 -> 1 -> 5 -> 6 -> -1024 -> 3,出现了一个 -1024// -1024 是我们 debug 时为 root_ 设定的默认值,从测试来看// 原因应该是在将 urgent5 所在链表插入 task_list 时,把 urgent5 所在链表的哨兵 root_ 节点也插入了// 我猜测,这里接口设计的目的,并没有“合并两个链表”的意味// 对于 a.InsertAfterAsList(b),其目的应该是将 a 所在链表插入到 "节点" b 之后// 注意,b 应该是一个“节点”而不是一个“链表”// 只有这样才能保证运行的正确return 0;
}
我上面的操作中,我们发现,我们可以脱离“链表”去操纵“链表中的节点”,这是个很有意思的事情:这意味着我们可以直接操作需要删除的节点,在 O(1) 的时间复杂度内删除它。
正如源文件开头注释所说,使用该循环链表而不是 STL 中的 std::list 的原因就是性能。
136.3 STL 标准库
绝大多数STL容器都是非侵入式的:
std::vectorstd::liststd::dequestd::map/std::setstd::unordered_map/std::unordered_set
特点:
- 容器完全管理元素的存储和生命周期
- 元素类型不需要特殊修改或继承
- 容器内部维护自己的数据结构(节点、指针等)
- 元素被拷贝或移动到容器内部
示例:
struct MyData { // 普通结构体,无需特殊修改int id;std::string name;
};std::list<MyData> myList; // 非侵入式
myList.push_back(MyData{1, "test"});
虽然STL主要采用非侵入式设计,但有些组件支持侵入式用法:
-
std::list的splice操作
std::list<int> list1, list2; // ...填充数据... list1.splice(list1.end(), list2); // 将list2的节点直接转移到list1- 这里实际移动的是内部节点,而非拷贝数据
-
std::shared_ptr的引用计数
- 引用计数器是侵入式地嵌入在控制块中
-
std::pmr::memory_resource相关容器
- 多态内存资源可以影响内存布局
为什么STL主要采用非侵入式设计?
- 封装性:隐藏实现细节
- 类型安全:元素类型保持纯净
- 易用性:用户无需修改自己的类
- 异常安全:资源管理更可靠
- 符合RAII原则:生命周期管理更清晰
137. 构造函数抛出异常
在 C++ 中,如果构造函数抛出异常,已经构造好的子对象(sub-objects)会按照与构造顺序相反的顺序自动被销毁。这个过程被称为栈展开(stack unwinding)。
让我更详细地解释一下:
- 构造过程:当一个对象的构造函数被调用时,它会按顺序构造其成员变量和基类。例如,如果一个类
C包含一个成员变量A和一个成员变量B,构造顺序通常是先构造A,再构造B。 - 异常发生:如果在构造
B的过程中抛出了异常,那么B的构造会失败。 - 栈展开与析构:此时,C++ 运行时系统会自动启动栈展开过程。它会检测到
A已经成功构造,但B没有。因此,为了防止资源泄露,系统会立即调用已经成功构造的子对象A的析构函数,从而安全地清理A所占用的资源。 - 对象本身:原始对象(即类
C的实例)本身被认为没有成功构造。因此,它的析构函数永远不会被调用。从外部看来,这个对象根本就没有存在过。
为了更好地理解这个过程,请看下面的代码:
#include <iostream>
#include <stdexcept>class MemberA {
public:MemberA() {std::cout << "MemberA 构造函数被调用" << std::endl;}~MemberA() {std::cout << "MemberA 析构函数被调用" << std::endl;}
};class MemberB {
public:MemberB() {std::cout << "MemberB 构造函数被调用" << std::endl;throw std::runtime_error("MemberB 构造函数中发生错误!");}~MemberB() {std::cout << "MemberB 析构函数被调用" << std::endl;}
};class MyClass {
public:MyClass() {std::cout << "MyClass 构造函数被调用" << std::endl;}~MyClass() {std::cout << "MyClass 析构函数被调用" << std::endl;}private:MemberA memberA_;MemberB memberB_;
};int main() {try {MyClass myObject;} catch (const std::exception& e) {std::cout << "捕获到异常: " << e.what() << std::endl;}return 0;
}
运行结果:
MemberA 构造函数被调用
MemberB 构造函数被调用
MemberA 析构函数被调用
捕获到异常: MemberB 构造函数中发生错误!
从输出可以看出,当 MemberB 的构造函数抛出异常时,MemberA 的析构函数被自动调用,而 MyClass 本身的析构函数则没有被调用,因为它被认为没有成功构造。这个机制保证了资源管理的安全性,防止了不完整的对象和相关资源泄露。
在 C++ 中,构造函数抛出异常后,已成功构造的子对象的析构是自动进行的。这是 C++ 语言标准规定的行为,属于异常安全(exception safety)机制的一部分,开发者不需要手动编写额外的代码来处理这种情况。
这个自动销毁过程是由 C++ 运行时系统管理的,它确保了在异常发生时,已经分配的资源能够被正确释放,避免了内存泄漏和其他资源泄漏问题。这正是 RAII(Resource Acquisition Is Initialization)原则在 C++ 中得到良好支持的原因之一。
简而言之,用户只需要在构造函数中编写可能抛出异常的代码,并使用 try...catch 块在调用构造函数的地方捕获异常即可。中间的清理工作(即已构造子对象的析构)由编译器和运行时环境自动完成。
138. std::invocable、std::invoke_result
std::is_invocable 是 C++17 引入的一个类型特性(type trait),用于在编译期检查某个可调用对象(如普通函数、lambda 表达式、成员函数、函数对象等)是否可以被调用(invocable),并返回一个 bool 类型的编译期常量值(true 或 false)。
138.1 基本语法
标准库的实现原理(简化版):
#include <type_traits>// 主模板(默认不可调用)
template <typename, typename = void, typename...>
struct is_invocable_impl : std::false_type {};// 特化版本(当调用合法时匹配)
template <typename F, typename... Args>
struct is_invocable_impl<F,std::void_t<decltype(std::declval<F>()(std::declval<Args>()...))>,Args...
> : std::true_type {};// 最终接口
template <typename F, typename... Args>
struct is_invocable : is_invocable_impl<F, void, Args...> {};// C++17 的 _v 变量模板
template <typename F, typename... Args>
inline constexpr bool is_invocable_v = is_invocable<F, Args...>::value;
Callable:要检查的可调用对象类型(如函数指针、std::function、lambda、成员函数指针等)。Args...:参数类型列表(如果Callable需要参数)。- 返回值:
true(如果Callable可以用Args...调用),否则false。
相关类型特性:
| 特性 | 作用 |
|---|---|
std::is_invocable_v |
检查是否可以用给定参数调用 Callable。 |
std::is_invocable_r_v<R, F, Args...> |
检查调用 F 后返回值是否能转换为 R。 |
std::invoke_result_t<F, Args...> |
获取调用 F 的返回类型(类似 C++11 的 std::result_of,但更准确)。 |
138.2 常见用法示例
结合 static_assert 可以用来在编译器检查某个函数是否可用或某个成员是否存在。
class Foo {
public:using value_type = int;static constexpr value_type size = 1024;string message() const { return "class::Foo"; }
};int square(int x) {return x * x;
}void check_valid()
{std::function<int(int)> factorial = [&factorial](int x) {return x <= 1 ? 1 : x * factorial(x - 1);};static_assert(std::is_invocable_v<decltype(&square), int>);static_assert(std::is_invocable_v<decltype(square), int>);static_assert(std::is_invocable_v<decltype(&Foo::message), const Foo&>);static_assert(std::is_invocable_v<decltype(factorial), int>);// 注意 &factorial 是指向 std::function 的指针,不是直接可调用对象static_assert(std::is_invocable_v<decltype(&factorial), int>); // error// is_invocable_v 不能检查非可调用成员,不过我们可以使用 is_same_v 来进行检查static_assert(std::is_same_v<Foo::value_type, int>);static_assert(std::is_same_v<decltype(Foo::size), Foo::value_type>); // errorstatic_assert(std::is_same_v<decltype(Foo::size), const Foo::value_type>);static_assert(std::is_same_v<std::invoke_result_t<decltype(&Foo::message), const Foo&>, string>);
}
139. 结构体对齐规则
139.1 1. 内存对齐的规则
- 对于结构体的各个成员,除了第一个成员的偏移量为 0 外,其余成员的偏移量是 其实际长度 的整数倍,如果不是,则在前一个成员后面补充字节。
- 结构体内所有数据成员各自内存对齐后,结构体本身还要进行一次内存对齐,保证整个结构体占用内存大小是结构体内最大数据成员的最小整数倍。
- 如程序中有 #pragma pack(n) 预编译指令,则所有成员对齐以 n字节 为准(即偏移量是n的整数倍),不再考虑当前类型以及最大结构体内类型。
139.2 2. 内存对齐的原因
139.2.1 (1) 硬件访问效率
大多数现代处理器设计为一次读取特定大小的内存块(通常是2、4或8字节)。当数据跨越了这些自然边界时:
- 处理器需要执行多次内存访问来获取一个数据
- 例如:如果一个4字节的int存储在地址3-6,CPU必须进行两次读取操作(地址0-3和地址4-7),然后合并结果
- 某些架构(如早期的SPARC、ARM等)在访问非对齐数据时会直接触发硬件异常
- x86架构虽然允许非对齐访问,但会导致明显的性能下降(可能慢5-10倍)
139.2.2 (2) 原子操作要求
原子操作是指不可被中断的操作,在并发编程中非常重要:
- 大多数处理器只能对对齐的数据执行原子操作
- 例如:C++的
std::atomic<int>在x86架构上,要求int必须4字节对齐 - 如果数据不对齐,原子操作可能会被分解为多个非原子操作,破坏线程安全性
- 在多核系统中,这会导致数据竞争和不可预测的行为
139.2.3 (3) 缓存效率
现代CPU使用缓存来加速内存访问:
- CPU缓存按"缓存线"(通常64字节)管理数据
- 当数据对齐时,可以更有效地填充和使用缓存线
- 非对齐数据可能横跨两个缓存线,导致缓存效率下降
- 特别是在需要频繁访问同一数据的场景中,对齐可以显著提高性能
- 减少"缓存伪共享"(false sharing)问题,即多个线程访问同一缓存线中的不同数据
139.3 3. 对齐注意事项
139.3.1 (1) 过度对齐导致内存浪费
- 强制使用过大的对齐值会在成员之间产生更多未使用的填充
- 例如:将所有结构体设为16字节对齐可能会让小结构体的大小增加数倍
- 对于包含大量小对象的应用(如游戏中的粒子系统),这会导致显著的内存浪费
- 特别是在内存受限环境(嵌入式系统、大数据量应用)中,这种浪费很关键
139.3.2 (2) 对齐规则因编译器和平台而异
- 不同处理器架构有不同的对齐要求(x86较宽松,ARM和RISC更严格)
- 各编译器有自己的默认对齐策略(MSVC、GCC、Clang可能不同)
- 某些平台对特定类型有特殊要求(如某些DSP对double的特殊对齐)
- 这导致可移植代码需要特别注意对齐问题,可能需要条件编译或平台检测
139.3.3 (3) 手动优化结构体布局
一般来说,按成员大小降序排列可以减少填充,这是因为小成员能够利用大成员之间可能存在的"缝隙",减少因对齐而添加的额外填充。
// 不优化: 12字节(有6字节填充)
struct BadLayout {char a; // 1字节// 3字节填充float b; // 4字节char c; // 1字节// 3字节填充
};// 优化后: 8字节(只有2字节填充)
struct GoodLayout {float b; // 4字节char a; // 1字节char c; // 1字节// 2字节填充
};
- 在某些情况下,可以利用位域(bit-fields)进一步压缩数据
140. 能否作为非类型模板/数组维度参数
| 变量类型 | 存储位置 | 地址是否编译时固定? | 能否作为非类型模板参数? |
|---|---|---|---|
| 全局变量 | 静态存储区 | ✅ 固定 | ✅ 允许 |
static 变量 |
静态存储区 | ✅ 固定 | ✅ 允许 |
constexpr 变量 |
(依赖声明) | ❌ 运行时决定 | ✅ 允许(值) |
| 局部变量(非静态) | 栈 | ❌ 运行时决定 | ❌ 不允许 |
动态内存(new) |
堆 | ❌ 运行时决定 | ❌ 不允许 |
- 动态内存(
new/malloc) 的地址在运行时分配在堆区,编译时无法确定。 - 局部变量(非
static)通常存储在 栈上,而栈的地址是由 运行时环境 动态分配的:- 栈的生长方向:每次函数调用时,栈指针(
ESP/RSP)会移动,局部变量的地址也随之变化。 - 递归/多线程的影响:同一个函数的不同调用实例(如递归调用),其局部变量的地址可能完全不同。
- 栈的生长方向:每次函数调用时,栈指针(
- 全局变量和
static变量存储在 静态存储区(全局数据区或 BSS 段),它们的地址在 程序加载时 就确定了,不会随函数调用改变。 constexpr变量是 编译期常量。constexpr只保证变量的值在 编译时可知,但它的 存储类别(storage duration) 仍然遵循普通变量的规则:- 如果
constexpr变量是 全局的 或static的,它存储在 静态存储区(地址编译时可计算)。 - 如果
constexpr变量是 局部非static的,它仍然存储在 栈 上(地址编译时未知)。
- 如果
为什么 constexpr 不自动变成静态存储?
C++ 的设计原则是 "零开销抽象",即:
- 如果
constexpr变量仅用于编译期计算(如数组大小、模板参数),它可能根本不需要占用内存(常量传播,编译器直接优化掉)。 - 如果程序员需要它的地址(如传递指针或引用),可以通过
static显式指定存储位置。
这种设计避免了不必要的内存占用(全局变量会始终存在,而局部 constexpr 可能被优化掉)。
例如:
int square(int x) { return pow(x, 2); }const int N = square(10);
const int M = 10;
static int K = 1;//int a[N]; // error: size of array ‘a’ is not an integral constant-expression
int c[K]; // error: size of array ‘c’ is not an integral constant-expression
int b[M];
141. 为什么要引入 constexpr
C++ 引入 constexpr 主要是为了增强编译时计算能力,让更多计算能在编译期完成,从而提升运行时性能、支持更强大的元编程,并增强类型安全。以下是详细原因:
141.1 1. 性能优化:减少运行时计算
-
传统
const的局限性:
const只保证变量不可修改,但它的值可以是运行时计算的(如用户输入、函数返回值)。const int x = get_value(); // 运行时计算,无法优化 -
constexpr的编译时计算:
强制在编译期求值,消除运行时开销。constexpr int x = 42; // 编译时确定 constexpr int y = square(5); // 编译时计算(如果 square 是 constexpr)
141.2 2. 支持更强大的元编程
-
模板元编程的替代方案:
传统模板元编程(TMP)依赖模板特化和递归,代码晦涩难懂。template <int N> struct Factorial {static const int value = N * Factorial<N-1>::value; };// 需要特化版本 template<> struct Factorial<0> {static const int value = 1; };constexpr提供更直观的编译时计算:constexpr int factorial(int n) {return (n <= 1) ? 1 : n * factorial(n - 1); } -
结合
constexpr和模板:
编译时类型检查、条件分支(如if constexpr)变得更简洁。
141.3 3. 扩展常量表达式的使用场景
-
传统 C++ 的局限:
数组大小、模板参数、static_assert等需要编译时常量,但仅支持基本字面量或简单表达式。int square(int x) { return pow(x, 2); } const int N = square(10); int a[N]; // error: size of array ‘a’ is not an integral constant-expression -
constexpr的解决方案:
允许复杂函数和类参与编译时计算:constexpr int get_size() { return 5; } int arr[get_size()]; // OK:编译时确定大小
141.4 4. 类型安全和错误提前暴露
-
编译时错误检查:
constexpr函数在编译时求值失败会直接报错,避免运行时问题。constexpr int div(int a, int b) {return (b == 0) ? throw "Division by zero!" : a / b; } constexpr int x = div(10, 0); // 编译时报错!编译器报错信息如下:
main.cpp:57:25: in ‘constexpr’ expansion of ‘Divide(10, 0)’ main.cpp:54:23: error: expression ‘<throw-expression>’ is not a constant expression54 | return (b == 0) ? throw "Division by zero!" : a / b;| ^~~~~~~~~~~~~~~~~~~~~~~~~ make: *** [makefile:5: app] Error 1 -
如果使用常规写法,程序会在运行时抛出一个异常:
int Divide(int a, int b) {return (b == 0) ? throw "Division by zero!" : a / b; }int x = Divide(10, 0); // 编译时报错!编译器报错信息如下:
g++ main.cpp -o app -pedantic-errors -Werror -g ./app terminate called after throwing an instance of 'char const*' make: *** [makefile:6: app] Aborted (core dumped)
141.5 5. 标准库的编译时支持
从 C++14 开始,标准库逐步支持 constexpr,例如:
- 容器(C++20 的
std::vector、std::string部分支持) - 算法(如
std::sort在 C++20 中可用于编译时数组) - 工具函数(如
std::max、std::min)
// C++20 编译时排序
constexpr std::array<int, 3> arr = {3, 1, 2};
constexpr auto sorted = std::sort(arr.begin(), arr.end()); // 编译时排序!
141.6 6. 现代 C++ 的演进需求
constexpr的逐步增强:- C++11:基础支持(简单变量和函数)。
- C++14:放宽限制(允许循环、局部变量)。
- C++17:
if constexpr、constexprlambda。 - C++20:更多标准库的
constexpr支持。
- 替代宏和模板 hack:
过去依赖宏或模板实现的编译时计算,现在可以用类型安全的constexpr替代。
142. RAII
RAII(Resource Acquisition Is Initialization,资源获取即初始化)是C++中一种管理资源、避免资源泄漏的惯用法,利用栈的特点来实现。这一概念最早由 Bjarne Stroustrup 提出。
RAII 的核心思想是将资源的生命周期与内存(对象)的生命周期绑定。本质上就是让资源的分配和释放和内存的分配释放关联到一起,从而保证了,只要内存得到释放,资源就一定释放。也就是内存管理着资源。
- 内存的释放是指我们会调用析构函数释放类对象的内存
- RAII 所做的就是在类对象的内存释放时,同时释放类对象占用的资源
关键点:
-
资源与内存绑定的核心思想
- RAII 的核心是利用对象的构造和析构来管理资源,无论对象本身分配在栈上还是堆上。
- 关键点在于:资源的释放由对象的析构函数触发,而析构函数的调用由内存的释放机制保证(栈对象离开作用域时析构,堆对象在
delete时析构)。
-
内存管理资源,不限于栈内存
- 栈内存:局部对象在作用域结束时自动析构,这是最简单的 RAII 场景。
- 堆内存:通过智能指针(如
std::unique_ptr、std::shared_ptr)管理的堆对象,也会在适当时候析构(如引用计数归零或显式释放),从而释放资源。 - 成员对象:即使对象是另一个类的成员(嵌套在堆或栈中),只要外层对象析构时正确触发成员析构,资源仍能自动释放。
-
“资源”不仅指内存
- 包括文件句柄、网络套接字、数据库连接、图形接口对象(如 GDI)等所有需要手动管理的有限资源。
- 目标是确保这些资源一定能被释放,避免泄漏。
-
不限于构造函数获取资源
- 资源可来自任何地方,只需析构函数负责释放。
143. 如何理解 int (*(*(*p)(int *))[4]) (int*)?
知乎问题 : 如何理解
int (*(*(*p)(int *))[4]) (int*)?All in all,if you ever use anything like this in your code, shame on you!
首先,在实际编程中不应该出现类似的代码,即使真的需要,也要通过类型别名把这一坨弄成人类一眼就能看懂的东西。其次,这里我长篇大论分析它,不是真的需要自己弄懂这一坨的原理,而是抛砖引玉,来介绍一些很有意思的工具和库。
143.1 1. cdecl
cdecl 是一个用于帮助程序员理解和创建复杂 C/C++ 声明的工具。它的名称来源于"C declaration"(C 声明)的缩写。
cdecl 的在线网站:cdecl.org
我们可以看一下 cdecl 的命令帮助:
[] means optional; {} means 1 or more; <> means defined elsewherecommands are separated by ';' and newlinescommand:declare <name> as <english>cast <name> into <english>explain <gibberish>set or set optionshelp, ?quit or exitenglish:function [( <decl-list> )] returning <english>array [<number>] of <english>[{ const | volatile | noalias }] pointer to <english><type>type:{[<storage-class>] [{<modifier>}] [<C-type>]}{ struct | union | enum } <name>decllist: a comma separated list of <name>, <english> or <name> as <english>name: a C identifiergibberish: a C declaration, like 'int *x', or cast, like '(int *)x'storage-class: extern, static, auto, registerC-type: int, char, float, double, or voidmodifier: short, long, signed, unsigned, const, volatile, or noalias
具体的用到再说,这里使用:
cdecl explain 'int (*(*(*p)(int *))[4]) (int*)'
输出为:
declare p as pointer to function (pointer to int) returning pointer to array 4 of pointer to function (pointer to int) returning int
逐层解释:
p是 指针:*p
- 指向 函数,参数为
int*:(*p)(int *)
- 函数返回 指针:
*(*p)(int *)
- 返回的指针指向 长度为 4 的数组:
(*(*p)(int *))[4]
- 数组元素是 指针,指向 函数(参数
int*,返回int):int (*(*(*p)(int *))[4])(int *)
143.2 magic-cpp
Github: ykiko/magic-cpp
- 作者的知乎
它可以以树的形式打印出复杂的声明,如下所示:
#include "./magic/visualize.h"
#include <iostream>int main()
{using T = int (*(*(*)(int*))[4])(int*); // hard to understandstd::cout << magic::visualize<T>() << std::endl;return 0;
}
输出为:
└── ptr└── function├── R: ptr│ └── array [N = 4]│ └── ptr│ └── function│ ├── R: int│ └── 0: ptr│ └── int└── 0: ptr└── int
更多内容看上面的 github 链接。
143.3 手动实现 cdecl
这是大佬 严格鸽 自己手动实现的 cdecl,学了一年的 C++ 便到了如此地步,只能感慨,智商是无法逾越的鸿沟啊。
#include <cstdio>
#include <iostream>template <char... data>
struct STR {template <char... N>using push_back = STR<data..., N...>;template <char... N>using push_front = STR<N..., data...>;constexpr static char str[] = {data...};template <char F, char B>using push_front_back = STR<F, data..., B>;
};template <typename T, typename S>
struct Merge; // 合并两个字符串
template <typename T, typename S, char ch>
struct Merge_with; // 合并两个字符串中间用ch连接template <typename T, typename = void>
struct type_decl; // 返回T的描述template <typename... Ts>
struct types_decl; // 返回Ts... 的描述template <typename T>
struct remove_array {constexpr static bool is_array = false;
};template <typename T>
struct remove_const {constexpr static bool is_const = false;
};template <typename T>
struct remove_pointer {constexpr static bool is_pointer = false;
};template <typename Ret, typename... Args>
struct remove_function {constexpr static bool is_func = false;
};char constexpr to_char(int num) {char ch = '0' + num;return ch;
}template <char... L, char... R>
struct Merge<STR<L...>, STR<R...>> {using ret = STR<L..., R...>;
};template <char... L, char... R, char ch>
struct Merge_with<STR<L...>, STR<R...>, ch> {using ret = STR<L..., ch, R...>;
};template <typename T>
struct types_decl<T> {using name = typename type_decl<T>::name;
};
template <typename head, typename... Ts>
struct types_decl<head, Ts...> {using head_name = typename type_decl<head>::name;using name = typename Merge_with<head_name, typename types_decl<Ts...>::name, ','>::ret;
};
template <>
struct types_decl<> {using name = STR<'v', 'o', 'i', 'd'>;
};template <typename Ret, typename... Args>
struct remove_function<Ret(Args...)> {constexpr static bool is_func = true;using ret_type = Ret;using ret_type_name = typename type_decl<Ret>::name;using arg_type_name = typename types_decl<Args...>::name;
};template <typename T, int N>
struct remove_array<T[N]> {constexpr static bool is_array = true;using type = T;constexpr static int len = N;
};template <typename T>
struct remove_const<const T> {constexpr static bool is_const = true;using type = T;
};template <typename T>
struct remove_pointer<T *> {constexpr static bool is_pointer = true;using type = T;
};template <>
struct type_decl<int> {using name = STR<'i', 'n', 't'>;
};template <>
struct type_decl<void> {using name = STR<'v', 'o', 'i', 'd'>;
};template <>
struct type_decl<char> {using name = STR<'c', 'h', 'a', 'r'>;
};template <typename T>
struct type_decl<T, typename std::enable_if_t<remove_array<T>::is_array, void>> {using next = typename remove_array<T>::type;constexpr static int len = remove_array<T>::len;using cur_name = STR<'a', 'r', 'r', 'a', 'y', ' ', to_char(len), ' ', 'o', 'f'>;using name = typename Merge_with<cur_name, typename type_decl<next>::name, ' '>::ret;
};template <typename T>
struct type_decl<T, typename std::enable_if_t<remove_const<T>::is_const, void>> {using next = typename remove_const<T>::type;using cur_name = STR<'c', 'o', 'n', 's', 't'>;using name = typename Merge_with<cur_name, typename type_decl<next>::name, ' '>::ret;
};template <typename T>
struct type_decl<T, typename std::enable_if_t<remove_pointer<T>::is_pointer && !std::is_const_v<T>, void>> {using next = typename remove_pointer<T>::type;using cur_name = STR<'p', 'o', 'i', 'n', 't', 'e', 'r', ' ', 't', 'o'>;using name = typename Merge_with<cur_name, typename type_decl<next>::name, ' '>::ret;
};template <typename T>
struct type_decl<T, typename std::enable_if_t<remove_function<T>::is_func, void>> {using ret_name = typename remove_function<T>::ret_type_name;using args_name = typename remove_function<T>::arg_type_name;using args_name_format = typename args_name::template push_front_back<'(', ')'>;using name0 = typename Merge<STR<'f', 'u', 'n', 'c', 't', 'i', 'o', 'n', ' '>, args_name_format>::ret;using name1 = typename Merge_with<name0, STR<'r', 'e', 't', 'u', 'r', 'n', 'i', 'n', 'g'>, ' '>::ret;using name = typename Merge_with<name1, ret_name, ' '>::ret;
};template <typename T>
void print() {using print_str = typename T::template push_back<'\0'>;std::cout << print_str::str << "\n";
}
int main() {{char const *(*(*const bar)[5])(int){};using T = decltype(bar);using name = type_decl<T>::name;print<name>();// declare bar as const pointer to array 5 of pointer to function (int) returning pointer to const char}{int(*(*foo)(void))[3];using T = decltype(foo);using name = type_decl<T>::name;print<name>();// declare foo as pointer to function (void) returning pointer to array 3 of int}{const int ** const pp2{};using T = decltype(pp2);using name = type_decl<T>::name;print<name>();// declare pp2 as const pointer to pointer to const int}
}
143.4 right-left-rule/spiral rule
为了避免文章因为偶然原因的丢失,下面是对原文的转载。
The "right-left" rule is a completely regular rule for deciphering C declarations. It can also be useful in creating them.
143.4.1 Symbols Read
*as "pointer to" — always on the left side[]as "array of" — always on the right side()as "function returning" — always on the right side
as you encounter them in the declaration.
143.4.2 Step 1
Find the identifier. This is your starting point. Then say to yourself,
"identifier is." You've started your declaration.
143.4.3 Step 2
Look at the symbols on the right of the identifier.
-
If, say, you find "()" there, then you know that this is the declaration for a function. So you would then have "identifier is function returning".
-
Or if you found a "[]" there, you would say "identifier is array of".
Continue right until you run out of symbols OR hit a right parenthesis ")".
If you hit a left parenthesis, that's the beginning of a () symbol, even if there is stuff in between the parentheses. More on that below.
143.4.4 Step 3
Look at the symbols to the left of the identifier.
- If it is not one of our symbols(
*,[],()) above (say, something like "int"), just say it. - Otherwise, translate it into English using that table above.
Keep going left until you run out of symbols OR hit a left parenthesis "(".
Now repeat steps 2 and 3 until you've formed your declaration.
Step2:
在这一步,你需要向右看标识符右边的符号。每遇到一个符号,就根据规则理解它的含义。符号包括:
*表示“指针”,所以如果右边是*,就意味着这个标识符是一个指针类型。[]表示“数组”,所以如果右边是[],就意味着这个标识符是一个数组类型。()表示“函数返回”,所以如果右边是(),就意味着这个标识符是一个函数。你会一直向右看,直到遇到右括号
)或者没有更多符号为止。Step3:
这一步,你需要开始向左看标识符左边的符号。你需要根据符号的类型来决定它的含义:
- 如果左边的符号是一个类型(比如
int、char等),就直接说它是什么类型。- 如果左边的符号是我们之前提到的符号(比如
*、[]、()等),就根据规则把它翻译成英文。你会继续向左看,直到遇到左括号
(或者没有更多符号为止。
143.4.5 Here are some examples
Example 1: int *p[];
1) Find identifier. int *p[];^"p is"2) Move right until out of symbols or right parenthesis hit.int *p[];^^"p is array of"3) Cant move right anymore (out of symbols), so move left and find:int *p[];^"p is array of pointer to"4) Keep going left and find:int *p[];^^^"p is array of pointer to int".(or "p is an array where each element is of type pointer to int")
Example 2: int *(*func())();
1) Find the identifier. int *(*func())();^^^^"func is"2) Move right. int *(*func())();^^"func is function returning"3) Cant move right anymore because of the right parenthesis, so move left.int *(*func())();^"func is function returning pointer to"4) Cant move left anymore because of the left parenthesis, so keep goingright. int *(*func())();^^"func is function returning pointer to function returning"5) Cant move right anymore because we're out of symbols, so go left.int *(*func())();^"func is function returning pointer to function returning pointer to"6) And finally, keep going left, because there's nothing left on the right.int *(*func())();^^^"func is function returning pointer to function returning pointer to int".
143.4.6 More Complex Case
As you can see, this rule can be quite useful. You can also use it to sanity check yourself while you are creating declarations, and to give you a hint about where to put the next symbol and whether parentheses are required.
Some declarations look much more complicated than they are due to array sizes and argument lists in prototype form.
- If you see
[3], that's read as "array (size 3) of...". - If you see
(char *,int)that's read as "function expecting(char *,int)and returning...".
Here's a fun one:
int (*(*fun_one)(char *,double))[9][20];
I won't go through each of the steps to decipher this one.😀
Ok. It's:
"fun_one is pointer to function expecting (char *,double) and returning pointer to array (size 9) of array (size 20) of int."
As you can see, it's not as complicated if you get rid of the array sizes and argument lists:
int (*(*fun_one)())[][];
You can decipher it that way, and then put in the array sizes and argument lists later. But if you're an expert, you don't need to do that.😀
143.4.7 Final Words
It is quite possible to make illegal declarations using this rule, so some knowledge of what's legal in C is necessary. For instance, if the above had been:
int *((*fun_one)())[][];
it would have been "fun_one is pointer to function returning array of array of
^^^^^^^^^^^^^^^^^^^^^^^^
pointer to int". Since a function cannot return an array, but only a pointer to an array, that declaration is illegal.
Illegal combinations include:
[]()- cannot have an array of functions()()- cannot have a function that returns a function()[]- cannot have a function that returns an array
In all the above cases, you would need a set of parentheses(()) to bind a * symbol on the left between these () and [] right-side symbols in order for the declaration to be legal.
143.4.8 More Examples
int i; // an int
int *p; // an int pointer (ptr to an int)
int a[]; // an array of ints
int f(); // a function returning an int
int **pp; // a pointer to an int pointer (ptr to a ptr to an int)
int (*pa)[]; // a pointer to an array of ints
int (*pf)(); // a pointer to a function returning an int
int *ap[]; // an array of int pointers (array of ptrs to ints)
int aa[][]; // an array of arrays of ints
int af[](); // an array of functions returning an int (ILLEGAL)
int *fp(); // a function returning an int pointer
int fa()[]; // a function returning an array of ints (ILLEGAL)
int ff()(); // a function returning a function returning an int (ILLEGAL)
int ***ppp; // a pointer to a pointer to an int pointer
int (**ppa)[]; // a pointer to a pointer to an array of ints
int (**ppf)(); // a pointer to a pointer to a function returning an int
int *(*pap)[]; // a pointer to an array of int pointers
int (*paa)[][]; // a pointer to an array of arrays of ints
int (*paf)[](); // a pointer to an array of functions returning an int (ILLEGAL)
int *(*pfp)(); // a pointer to a function returning an int pointer
int (*pfa)()[]; // a pointer to a function returning an array of ints (ILLEGAL)
int (*pff)()(); // a pointer to a function returning a function returning an int (ILLEGAL)
int **app[]; // an array of pointers to int pointers
int (*apa[])[]; // an array of pointers to arrays of ints
int (*apf[])(); // an array of pointers to functions returning an int
int *aap[][]; // an array of arrays of int pointers
int aaa[][][]; // an array of arrays of arrays of ints
int aaf[][](); // an array of arrays of functions returning an int (ILLEGAL)
int *afp[](); // an array of functions returning int pointers (ILLEGAL)
int afa[]()[]; // an array of functions returning an array of ints (ILLEGAL)
int aff[]()(); // an array of functions returning functions returning an int (ILLEGAL)
int **fpp(); // a function returning a pointer to an int pointer
int (*fpa())[]; // a function returning a pointer to an array of ints
int (*fpf())(); // a function returning a pointer to a function returning an int
int *fap()[]; // a function returning an array of int pointers (ILLEGAL)
int faa()[][]; // a function returning an array of arrays of ints (ILLEGAL)
int faf()[](); // a function returning an array of functions returning an int (ILLEGAL)
int *ffp()(); // a function returning a function returning an int pointer (ILLEGAL)
143.4.9 Other Reference
Clockwise/Spiral Rule
教大家一种无脑解决这类指针嵌套解读的方法。用这种方式可以写出自己的嵌套指针解读器。
解读步骤:
- 找到最内层变量
p
如果没有变量,找第一个)左边的位置。 - 从
p开始,向右进行解读
解读细则如下:- 2.1 向右过程中遇到
)则改变方向。 - 2.2 遇到
(则“是函数”,开始用同样方式递归解读逗号分隔的函数参数,并跳出该括号匹配部分后继续解读返回值。 - 2.3 遇到
[则“是数组”,找到对应]匹配即可,并添加“数组的元素”。 - 2.4 向左过程中,遇到
*则“是指针”,并添加“指针所指对象”。 - 2.5 遇到
(则改变方向。 - 2.6 整个过程中,对遇到的类型说明加以阐释。
- 2.7 碰到一个符号,解读完后就将这个符号去除。
- 2.8 若向右过程中所有符号均解读完毕,则向左。
- 2.1 向右过程中遇到
解读示例:int (*(*(*p)(int *))[4]) (int*)
(*p): p is a pointer(*p)(int*): p is a pointer to function(int *)(*(*p)(int*): p is a pointer to function(int *) returning a pointer to(*(*p)(int*)[4]: p is a pointer to function(int *) returning a pointer to array[4] of*(*(*p)(int*)[4]: p is a pointer to function(int *) returning a pointer to array[4] of point*(*(*p)(int*)[4] (int*): p is a pointer to function(int *) returning a pointer to array[4] of point to a function(int *)int *(*(*p)(int*)[4] (int*): p is a pointer to function(int *) returning a pointer to array[4] of point to a function(int *) returning int
解读完毕,感觉可以出一道栈与递归的算法题。
144. C++智能指针的使用
GotW #91 Solution: Smart Pointer Parameters
144.1 为什么我们需要智能指针
-
忘记 delete
class Box{ public:Box() : w(new Widget()){}~Box(){// 忘记 delete w} private:Widget* w; }; -
异常安全
void process() {Widget* w = new Widget();w->do_something(); // 可能会发生异常,此时 w 不会被正确 deletedelete w; }当然我们也可以使用 try-catch,但这样代码上并不美观,也容易漏写。
144.2 shared_ptr or unique_ptr
因为 C++ 的 zero cost abstraction 的特点,unique_ptr 在默认情况下和裸指针的大小是一样的。所以 内存上没有任何的额外消耗,性能是最优的。
而 shared_ptr 则有以下问题:
-
内存开销更大
shared_ptr 的内存占用是裸指针的两倍。因为除了要管理一个裸指针外,还要维护一个引用计数和其它东西(e.g. deleter),具体是通过一个指向控制块的指针。因此相比于 unique_ptr, shared_ptr 的内存占用更高。
-
原子操作性能低
考虑到线程安全问题,引用计数的增减必须是原子操作。而原子操作一般情况下都比非原子操作慢。
虽然 shared_ptr 性能上不如 unique_ptr,我们也可以通过移动语义优化性能,当我们在使用 shared_ptr 时,需要将所有权共享给另外一个新的 shared_ptr,而我们确定在之后的代码中都不再使用这个 shared_ptr,那么这是一个非常鲜明的移动语义。对于此种场景,我们尽量使用 std::move,将 shared_ptr 转移给新的对象。因为移动不用增加引用计数,性能比复制更好。
对象的延迟销毁:陈硕在《Linux 多线程服务器端编程》中提到,当一个对象的析构非常耗时,甚至影响到了关键线程的速度。可以使用 BlockingQueue<std::shared_ptr<void>> 将对象转移到另外一个线程中释放,从而解放关键线程。
144.3 weak_ptr
典型的循环引用问题:
struct B;
struct A {shared_ptr<B> sb;~A() { cout << "A::~A()" << endl;}
};
struct B {shared_ptr<A> sa;~B() { cout << "B::~B()" << endl;}
};int main()
{auto pa = make_shared<A>(); // pa->use_count = 1auto pb = make_shared<B>(); // pb->use_count = 1pa->sb = pb; // pb->use_count = 2pb->sa = pa; // pa->use_count = 2return 0;// pa->use_count = 1// pb->use_count = 1
}
通常使用 weak_ptr 来打破 shared_ptr 循环引用的问题,weak_ptr 有以下特性:
- 不增加引用计数:weak_ptr 指向 shared_ptr 管理的对象,但不会增加引用计数
- 观测而非拥有:weak_ptr 禁用来临时访问资源,不参与生命周期管理
- 需通过 lock() 获取 shared_ptr:由于 weak_ptr 并不持有对象,因此访问对象时需升级为 shared_ptr,可以通过 lock() 或转换为 shared_ptr,确保 weak_ptr 观测的对象存在
parent-child 场景的合理语义设计:
- parent 拥有 child: parent 的生命周期决定 child 的声明周期(child 的存在依赖于 parent)
- child 引用 parent: child 需要访问 parent,但不应该阻止 parent 释放
也即,parent 持有 child 的 shared_ptr,child 持有 parent 的 weak_ptr
144.4 smart pointer parameters
smart pointer 可以让用户从 raw pointer 对象复杂的内存管理中脱离出来,但是这里有了一个新的问题,有必要在所有需要使用指针的地方使用 smart pointer,而不是 raw pointer 吗?
144.4.1 Question1:
What are the performance implications of the following function declaration? Explain.
void f( shared_ptr<widget> );
Second, the less-likely minor reason is the potentially scalability-bustingly contentious nature of the “shared reference count”: Both increment and decrement update the reference count, which means that at the processor and memory level only one core at a time can be executing such an instruction on the same reference count because it needs exclusive access to the count’s cache line. The net result is that this causes some contention on the count’s cache line, which can affect scalability if it’s a popular cache line being touched by multiple threads in tight loops—such as if two threads are calling functions like this one in tight loops and accessing shared_ptrs that own the same object. “So don’t do that, thou heretic caller!” we might righteously say. Well and good, but the caller doesn’t always know when two shared_ptrs used on two different threads refer to the same object, so let’s not be quick to pile the wood around his stake just yet.
As we will see, an essential best practice for any reference-counted smart pointer type is to avoid copying it unless you really mean to add a new reference. This cannot be stressed enough.
总而言之,我们不希望通过上面的形式传递指针,主要有以下 3 点:
- shared_ptr 的拷贝构造开销,当然现代编译器可以对其优化
- 引用计数的管理是原子性的,耗时
- 上面提到的,对 cache line 的影响,当多个线程修改同一个引用计数时,会导致 cache line contention,进而影响程序的可拓展性
144.4.2 Question2
What are the correctness implications of the function declaration in #1? Explain with clear examples.
The only correctness implication is that the function advertises in a clear type-enforced way that it will (or could) retain a copy of the shared_ptr.
Guideline: Don’t pass a smart pointer as a function parameter unless you want to use or manipulate the smart pointer itself, such as to share or transfer ownership.
Guideline: Prefer passing objects by value, *, or &, not by smart pointer.
简而言之,除非我们需要使用“智能指针”本身,例如在函数中将它 share 或 transfer 给其它对象。否则,如果我们仅仅时想像使用一个 raw pointer 一样使用 smart pointer,请使用指针(有 NULL 语义时)或引用(没有 NULL 语义时)。
144.4.3 Question3
A colleague is writing a function f that takes an existing object of type widget as a required input-only parameter, and trying to decide among the following basic ways to take the parameter (omitting const):
void f( widget* ); (a) // prefer
void f( widget& ); (b) // prefer
void f( unique_ptr<widget> ); (c) // sink
void f( unique_ptr<widget>& ); (d) // control
void f( shared_ptr<widget> ); (e)
void f( shared_ptr<widget>& ); (f)
Under what circumstances is each appropriate? Explain your answer, including where const should or should not be added anywhere in the parameter type.
(There are other ways to pass the parameter, but we will consider only the ones shown above.)
Guideline: Express a “sink” function using a by-value unique_ptr parameter.(c)
注意,使用类似 (d) 的形式来代替 (b) 并不是一个好的做法,即使我们将引用声明为常量引用,因为它把参数限制在了调用者必须使用 unique_ptr 的生命周期管理策略上,耦合性变高了。
而实际上,函数的调用者是如何管理对象生命周期的,函数本身(被调用者)通常不应该去关心。
相反,如果改用 widget*(裸指针)作为参数,就可以覆盖上面所有情况的超集。即:无论对象是否为空、是否由 unique_ptr 管理,裸指针都可以接受,更通用。
所以说,如果我们使用 const unique_ptr&,是 strange and not not universal。
Guideline: Use a non-const unique_ptr& parameter only to modify the unique_ptr.(d)
Guideline: Express that a function will store and share ownership of a heap object using a by-value shared_ptr parameter. (e)
Similarly to (d), this should mainly be used to accept an in/out shared_ptr, when the function is supposed to actually modify the shared_ptr itself. It’s usually a bad way to accept a widget, because it is restricted to a particular lifetime strategy in the caller. (f)
Guideline: Use a non-const shared_ptr& parameter only to modify the shared_ptr. Use a const shared_ptr& as a parameter only if you’re not sure whether or not you’ll take a copy and share ownership; otherwise use widget* instead (or if not nullable, a widget&).
144.4.4 Conclusion
除非我们的函数需要管理智能指针本身(控制对象的所有权),否则,一律不要使用智能指针作为形参。
145. 高并发场景下的可拓展性
“影响程序的可扩展性”这个说法,在多线程或高并发编程中是一个非常关键的性能术语。我们来拆解一下这个概念:
可扩展性(Scalability),是指:
当你增加计算资源(比如更多的 CPU 核心、线程、服务器),程序的性能是否能成比例地提升。
假设你有一个程序在 1 个线程下每秒能处理 1000 个请求。
- 如果你把线程数加到 4 个,然后它能处理 4000 个请求/秒 —— 这就是理想可扩展性。
- 如果你加了线程,但只能处理 2000 个请求/秒 —— 表示程序的扩展性被限制了,资源浪费了。
- 更糟糕的情况是,线程越多,程序越慢 —— 就是完全不可扩展。
一些常见的扩展性障碍包括:
| 情况 | 原因 | 举例 |
|---|---|---|
| 锁冲突 / 线程竞争 | 多个线程访问同一个资源,需要排队或等待 | 多线程同时修改同一个 shared_ptr 的引用计数 |
| 共享内存访问 | 数据不容易被分散到多核 | 缓存行争用(cache line contention) |
| I/O 瓶颈 | 文件、数据库、网络响应慢 | 线程都卡在等待磁盘读写 |
| 任务分配不均 | 某些线程负载重,某些很空闲 | 一个线程忙着处理队列,另一个啥也没干 |
“影响程序的可扩展性”是说:当程序尝试利用更多资源(如线程)来提高性能时,因为设计上的限制,实际效果却提升不大甚至变差。
146. elide-constructors
geeksforgeeks
cppreference
在 C++ 中,elide-constructors(构造函数省略)是编译器优化技术,用于消除不必要的临时对象构造和拷贝/移动操作,从而提高程序性能。最常见的场景是 RVO(返回值优化) 和 NRVO(命名返回值优化)。
大多数编译器默认启用构造函数优化,但可以通过编译选项关闭:
GCC/Clang:
-fno-elide-constructors # 关闭 RVO/NRVO 优化
C++17 之前,RVO/NRVO 是可选的优化,但 C++17 起,某些情况下的拷贝省略变成强制要求(即使拷贝/移动构造函数有副作用)。
如果拷贝省略(RVO/NRVO)未发生,C++11 会优先尝试移动语义(如果定义了移动构造函数)
虽然 RVO/NRVO 很强大,但某些情况下无法优化:
-
返回函数参数:
T func(T x) {return x; // 不能优化,因为 x 是参数,不是局部变量 } -
多返回路径(影响 NRVO):
T func(bool cond) {T a, b;return cond ? a : b; // NRVO 可能失效 } -
返回全局变量或静态变量:
T global; T func() {return global; // 不能优化 }
147. zero abstraction cost
What does 'Zero Cost Abstraction' mean?
reddit 上的讨论
147.1 Answer1: Creator of C++
"What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better."
— Bjarne Stroustrup
ZCAs allow high-level abstractions (e.g., generics, iterators, smart pointers) to be fully optimized to the equivalent low-level code you’d write manually, with no runtime overhead.
零成本抽象的本质是:
让高级代码的抽象能力(安全性、表达力)和底层代码的性能统一,没有妥协。这种设计哲学使得 C++/Rust 既能写高抽象的系统(如操作系统、浏览器引擎),又能替代手写汇编的关键部分。
147.1.1 1. "What you don’t use, you don’t pay for"
Meaning:
- If a language feature (e.g., runtime polymorphism, exceptions, garbage collection) is not used in your code, it should not impose any runtime overhead (CPU cycles, memory, etc.).
- Example:
- In C++, if you don’t use
virtualfunctions, there’s no vtable or dynamic dispatch cost. - In Rust, if you don’t use
ArcorBox, there’s no heap allocation overhead.
- In C++, if you don’t use
Why it matters:
- Unlike languages like Java or Python (where features like GC or reflection add baseline overhead), C++/Rust only charge you for what you explicitly use.
147.1.2 2. "What you do use, you couldn’t hand code any better"
Meaning:
- If you do use an abstraction (e.g., generics, iterators, smart pointers), the compiled code should be as efficient as the best possible hand-written, low-level equivalent.
- Examples:
- A Rust
Iteratorchain (iter.map().filter().collect()) optimizes to the same assembly as a manualfor-loop. - C++’s
std::sortgenerates custom machine code for each type, matching a hand-tuned sort.
- A Rust
Why it matters:
- Abstractions aren’t just "syntactic sugar"—they map directly to optimal hardware operations.
147.1.3 Key Characteristics
- Compile-Time Resolution
Abstractions are "erased" during compilation, leaving only the necessary machine code.- Example: A Rust
Iteratorchain likeiter.map(...).filter(...).collect()compiles down to a tight loop with no intermediate allocations.
- Example: A Rust
- No Hidden Costs
No implicit memory allocations, indirect calls, or runtime checks unless explicitly requested.- Counter-example: Java’s
Streams are not zero-cost due to boxing and virtual dispatch.(装箱和虚拟调度)
- Counter-example: Java’s
- Type System Leverage
Types encode invariants at compile time (like your type-state example), making runtime checks unnecessary.
147.2 Answer2: Type states
In The Embedded Rust Book, it said:
Type states are also an excellent example of Zero Cost Abstractions
- the ability to move certain behaviors to compile time execution or analysis.
These type states contain no actual data, and are instead used as markers.
Since they contain no data, they have no actual representation in memory at runtime:
147.3 Answer3: StackOverflow Accepted
Zero Cost Abstractions means adding higher-level programming concepts, like generics, collections and so on do not come with a run-time cost, only compile time cost (the code will be slower to compile). Any operation on zero-cost abstractions is as fast as you would write out matching functionality by hand using lower-level programming concepts like for loops, counters, ifs and using raw pointers.
Or another way to view this is that using zero-cost abstraction tools, functions, templates, classes and such come with "zero cost" for the performance of your code.
148. 为什么 emplace_back({1, 2}) 会报错
执行下面代码:
int main()
{vector<vector<int>> vec;vec.push_back({1, 2});vec.emplace_back({1, 2});
}
编译器报错如下:
main.cpp: In function ‘int main()’:
main.cpp:57:21: error: no matching function for call to ‘std::vector<std::vector<int> >::emplace_back(<brace-enclosed initializer list>)’57 | vec.emplace_back({1, 2});| ~~~~~~~~~~~~~~~~^~~~~~~~
In file included from /usr/include/c++/11/vector:72,from main.cpp:7:
/usr/include/c++/11/bits/vector.tcc:109:7: note: candidate: ‘std::vector<_Tp, _Alloc>::reference std::vector<_Tp, _Alloc>::emplace_back(_Args&& ...) [with _Args = {}; _Tp = std::vector<int>; _Alloc = std::allocator<std::vector<int> >; std::vector<_Tp, _Alloc>::reference = std::vector<int>&]’109 | vector<_Tp, _Alloc>::| ^~~~~~~~~~~~~~~~~~~
/usr/include/c++/11/bits/vector.tcc:109:7: note: candidate expects 0 arguments, 1 provided
在 C++中,
braced-init-list(大括号初始化列表)是指使用花括号{}包围的初始化器列表。这是C++11引入的一种统一初始化语法。
现在,我们稍微给出一点提示,下面是简化后的 std::vector::push_back 和 std::vector::emplace_back 的函数签名:
void push_back(const T& value); // (1) 接受 const 左值引用
void push_back(T&& value); // (2) 接受右值引用(移动语义)template <typename... Args>
reference emplace_back(Args&&... args);
148.1 push_back为什么可行
由于 push_back 接受的参数 T 类型是我们指定的 vector<int>,而 vector<int> 又可以接受 initializer_list 作为参数构造对象(C++ 11引入),所以这里 {1,2} 可以用来构造 vector<int>。
148.2 emplace_back 为什么不行
这是因为,当我们通过 emplace_back 传递 {1,2} 时,{1,2} 本身什么都不是,而你以为它是 std::initializer_list<int>。
它变身 std::initializer_list<int>是有条件的:
- 要么是你在用
auto推导这玩意儿的类型 - 要么是你在做某种函数调用并且被调用的函数以
std::initializer_list作为参数。
而这里的 emplace_back 本身是个模板(std::vector<_Tp, _Alloc>::emplace_back(_Args&& ...)),它的参数 _Args 并不确定,所以就形成了一个死锁的局面:
emplace_back实例化出接受std::initializer_list的版本的前提是{1,2}变身为std::initializer_list{1,2}变身的前提是emplace_back实例化出接受std::initializer_list的版本
由于我们不能修改 STL 源代码,让 vector::emplaec_back 特例化一个接受 std::initializer_list 的版本,就只能显示的指定 {1,2} 的类型了:
// 手动构造一个 initializer_list 是可以的
vec.emplace_back(std::initializer_list<int>{1, 2});// 手动指定模板参数类型,放弃自动推导 emplace_back 也是可以的
// 这样 {1,2} 看见这个函数的参数是 initializer_list,就会触发变身
vec.emplace_back<std::initializer_list<int>>({1, 2});auto x = {1, 2}; // 或者利用游戏规则先得到 initializer_list
vec.emplace_back(x); // 然后就行了
当然,除了使用 initializer_list 初始化以外,我们还可以利用其它 vector 的构造函数,更好的体现 emplace_back 的语义(直接构造):
vector<vector<int>> vec;
vec.emplace_back(4, 3); // {3, 3, 3, 3}vector<int> tmp{1, 2, 3, 4, 5};
vec.emplace_back(tmp.begin(), tmp.end()); // {1, 2, 3, 4, 5}for(auto &x : vec) cout << x.size() << endl; // 4 5
最后,不要误认为 emplace_back(4,3) 是构造了一个 vector<int>{4,3}😀。
2025/5/11 答
- 作者:mq白
- 链接:https://www.zhihu.com/question/438004429/answer/3348723205
{} 花括号初始化器列表不是表达式,因此它没有类型,即decltype({1,2})非良构。没有类型意味着模板类型推导无法推导出与花括号初始化器列表相匹配的类型。
push_back它不是成员函数模板,它只是使用了类模板std::vector 的类型模板形参罢了,当你实例化了对象再调用这个成员函数的时候,它的参数是确定的,使用 {}没问题。
emplace_back是成员函数模板,使用到了形参包、包展开、 完美转发,placement new等技术,反正,它是模板,它推导不出 {} 是什么,就这么简单。
当然了 {}也有例外:对于使用关键词auto的类型推导中有一个例外,在复制列表初始化中将任何花括号初始化器列表均推导为std::initializer_list 。
auto p = { 1,2,3,4,5,6 };//复制列表初始化将花括号初始化器列表推导为std::initializer_list
auto p2 { 1,2,3,4,5,6 };//无法推导
auto p = { 1, 2, 3, 4, 5, 6 };
- 这里使用了 拷贝列表初始化(copy-list-initialization)。
- 编译器会推导
p的类型为std::initializer_list<int>,因为{ 1, 2, 3, 4, 5, 6 }是一个std::initializer_list。- 适用于 C++11 及更高版本。
auto p2 { 1, 2, 3, 4, 5, 6 };
- 这里使用了 直接列表初始化(direct-list-initialization)。
- 在 C++11 和 C++14 中,
p2的类型也会被推导为std::initializer_list<int>,和p一样。- 但在 C++17 及更高版本中,规则发生了变化:
auto p2 { 1, 2, 3, 4, 5, 6 };会报错,因为直接列表初始化不允许推导std::initializer_list,而auto p2 { 42 };会推导为int(而不是std::initializer_list<int>)。
149. 为什么C++中为什么不提倡使用 vector<bool>?
因为他既不是 vector,也不存 bool,更不是 STL。🤭
149.1 1. vector<bool> 并不是一个标准的 STL 容器
首先 vector<bool> 并不是一个通常意义上的 vector 容器,它甚至不能说是一个标准 STL 容器。
这个源自于历史遗留问题。 早在 C++98 的时候,就有 vector<bool> 这个类型了,但是因为当时为了考虑到节省空间的想法,所以 vector<bool> 里面不是一个 byte 一个 byte 储存的,它是一个 bit 一个 bit 储存的!
因为 C++ 没有办法直接去给一个 bit 来操作,所以用 operator[] 的时候,正常容器返回的应该是一个对应元素的引用,但是对于 vector< bool> 实际上访问的是一个"proxy reference"而不是一个"true reference",返回的是 std::vector<bool>::reference 类型的对象。
看下面的例子:
vector<bool> c{ false, true, false, true, false };
bool b = c[0];
auto d = c[0];
- 对于
b的初始化它其实暗含了一个隐式的类型转换。 - 而对于
d,它的类型并不是 bool,而是一个vector<bool>中的一个内部类_Bvector_base。
此时如果修改 d 的值,c 中的值也会跟着修改
d = true;
for(auto x : c) cout << x << ' '; // 1 1 0 1 0
注意不能写为
for(auto &x : c)
而如果 c 被销毁,d 就会变成一个悬垂指针,再对 d 操作就属于未定义行为。
而为什么说 vector<bool> 不是一个通常意义上的 vector 容器,就是因为它不能支持一些 vector 该有的基本操作,诸如取地址给指针初始化操作
vector<bool> c{0, 1, 0, 1, 0};
bool &tmp = c[0]; //错误,不能编译,对于引用来说,因为c[0]不是一个左值
bool *p = &c[0]; //错误,不能编译,因为无法将一个临时量地址给绑定到指针 ```
149.2 容易出错的情况
自己单纯使用 vector<bool> 基本不会踩坑,你就把它当作一个 vector<bit> 来用就行了。不过,如果真的希望使用 bit 级别的操作,那么 C++11 引入的 bitset 或许是更好的解决方案。
bitset不是 STL 容器,是 C++ 标准库的一部分,大小在编译期固定,因此不支持插入和删除元素,不是迭代器,不支持 iterator。压缩表示,每个值只占用 1bit。提供
vector<bool>特有的 flip 成员函数,还有一些列其他操作位集所特有的成员函数。如果不在意没有迭代器和动态改变大小,bitset正合适。
有坑的是写了个 vector<T>,然后一不小心 T = bool 了。然后你可能就会遇到各种意想不到的事情,因为 vector<bool> 不满足普通 vector 的所有用法。
在项目里遇到这种坑时,我不得不用特殊的特化让 vector<bool> 不要出现,变成 vector<uint8_t> 之类的东西。
- 此外,还可以用
deque<bool>替代,deque提供了几乎所有vector所提供的,而且deque<bool>保存真正的 bool 值。
所以坑的是 C++ 委员会一不小心做出的让目前的 vector<bool> 叫 vector<bool> 的错误决定,不是你想不用就能不用的。如果叫特殊的名字像 bool_vector 就挺好。
149.3 性能问题
每次访问都需要额外操作:因为 vector<bool> 并没有直接存储 bool,而是用位操作来处理多个 bool 压缩在一个字节里,这意味着每次你访问、修改元素时,都要通过位运算进行解压和压缩。相比普通的 vector<T>,这些额外操作会带来性能损耗,特别是在需要频繁读取和写入的场景下。
不支持指针操作:vector 本质上是一个连续存储的数组,在很多场景下,你可以直接获取内存的指针来进行更高效的操作。但是在 vector<bool> 中,因为数据是压缩存储的,不能直接拿到底层数据的指针,丧失了这种灵活性。
149.4 为什么说 vector<bool> 不是 STL 容器
stackoverflow
For space-optimization reasons, the C++ standard (as far back as C++98) explicitly calls out vector<bool> as a special standard container where each bool uses only one bit of space rather than one byte as a normal bool would (implementing a kind of "dynamic bitset"). In exchange for this optimization it doesn't offer all the capabilities and interface of a normal standard container.
In this case, since you can't take the address of a bit within a byte, things such as operator[] can't return a bool& but instead return a proxy object that allows to manipulate the particular bit in question. Since this proxy object is not a bool&, you can't assign its address to a bool* like you could with the result of such an operator call on a "normal" container. In turn this means that bool *pb =&v[0]; isn't valid code.
On the other hand deque doesn't have any such specialization called out so each bool takes a byte and you can take the address of the value return from operator[].
Finally note that the MS standard library implementation is (arguably) suboptimal in that it uses a small chunk size for deques, which means that using deque as a substitute isn't always the right answer.
149.5 源码分析
reference
std::vector<bool>,是类 sd::vector<T,std::allocator<T>> 的部分特化,为了节省内存,内部实际上是按bit来表征bool类型。从底层实现来看,std::vector<bool> 可视为动态的std::bitset,只是接口符合 std::vector,换个名字表达为 DynamicBitset 更为合理,也许就没那么多吐槽了。
149.5.1 _Bit_type
先来看看是怎么存储bit的。
在C++标准中,并没有单独的bit类型。GNU-STL使用一个typedef,将 unsigned long 定义为 _Bit_type,如此,一个_Bit_type 就有64 bit,也就可以存储64个bool类型变量。
在X86-64位CPU上,
unsigned long类型在 MSVC中4个字节,在GCC中 8个字节。
具体的看代码,我们可以发现,vector 特化了 vector<bool>,并继承自类 _Bvector_base:
class vector<bool, _Alloc> : protected _Bvector_base<_Alloc>
在类 _Bvector_base 所在的 namespace 中有一个 typedef 和 enum:
#define __CHAR_BIT__ 8
typedef unsigned long _Bit_type;
enum {_S_word_bit = int(__CHAR_BIT__ * sizeof(_Bit_type))
};
因此,当 std::vector<bool>要存储__n个bool类型时,底层实际上只需要__n个bit。
那__n个bit对应多少个_Bit_type呢?
在 std::_Bvector_base 类中有个static成员函数 _S_nword,其返回值就是 __n 个bit 所需的 _Bit_type个数。
static size_t _S_nword(size_t __n)
{ return (__n + int(_S_word_bit) - 1) / int(_S_word_bit); }
_S_word_bit表示每个_Bit_type可以容纳的 bit 数。
+ int(int(_S_word_bit) - 1)是上取整。
by the way
顺带考虑个问题,下面的demo中,vb 调用多少次push_back函数才会发生扩容?
std::vector<bool> vb(10);
由于 std::vector<bool> 底层是将 _Bit_type 中的 bit 映射成 bool类型的,也就是说分配一个_Bit_type对象,就能表征 64 个 bool 类型变量,因此上面的demo 中,vb 调用 push_back 函数 64 次后才会发生扩容,而不是 10 次。
149.5.2 std::_Bit_reference
讲完了_Bit_type,下面来看看怎么将一个bool类型变量映射到_Bit_type中的一个bit,这由类 std::_Bit_reference 实现的。
类 std::_Bit_reference 是 std::vector<bool> 中的基本存储单位。
比如,std::vector<bool>的 operator[]函数返回值类型就是std::_Bit_reference,而不是 bool 类型 。
template<typename _Alloc>
class vector<bool, _Alloc> : protected _Bvector_base<_Alloc>
{
//...
public:typedef _Bit_reference reference;typedef bool const_reference;typedef _Bit_reference* pointer;typedef const bool* const_pointer;typedef _Bit_iterator iterator;typedef _Bit_const_iterator const_iterator;reference operator[](size_type __n) { return begin()[__n]; }const_reference operator[](size_type __n) const { return begin()[__n]; }
//...
}
因此,为了让 operator[] 的返回值能和 bool 类型变量表现得一致,std::_Bit_reference 就必须满足两点:
std::_Bit_reference能隐式转换为 bool 类型- 能接受 bool 类型赋值
简而言之,就是下面的 demo 能编译过:
std::vector<bool> vb(3);bool state = vb[1]; // 1: std::_Bit_reference隐式转换为bool
vb[1] = true; // 2: 能接受bool类型赋值
在类 std::_Bit_reference 内部有两个字段:
_M_p:_Bit_type*类型,指向_Bit_tpe类型数据内存;_M_mask:_Bit_type类型,用于指示_M_p中的每一位是 0 还是 1,即表征的是 false 还是 true。
通过这两个字段,将一个 bool 类型变量映射到 _M_p 上的某个 bit。
类 std::_Bit_reference 的实现及注释如下。
struct _Bit_reference
{_Bit_type* _M_p; // 指向存储比特的整型块_Bit_type _M_mask; // 标识目标比特位置的掩码(如 0x0004 表示第3个比特)_Bit_reference(_Bit_type *__x, _Bit_type __y): _M_p(__x), _M_mask(__y) {}_Bit_reference() noexcept : _M_p(0), _M_mask(0) {}_Bit_reference(const _Bit_reference &) = default;///@brief 隐式转成 bool/// bool state = vb[1]; 会触发此函数// 两次!可以将整形转换为bool,很巧妙operator bool() const noexcept{ return !!(*_M_p & _M_mask); }///@brief 将 _M_p 的 _M_mask 位,设置为 __x 状态/// vb[1] = true; 会触发此函数_Bit_reference& operator=(bool __x) noexcept{if (__x)*_M_p |= _M_mask; // 1else*_M_p &= ~_M_mask;return *this;}// @brief 这个函数实际上调用了:// 1. 先调用了 operator bool() const noexcept// 2. 在调用了 _Bit_reference& operator=(bool __x) noexcept_Bit_reference& operator=(const _Bit_reference &__x) noexcept{ return *this = bool(__x); }bool operator==(const _Bit_reference &__x) const{ return bool(*this) == bool(__x); }bool operator<(const _Bit_reference &__x) const{ return !bool(*this) && bool(__x); }void flip() noexcept{ *_M_p ^= _M_mask; }
};
149.5.3 std::_Bit_iterator_base
自然,std::vector<T> 中的迭代器也不能用于 std::_Bit_reference,需要重新实现。
std::vector<bool> 中对每个 bit 的移动操作是基于类 std::_Bit_iterator类实现的,它的基类是std::_Bit_iterator_base。
struct _Bit_iterator : public _Bit_iterator_base {/**/}
类 std::_Bit_iterator_base ,继承了迭代器类 std::iterator,而std::iterator是个空类,其中定义了一些 typedef:
struct _Bit_iterator_base : public std::iterator<std::random_access_iterator_tag, bool> ;struct _Bit_iterator_base : public std::iterator<std::random_access_iterator_tag, bool> ;template<typename _Category,typename _Tp,typename _Distance = ptrdiff_t,typename _Pointer = _Tp*,typename _Reference = _Tp&>
struct iterator
{typedef _Category iterator_category;typedef _Tp value_type;typedef _Distance difference_type;typedef _Pointer pointer;typedef _Reference reference;
};
因此,继承的基类 std::iterator<std::random_access_iterator_tag, bool>,实例化后如下:
struct std::iterator<std::random_access_iterator_tag, bool>
{typedef std::random_access_iterator_tag iterator_category;typedef bool value_type;typedef ptrdiff_t difference_type;typedef bool* pointer;typedef bool& reference;
};
说完迭代器基类,下面来看看 std::_Bit_iterator_base 的实现。
类 std::_Bit_iterator_base中,有两个字段:
_M_p:指向数据体实体,和std::_Bit_reference中的_M_p相同;_M_offset:指示当前正遍历到_M_p中第_M_offset个bit(从0开始计数),这和std::_Bit_reference中的_M_mask字段含义不同。
还有三个比较重要的成员函数:
_M_bump_up:前进一位,是后面实现++operator重载的基础;_M_bump_down:后退一位,是后面实现--operator重载的基础;_M_incr:实现operator+=、operator-=重载。
类 std::_Bit_iterator_base的完整实现及注释如下。
struct _Bit_iterator_base : public std::iterator<std::random_access_iterator_tag, bool>
{_Bit_type* _M_p; // 指向当前存储块(如 unsigned long)unsigned int _M_offset; // 当前比特在 *_M_p 中的偏移(0 ~ _S_word_bit-1)_Bit_iterator_base(_Bit_type *__x, unsigned int __y): _M_p(__x), _M_offset(__y) {}/// @brief 前进一个bit,如果当前_M_p遍历完,则到下一个 _Bit_type 对象void _M_bump_up(){if (_M_offset++ == int(_S_word_bit) - 1) // 到达当前块的最后一个比特?{_M_offset = 0; // 重置偏移++_M_p; // 移动到下一个存储块}}/// @brief 后退一个bit,如果到达 _M_p 的首地址,则进入到前一个 _Bit_type 对象void _M_bump_down(){if (_M_offset-- == 0){_M_offset = int(_S_word_bit) - 1;--_M_p;}}/// @brief 前进 __i 个bitvoid _M_incr(ptrdiff_t __i){difference_type __n = __i + _M_offset;_M_p += __n / int(_S_word_bit); // 计算跨越的完整块数__n = __n % int(_S_word_bit); // 剩余比特数if (__n < 0) // 处理负数(后退){__n += int(_S_word_bit);--_M_p;}_M_offset = static_cast<unsigned int>(__n);}/** 下面是关于迭代器的比较运算重载 **/bool operator==(const _Bit_iterator_base &__i) const{ return _M_p == __i._M_p && _M_offset == __i._M_offset; }bool operator<(const _Bit_iterator_base &__i) const{ return _M_p < __i._M_p || (_M_p == __i._M_p && _M_offset < __i._M_offset); }bool operator!=(const _Bit_iterator_base &__i) const{ return !(*this == __i); }bool operator>(const _Bit_iterator_base &__i) const{ return __i < *this; }bool operator<=(const _Bit_iterator_base &__i) const{ return !(__i < *this); }bool operator>=(const _Bit_iterator_base &__i) const{ return !(*this < __i); }
};
149.5.4 std::_Bit_iterator
类std::Bit_iterator 就是其基类std::_Bit_iterator_base 的wrapper,重载 -- 和 ++ 操作以及解引用等运算操作。
类 std::_Bit_iterator的部分实现及注释如下。
struct _Bit_iterator : public _Bit_iterator_base{typedef _Bit_reference reference;typedef _Bit_reference* pointer;typedef _Bit_iterator iterator;_Bit_iterator() : _Bit_iterator_base(0, 0) {}_Bit_iterator(_Bit_type* __x, unsigned int __y) : _Bit_iterator_base(__x, __y) {}iterator _M_const_cast() const{ return *this; }/// @brief 解引用运算符,返回的也是 std::_Bit_reference 类型reference operator*() const{ return reference(_M_p, 1UL << _M_offset); }/// @brief 索引reference operator[](difference_type __i) const{ return *(*this + __i); }/** 下面是运算符重载 **//// @brief 每次前进一个bititerator& operator++(){_M_bump_up();return *this;}/// @brief 每次后退一个bititerator& operator--(){_M_bump_down();return *this;}iterator& operator+=(difference_type __i){_M_incr(__i);return *this;}iterator& operator-=(difference_type __i){*this += -__i;return *this;}//...};
149.5.5 std::_Bvector_impl_data
类 std::_Bvector_impl_data 记录了 std::_Bvector_base 的数据存储,里面有三个字段:
_M_start:指向内存的首地址,即begin()函数的返回值;_M_finish:下一个元素要插入的位置,即end()函数的返回值;_M_end_of_stroage:整个可用内存区间是[_M_start, _M_end_of_storage),_M_end_of_stroage指向的就是这块内存的最后一个 bit 的后一个位置。
std::_Bvector_impl_data 的实现及注释如下。
truct _Bvector_base
// ...
{struct _Bvector_impl_data{_Bit_iterator _M_start; // 迭代器_Bit_iterator _M_finish; // 迭代器_Bit_pointer _M_end_of_storage; // 指针_Bvector_impl_data() noexcept: _M_start(),_M_finish(),_M_end_of_storage(){}/// @brief 移动构造函数_Bvector_impl_data(_Bvector_impl_data &&__x) noexcept: _M_start(__x._M_start),_M_finish(__x._M_finish),_M_end_of_storage(__x._M_end_of_storage){__x._M_reset();}void _M_move_data(_Bvector_impl_data &&__x) noexcept{this->_M_start = __x._M_start;this->_M_finish = __x._M_finish;this->_M_end_of_storage = __x._M_end_of_storage;__x._M_reset();}void _M_reset() _GLIBCXX_NOEXCEPT{_M_start = _M_finish = _Bit_iterator();_M_end_of_storage = _Bit_pointer();}};//...
}
149.5.6 std::_Bvector_impl
类std::_Bvector_impl_data 只具有记录内存使用情况的三个字段,那谁来分配内存?
类std::_Bvector_impl 继承了两个类:
_Bit_alloc_type:负责分配内存std::_Bvector_impl_data:负记记录内存的使用情况
如此,才使 std::_Bvector_impl 成为实现std::vector<bool>的核心:
// _Bvector_base 类结构
template<typename _Alloc> struct _Bvector_base
{typedef typename __gnu_cxx::__alloc_traits<_Alloc>::template rebind<_Bit_type>::other _Bit_alloc_type;typedef typename __gnu_cxx::__alloc_traits<_Bit_alloc_type>_Bit_alloc_traits;typedef typename _Bit_alloc_traits::pointer _Bit_pointer;struct _Bvector_impl_data{/*...*/};struct _Bvector_impl: public _Bit_alloc_type, // 负责内存分类public _Bvector_impl_data // 记录内存使用情况{/*...*/};
};
_Bit_alloc_type
类_Bit_alloc_type,实际上是类 std::_Bvector_base 中的一个typedef:
typedef typename __gnu_cxx::__alloc_traits<_Alloc>::template rebind<_Bit_type>::other _Bit_alloc_type;
因此,_Bit_alloc_type 实际上就是 std::allocator<_Bit_type>。
如果不了解 类__gnu_cxx::__alloc_traits 、rebind函数,可以看看我之前写的一期 【提高C++程序员的自我修养 from 剖析STL内存分配器】,在里面详细分析过。
by the way
std::vector<bool> 的全称是 std::vector<bool, std::allocator<bool>>,最初传入的分配器是std::allocator<bool>,是为bool类型变量分配内存的。
但由STL对bool类型做了特化,内部并不是存储bool类型,而是_Bit_type类型,因此 std::allocator 现在需要为_Bit_type类型分配内存,这就需要通过 rebind 函数来获得获得std::allocator<_Bit_type>。
std::_Bvector_impl_data
_Bit_alloc_type 负责获得_Bit_type类型的内存分配器 std::allocator<_Bit_type>,而所得的内存就是由 _Bvector_impl_data 中的字段来记录。
因此,std::_Bvector_impl 继承了上面两个类后,就完整了。
类std::_Bvector_impl的完整实现及注释如下。
struct _Bvector_impl: public _Bit_alloc_type, _Bvector_impl_data// _Bit_alloc_type:内存分配器(通常为空基类优化,EBO)。// _Bvector_impl_data:存储实际数据指针和容量信息。
{
public:// 初始化分配器,数据指针(_M_start等)由 _Bvector_impl_data 默认初始化为 nullptr_Bvector_impl() noexcept(is_nothrow_default_constructible<_Bit_alloc_type>::value): _Bit_alloc_type() { }// 用用户提供的分配器构造,数据指针仍为空。_Bvector_impl(const _Bit_alloc_type &__a) noexcept: _Bit_alloc_type(__a) { }// 隐式生成的移动构造:// 由于 _Bit_alloc_type 是空基类,且 _Bvector_impl_data 已实现移动语义,直接默认即可。_Bvector_impl(_Bvector_impl&&) = default;/// @brief 获得 _M_end_of_storage 指向的地址// 这里不直接返回其地址,而是先 [-1] 再 +1// 可能是历史代码的“防御性编程”习惯,或未简化的遗留设计_Bit_type* _M_end_addr() const noexcept {if (this->_M_end_of_storage)return std::__addressof(this->_M_end_of_storage[-1]) + 1;return 0;}};
说完两个内嵌类,下面来看看 std::_Bvector_base本身。
它只有一个字段:
struct _Bvector_impl: public _Bit_alloc_type, _Bvector_impl_data;_Bvector_impl _M_impl;
而整个类 std::_Bvector_base ,主要是针对_M_impl 的内存操作,并无数据操作:
_M_allocate函数:分配内存_M_deallocate函数:释放使用_M_allocate函数分配的内存- 其他一些辅助函数
完整的代码,见代码注释。
template <typename _Alloc>
struct _Bvector_base
{typedef typename __gnu_cxx::__alloc_traits<_Alloc>::template rebind<_Bit_type>::other _Bit_alloc_type;typedef typename __gnu_cxx::__alloc_traits<_Bit_alloc_type> _Bit_alloc_traits;typedef typename _Bit_alloc_traits::pointer _Bit_pointer;struct _Bvector_impl_data { /** ... **/ };struct _Bvector_impl { /** ... **/ };public:typedef _Alloc allocator_type; // std::allocator<bool>_Bvector_base() = default;_Bvector_base(const allocator_type& __a) : _M_impl(__a) { }_Bvector_base(_Bvector_base &&) = default;~_Bvector_base(){ this->_M_deallocate(); }/// @brief 获取内存分配器,实质就是子类对象转换为父类_Bit_alloc_type& _M_get_Bit_allocator() noexcept{ return this->_M_impl; }/// @brief 由 std::allocator<_Bit_type> 构造 std::allocator<bool>allocator_type get_allocator() const noexcept{ return allocator_type(_M_get_Bit_allocator()); }protected:_Bvector_impl _M_impl; //!!! 唯一字段/// @brief 分配 _s_nword(__n) 个字节的内存_Bit_pointer _M_allocate(size_t __n){ return _Bit_alloc_traits::allocate(_M_impl, _S_nword(__n)); }/// @brief 析构内存void _M_deallocate() {if (_M_impl._M_start._M_p) {const size_t __n = _M_impl._M_end_addr() - _M_impl._M_start._M_p;_Bit_alloc_traits::deallocate(_M_impl,_M_impl._M_end_of_storage - __n,__n);_M_impl._M_reset();}}void _M_move_data(_Bvector_base &&__x) noexcept{ _M_impl._M_move_data(std::move(__x._M_impl)); }static size_t _S_nword(size_t __n){ return (__n + int(_S_word_bit) - 1) / int(_S_word_bit); }
};
149.5.7 std::vector<bool, _Alloc>
好嘞,终于讲解到 std::vector<bool>了,它就是个部分特化的类:
// 原型
template<typename _Tp, typename _Alloc = std::allocator<_Tp> >
class vector : protected _Vector_base<_Tp, _Alloc> {//...
};// 第一个参数特化为bool
template <typename _Alloc>
class vector<bool, _Alloc> : protected _Bvector_base<_Alloc> {typedef _Bvector_base<_Alloc> _Base; // 核心//...public:typedef _Alloc allocator_type; // std::allocator<bool>//...protected:using _Base::_M_allocate;using _Base::_M_deallocate;using _Base::_S_nword;using _Base::_M_get_Bit_allocator;explicit vector(size_type __n, const allocator_type &__a = allocator_type()): vector(__n, false, __a) // 委托构造函数{ }vector(size_type __n, const bool &__value, const allocator_type &__a = allocator_type()): _Base(__a){_M_initialize(__n);_M_initialize_value(__value);}reference operator[](size_type __n){return *iterator(this->_M_impl._M_start._M_p + __n / int(_S_word_bit), //第几个数据块__n % int(_S_word_bit)); // 该数据块的第几个偏移}//...
};
因此,std::vector<bool> 的默认内存分配器仍然是std::allocaotr<bool>,这就解释了类std::_Bvector_base中需要通过 __alloc_traits 获得std::allocator<_Bit_type>的必要性。
而std::vector<bool>的构造函数,主要是调用了_M_initialize、_M_initialize_value两个函数。
_M_initialize
_M_initialize 函数,其输入参数__n 对于函数使用者来说表达的是__n个bool类型,但是对于设计者而言,__n是被视为__n个bit。这个函数主要有两步:
- 调用基类
std::_Bvector_base的_M_allocate函数分配内存; - 使用
std::_Bvector_impl_data类的成员变量来记录这块内存的使用情况。
先看下整体实现:
template <typename _Alloc>
void vector<bool, _Alloc>::_M_initialize(size_type __n)
{if (__n) {_Bit_pointer __q = this->_M_allocate(__n); // 分配__n个字节的内存this->_M_impl._M_end_of_storage = __q + _S_nword(__n); // 内存末尾this->_M_impl._M_start = iterator(std::__addressof(*__q), 0); // 内存首地址}else {this->_M_impl._M_end_of_storage = _Bit_pointer();this->_M_impl._M_start = iterator(0, 0);}// 指向第一个未使用的bitthis->_M_impl._M_finish = this->_M_impl._M_start + difference_type(__n);
}
由于std::vector<bool>内部是按照_Bit_type为基本类型进行存储的,_S_nword(__n) 计算的是存储__n 个bit至少需要几个 _Bit_type 。因此,_M_end_of_storage :
- 当
__n是_S_word_bit的整倍数,_M_end_of_storage指向的地址就是__q + __n / _S_word_bit; - 否则,就是指向了
__q + __n / _S_word_bit + 1。
因此,_M_end_of_storage 指向的就是可使用内存的下一个字节,整个可用内存区间是[_M_start, _M_end_of_storage)。
_M_initialize_value
由_M_initialize_value函数,将[_M_start,_M_end_of_storage)区间的值初始化为 __x。
///@brief 为[start, end_of_storage) 区间全部赋值为 __xvoid _M_initialize_value(bool __x){if (_Bit_type* __p = this->_M_impl._M_start._M_p)__builtin_memset(__p,__x ? ~0 : 0,(this->_M_impl._M_end_addr() - __p) * sizeof(_Bit_type));}
push_back
最后,再来看看 std::vector<bool> 是怎么添加元素的,下面以push_back函数为例。
- 先检测当前是否还有可用内存,即
_M_finish._M_p != _M_end_of_storage,如果还有则直接在_M_finish._M_offset位置处构造对象; - 否则,需要扩容,再插入。这由
_M_insert_aux函数完成。
完整如下:
template <typename _Alloc>void vector<bool, _Alloc>::push_back(bool __x){if (this->_M_impl._M_finish._M_p != this->_M_impl._M_end_addr())*this->_M_impl._M_finish++ = __x;else_M_insert_aux(end(), __x);}iterator end() noexcept{ return this->_M_impl._M_finish; }
_M_insert_aux
_M_insert_aux函数,表达的语义是 __pos位置插入__X,自然就需要考虑[_M_start, _M_end_addr)区间是否还有多余的内存了。
template <typename _Alloc>void vector<bool, _Alloc>::_M_insert_aux(iterator __position, bool __x){if (this->_M_impl._M_finish._M_p != this->_M_impl._M_end_addr()) {// 将 [__position, _M_finish) 后移动一位std::copy_backward(__position,this->_M_impl._M_finish,this->_M_impl._M_finish + 1);// 将 __x 插入在 __position 位置*__position = __x;++this->_M_impl._M_finish;}else {/*** 需要扩容 ***/const size_type __len = _M_check_len(size_type(1), "vector<bool>::_M_insert_aux");_Bit_pointer __q = this->_M_allocate(__len); // 新的内存iterator __start(std::__addressof(*__q), 0); // 指向新的内存首地址// [_M_start, __pos) 移动到 __start 开始的位置iterator __i = _M_copy_aligned(begin(), __position, __start);// 将 __x 赋值给 __i 位置的值*__i++ = __x;// [_pos, _M_finish) 移动到 __i 开始的位置iterator __finish = std::copy(__position, end(), __i);// 释放原来的内存this->_M_deallocate();// 调整地址this->_M_impl._M_end_of_storage = __q + _S_nword(__len);this->_M_impl._M_start = __start;this->_M_impl._M_finish = __finish;}}
operator[]
最后,再完整地分析下赋值流程,更好地将前文的知识穿起来。
std::vector<bool> vb(3);vb[1] = true; // 由如下两步完成
(1) 获得 _Bit_reference 对象
首先,根据__n 定位到具体的第几个_Bit_type对象及该对象中的某位,最终返回的是 _Bit_reference类型:
*iterator(this->_M_impl._M_start._M_p + __n / int(_S_word_bit),__n % int(_S_word_bit));
注意,返回的_Bit_reference 对象是由如下函数得到的:
reference std::_Bit_iterator::operator*() const{ return reference(_M_p, 1UL << _M_offset); }
也就是说,返回的_Bit_reference对象的_M_mask字段中, 仅需要改变值的那位是1,其他位置都是0。
(2) 给_Bit_reference对象赋值
此时调用的是_Bit_reference 的operator=函数,仅改变需要改变的那位,对其他bit不会改变。
_Bit_reference& _Bit_reference::operator=(bool __x) noexcept
{if (__x)*_M_p |= _M_mask;else*_M_p &= ~_M_mask;return *this;
}
149.5.8 结尾
经过上面的源码分析,最后我们再来看看一个问题:一个std::vector<bool>对象占据多少字节?
sizeof(std::vector<bool>{}); // ???
其大小等效于:
sizeof(std::_Bvector_impl_data);
---
sizeof(_M_start); // 12
padding // 16
sizeof(_M_finish); // 28
padding // 32
sizeof(_M_end_of_storage); // 40
因此,经过字节对齐后,一个std::vector<bool>的大小是40个字节。
std::vector<bool>的源码分析,到此为止,下期再见。
150. ADL
150.1 参数依赖查找
参数依赖查找(Argument-Dependent Lookup,ADL),也称为"Koenig查找",是 C++ 中影响函数调用解析方式的一项重要特性。
ADL 是指:当编译器在查找函数名时,不仅会在常规的作用域中查找,还会在函数参数类型所属的命名空间中进行额外的查找。
工作原理:
- 常规查找:首先在调用点所在的作用域中查找函数
- ADL扩展查找:然后在每个实参类型的命名空间(以及它们关联的命名空间)中查找
典型示例:
#include <algorithm>
#include <iostream>using std::cout;
using std::endl;
using std::swap;namespace NS1
{struct S1{};void swap(S1& s1, S1& s2){cout << "You've been tricked :)" << endl;}
}int main(int argc, char** argv)
{NS1::S1 a, b;swap(a, b);cout << "--->" << endl;(swap)(a, b);cout << "--->" << endl;std::swap(a, b);cout << "--->" << endl;return 0;
}
重要特点:
-
减少冗长代码:不需要写完整的命名空间限定
-
支持运算符重载:使运算符重载更自然,最经典的就是流运算符
std::cout << "Hello"; // 通过ADL找到operator<< -
查找范围:包括参数类型本身、其模板参数、基类等关联的命名空间
注意事项:
- 可能导致意外的函数被调用,例如上面的 swap
- 可能与模板结合时产生复杂情况
- 可以通过
(function)(args)语法禁用 ADL- C++ 标准规定,如果函数名被括号包围(例如
(func)(args)),则名称查找 不会 进行 ADL,而只会在常规的作用域(如当前作用域、全局作用域、类作用域等)中查找该函数。
- C++ 标准规定,如果函数名被括号包围(例如
ADL 是 C++ 重载决议过程中的重要环节,理解它对编写正确的模板代码和运算符重载至关重要。
150.2 与 namespace std 联动的一个意外 Bug
就是我们上面给出的 swap 的例子,在我们的 main 函数所处的作用域(::)中,并没有自定义 swap 函数,但是当我们调用 swap(a,b) 时,却调用了 ::NS1 下的 swap 版本,也就是 ADL 惹的祸。当然我们可以通过对函数名加括号解决该问题,但我们无法提前得知某个标准库函数是否会被某个类所在 namespace 重载,因此,一个好的解决方案是使用 std::move,类似的还有 std::forward,std::move 等等。
151. 内存重叠
memmove 是一个 C 标准库函数,用于将一块内存区域的内容复制到另一块内存区域。与 memcpy 不同的是,memmove 能够正确处理源内存区域和目标内存区域发生重叠的情况。
函数原型:
#include <string.h>void *memmove(void *dest, const void *src, size_t n);
参数说明:
dest: 指向目标内存区域的指针。src: 指向源内存区域的指针。n: 需要复制的字节数。
返回值:
memmove 函数返回指向目标内存区域 dest 的指针。
工作原理:
memmove 的关键在于它如何处理内存重叠。它通过以下两种方式之一来确保正确复制:
- 当目标区域的起始地址在源区域之后,或者源区域的起始地址在目标区域之后(即没有重叠或目标区域在源区域之前):
memmove的行为与memcpy类似,它可以简单地从源地址开始逐字节复制到目标地址。 - 当源区域和目标区域发生重叠时:
- 如果目标区域的起始地址小于源区域的起始地址:
memmove会从源地址的起始位置开始,向目标地址的起始位置逐字节复制。 - 如果目标区域的起始地址大于源区域的起始地址:
memmove会从源地址的末尾开始,向目标地址的末尾逐字节复制。
- 如果目标区域的起始地址小于源区域的起始地址:
这种处理方式避免了在复制过程中覆盖尚未复制的源数据。
使用示例:
假设我们有以下内存区域:
char buffer[10] = "ABCDEFGHIJ";
示例 1: 没有重叠
char destination[5];
memmove(destination, buffer, 5);
// destination 现在包含 "ABCDE"
示例 2: 目标区域在源区域之后重叠
memmove(buffer + 2, buffer, 5);
// 复制 buffer[4] 到 buffer[6], buffer[3] 到 buffer[5], ..., buffer[0] 到 buffer[2]
// buffer 现在可能包含 "ABABCDEHIJ"
示例 3: 目标区域在源区域之前重叠
memmove(buffer, buffer + 2, 5);
// 复制 buffer[2] 到 buffer[0], buffer[3] 到 buffer[1], ..., buffer[6] 到 buffer[4]
// buffer 现在可能包含 "CDEFGFGHIJ"
与 memcpy 的区别:
memcpy不保证在源和目标内存区域重叠时能正确工作。如果发生重叠,memcpy的行为是未定义的,可能会导致数据损坏。memmove可以安全地处理内存重叠的情况。
何时使用 memmove:
当你不确定源内存区域和目标内存区域是否会重叠时,为了保证程序的正确性,应该使用 memmove。如果可以确定内存区域不会重叠,那么使用 memcpy 可能会稍微快一些,因为它不需要处理重叠的情况。
152. First-Class Citizen
"First-Class" 是编程语言理论中的一个概念,用来描述某种编程语言中的实体 (entity),如果它满足以下一个或多个条件,那么它就被认为是“一等公民 (first-class citizen)”。这些条件通常包括:
- 可以作为变量的值进行赋值 (Can be assigned to variables): 你可以将这种实体存储在一个变量中。
- 可以作为参数传递给函数 (Can be passed as arguments to functions): 你可以将这种实体作为函数的输入。
- 可以作为函数的返回值 (Can be returned from functions): 函数可以将这种实体作为其输出结果。
- 可以在运行时创建 (Can be created at runtime): 你可以在程序执行的过程中动态地创建这种实体。
将 "First-Class" 应用到编程语言中的不同实体:
- 基本数据类型 (e.g., integers, floats, booleans): 在大多数编程语言中,基本数据类型都是一等公民。你可以将它们赋值给变量,作为参数传递,从函数返回,并在运行时创建字面量。
- 对象 (Objects): 在面向对象编程语言中,对象通常也是一等公民。你可以将对象赋值给变量,传递给方法,从方法返回,并通过构造函数在运行时创建。
- 函数 (Functions): 函数是否为一等公民是不同编程语言之间一个重要的区别:
- 在支持一等函数 (First-Class Functions) 的语言中 (例如 Python, JavaScript, Lisp, Haskell 等): 函数可以被赋值给变量,作为参数传递给其他函数(这被称为“高阶函数”),可以作为函数的返回值(创建并返回新的函数或闭包),并且可以在运行时动态创建(例如通过 lambda 表达式或匿名函数)。
- 在 C++ 中 ** 函数本身不是一等公民。你不能直接将一个函数赋值给一个变量。但是,你可以通过函数指针、函数对象(包括 lambda 表达式)和
std::function等机制来间接地实现类似的功能,使得函数在某种程度上可以像一等公民一样被操作。此外,C 语言、Pascal 和早期版本的 Fortran 已经某些脚本语言的早期版本在函数作为一等公民方面的支持不完备。需要注意的是,许多较新的语言或者旧语言的现代版本都在朝着支持一等函数的方向发展,C++ 引入 lambda 表达式和 std::function 等机制就是很好的例子。**
- 类 (Classes): 在某些语言中(例如 Python),类本身也可以被视为一等公民,可以动态创建、赋值给变量等。但在其他语言中(例如 Java, C++),类的地位可能略有不同。
"First-Class" 的重要性:
拥有“一等公民”特性的实体可以提高编程的灵活性和表达能力。例如,一等函数允许:
- 函数式编程范式: 可以更容易地使用高阶函数、匿名函数和闭包等函数式编程概念。
- 代码的模块化和重用: 可以将行为(函数)像数据一样传递和组合。
- 事件驱动编程: 可以将函数作为事件处理程序传递。
- 策略模式和命令模式等设计模式的实现。
总而言之,"First-Class" 描述了编程语言中某种实体所拥有的最高级别的操作权限,使其能够像语言中的其他基本值一样被自由地使用和组合。函数作为一等公民是许多现代编程语言的重要特性。
153. side effect
在编程语言中,副作用(side effect)是指函数在返回值之外,对程序状态所做的任何可观察的改变。具体来说,副作用包括:
- 修改变量状态:改变函数外部的变量值
- 修改对象状态:改变对象的成员变量
- 进行输入/输出操作:如读写文件、屏幕显示、网络通信等
- 抛出异常:异常传播改变了程序的正常控制流
- 修改静态或全局变量:改变程序的全局状态
纯函数是没有副作用的函数,它具有两个关键特性:
- 对于相同的输入,总是产生相同的输出
- 不会修改任何外部状态或产生可观察的副作用
154. 从时空的角度理解堆和栈
圆角骑士魔理沙
因为堆栈管理着空间与时间。
堆作为一个key-value store,当然自然承担了管理空间的任务。
另一方面,栈本质上是程序的延续,管理着程序的历史 - 当我进行完本操作后,接着应该执行啥?
这样划分,是因为自由度的不同。heap 是自由的,但是 stack 有 LIFO 的限制。一般来说,对程序求值是一个 post order traversal。这时候,利用 LIFO 的限制可以大大提高性能。
但是,有没有可能,你不需要这样划分?
首先,很明显,有的程序不进行任何 malloc/free/new。这些程序自然不需要 heap;对应的,也有程序不进行递归,这时候你可以预分配所有变量(包括延续),你也不需要 stack。
除了上面这两个‘作弊回答’,也可以用 stack 管理空间,或者用 heap 管理时间。
在计算机早期发展的时候,性能比自由度重要。这时候,我们会牺牲掉诸如高阶函数的一等公民地位,通过降低自由度用 stack 控制程序空间。The Essence of Algol 里对如此的 stack discipline 做出了总结。
而在现代,自由度反压性能一头。这时候,我们会发现,不止 heap 是需要的,甚至 stack 也不太能胜任控制时间的工作了。对 fork-join 或者 coroutine,程序无法由单个 stack 表示,而需要演化成多个 heap-allocated stack。当这个发展到极致,就是 call-cc, shift/reset 等 control operator,以及 heap-allocated continuation object。
当然,小孩子做选择,成年人全都要!
<CONS should not CONS its arguments, part II: Cheney on the M.T.A.>里同时满足了两点:
- 可以直接调用C函数,同时stack-allocate一切对象。这代表你的函数调用/new的性能损失很低,同时也保留了最重要的兼容性(跟C的兼容性)
- 引用不会失效!
换句话说,stack-allocated 对象生命周期如同 heap-allocated 一般!
具体来说,这篇 paper:
- 首先,stack 只进不出,这时候引用自然不会失效 - 但是你依旧需要回收内存的
- 这好办,只要 stack 到达一定大小,我们就进行 mark-copy,把一切live object 放一起,然后就是 stack frame 开头,接着长!
但是,只增不减的 stack 还能叫 stack 吗?我个人认为更接近 ring-buffer。stack/heap/ring-buffer 并不本质,时空才是本质。
155. Low-Level vs. Underlying
在计算机科学和软件开发中,低层(low-level) 和 底层(underlying) 是两个相关但略有不同的概念:
低层(low-level)通常指的是:
- 更接近硬件的抽象层级
- 提供较少的自动化和便捷功能
- 需要更详细的手动控制
- 通常执行效率更高,但编程难度更大
例如:
- 汇编语言是低层语言,而Python是高层语言
- 直接操作内存地址是低层操作,使用自动内存管理是高层操作
底层(underlying)通常指的是:
- 支撑上层系统运行的基础结构
- 提供核心功能的系统或技术
- 被其他系统所依赖的组件
- 通常对用户不可见,但对系统运行至关重要
例如:
- 操作系统是应用程序的底层系统
- TCP/IP协议是互联网应用程序的底层协议
- 数据库是许多应用程序的底层存储机制
两者的关系:
- 底层系统通常也是低层系统,因为它们需要直接与硬件交互
- 但不是所有低层系统都是底层系统,有些低层系统可能是专用的,而非作为其他系统的基础
- 两者都强调了技术栈中的分层结构,只是侧重点不同
简单来说,低层(low-level) 更多地描述抽象程度和复杂性,而 底层(underlyig) 更强调功能和依赖关系。
156. C is not a low-level language
这篇文章的核心观点是:尽管 C 语言传统上被称为 “低级语言”(low-level language),但随着计算机体系结构的发展,它已经不再真正贴近硬件,反而存在许多抽象和性能问题。以下是关键点的解析:
156.1 1. 标题的颠覆性
- 传统观点:C语言常被称为“低级语言”,因为相比 Python/Java 等,它更接近硬件(如直接操作内存、指针等)。
- 作者反驳:现代 CPU 架构(如多核、缓存、流水线、SIMD 指令)和编译器优化已高度复杂,C 的抽象模型(如线性内存、顺序执行)与真实硬件严重脱节。
156.2 2. C 语言为何不再是“低级”语言?
156.2.1 (1) 硬件演进 vs. C的抽象模型
- 内存模型:
- C 假设内存是扁平的、连续的(如数组是连续内存块)。
- 但现代 CPU 有多级缓存(Cache)、预取(Prefetching)、乱序执行(Out-of-Order Execution),内存访问性能高度依赖局部性(Locality)。
- C 无法直接表达这些硬件特性(如显式控制缓存)。
- 并行性:
- C 默认是顺序执行的,而现代 CPU 依赖多核、超线程、SIMD(单指令多数据)。
- 需要手动写线程(如 pthread)或依赖编译器扩展(如 OpenMP),但 C 标准本身不原生支持这些。
156.2.2 (2) 编译器的“魔法”优化
- 现代编译器(如 GCC、Clang)会大幅重写C代码(如循环展开、内联、向量化),实际运行的机器码可能与C源代码逻辑差异极大。
- 开发者若不了解优化规则,可能写出“看似高效但实际性能极差”的代码(如误用
volatile或错误的内存对齐)。
156.2.3 (3) 安全问题
- C 的“低级”特性(如指针、无边界检查)导致大量漏洞(缓冲区溢出、Use-After-Free)。
- 硬件防御机制(如 NX 位、ASLR)和语言运行时(如 Rust 的借用检查)已超越 C 的能力。
156.3 3. 为什么人们仍误认为C是低级语言?
- 历史惯性:C诞生于1970年代(PDP-11时代),当时硬件简单,C的抽象与硬件几乎一一对应。
- 教学误导:许多课程仍用C讲解“接近硬件”的编程,但忽略了现代体系结构的复杂性。
- 术语混淆:“低级语言”本应指直接映射硬件操作的语言(如汇编),而C只是“比高级语言更底层”。
156.4 4. 替代方案是什么?
作者可能暗示:
- 特定领域的低级语言:如Rust(安全且支持现代并发)、Halide(专为图像处理优化)。
- 直接使用编译器扩展:如LLVM IR或 intrinsics(显式调用CPU指令)。
- 接受更高层抽象:如用Python/Julia编写,依赖JIT编译优化。
156.5 5. 对开发者的启示
- 不要迷信C的性能:手动优化可能适得其反,需依赖剖析工具(如perf)。
- 理解硬件实际行为:学习缓存、流水线、向量化等知识,而非仅依赖C语法。
- 评估语言选择:在需要极致性能时,考虑Rust或特定DSL(领域专用语言)。
156.6 总结
文章并非否定C的价值,而是指出:
“C的抽象模型已无法代表现代硬件,继续称其为‘低级语言’会误导开发者对性能和安全的理解。”
这一观点对系统编程、嵌入式开发和高性能计算领域尤为重要。
157. C++ 为什么不允许从 void* 到其它指针类型的转换
157.1 真的不行吗?
int *p = ((void*)0);
对于上面的语句,在 C 语言中是可以编译通过的,而在 C++ 中则会编译错误:
a.cpp: In function 'int main()':
a.cpp:5:23: error: invalid conversion from 'void*' to 'int*' [-fpermissive]5 | int *p = ((void*)0);| ^| || void*
a.cpp:5:10: warning: unused variable 'p' [-Wunused-variable]5 | int *p = ((void*)0);| ^
我们在稍微修改一下代码:
int *p = static_cast<int*>((void*)0);
可以发现,在 C++ 下编译通过了。
由此我们可以得到一个结论:C++ 禁止掉了 C 语言中从 void* 到其它指针类型的隐式类型转换。
157.2 为什么?
为什么 C++ 要禁止掉这种隐式的指针转换,又或者说,C++ 为什么必须显式的对 void* 进行类型转换?
Stroustrup 在 FAQ 中给出了一个很有代表性的例子:
[Why must I use a cast to convert from void*?](Stroustrup: C++ Style and Technique FAQ)
In C, you can implicitly convert a void* to a T*. This is unsafe. Consider:
#include<stdio.h>int main() { char i = 0; char j = 0; char u = 0; char v = 0; char k = 0;char* p = &i; void* q = p; int* pp = q; /* unsafe, legal C, not C++ */printf("0x%x 0x%x\n", &i, &j); // 0xec964d63 0xec964d64 printf("%d %d %d %d %d\n",i,j,u,v,k); // 0 0 0 0 0 *pp = -1; /* overwrite memory starting at &i, total 4 Bytes */ printf("%d %d %d %d %d\n",i,j,u,v,k); // -1 -1 -1 -1 0 }The effects of using a T* that doesn't point to a T can be disastrous. Consequently, in C++, to get a T* from a void* you need an explicit cast. For example, to get the undesirable effects of the program above, you have to write:
int* pp = (int*)q;or, using a new style cast to make the unchecked type conversion operation more visible:
int* pp = static_cast<int*>(q);Casts are best avoided.
One of the most common uses of this unsafe conversion in C is to assign the result of malloc() to a suitable pointer. For example:
int* p = malloc(sizeof(int));In C++, use the typesafe new operator:
int* p = new int;Incidentally, the new operator offers additional advantages over malloc():
- new can't accidentally allocate the wrong amount of memory,
- new implicitly checks for memory exhaustion, and
- new provides for initialization
For example:
typedef std::complex<double> cmplx;// C style: cmplx* p = (cmplx*)malloc(sizeof(int)); /* error: wrong size *//* forgot to test for p==0 */ if (*p == 7) { /* ... */ } /* oops: forgot to initialize *p */// C++ style: cmplx* q = new cmplx(1,2); // will throw bad_alloc if memory is exhausted if (*q == 7) { /* ... */ }
158. list 二分查找的比较次数
对于 list 中的任意节点,定位它的时间复杂度是 \(O(N)\),因此你可以容易的得出,当我们对 list 执行 binary_search 时,时间复杂也是 \(O(N)\) 的。
容易出错的点是,你误认为,由于 list 中的节点无法随机访问,因此这里二分查找实质上是一个元素一个元素的查找,也即此时二分查找并没有执行二分的行为,它实际上执行的是顺序查找,比较次数也是 \(O(N)\)。
但是,实际上,二分查找依然是“二分”的查找,它并没有因为 list 无法随机访问而做顺序查找的行为:
- 第 \(1\) 次二分:花费 \(O(\frac{N}{2})\) 时间找到中间节点,进行比较,搜索空间减少为 \(\frac{N}{2}\)
- 第 \(2\) 次二分:花费 \(O(\frac{N}{4})\) 时间找到中间节点,进行比较,搜索空间减少为 \(\frac{N}{4}\)
- ...
一共经过 \(log_2^N\) 此查找,我们就能找到答案,此时我们仅仅比较了 \(log_2^N\) 此次,总的时间复杂度是:
等于:
159. 如何高效的删除 vector 中所有下标为偶数的元素
这个问题就很有意思,你可能会通过一个 for 循环写出类似下面的函数:
void erase_even_index(vector<int> &v) {for(int i = 0, j = 1; j < v.size(); i ++ , j += 2) {v[i] = std::move(v[j]);}v.resize(v.size() / 2);
}
或者简化一些:
void erase_even_index(vector<int> &v) {for(int i = 1; i < v.size(); i += 2) {v[i >> 1] = v[i]; // 0, 1, 2, 3, 4 := 1, 3, 5, 7, 9}v.resize(v.size() >> 1);
}
它们本质上并没有太大的差别,都是通过手写 for 循环,但是还记得吗,在 《Effective STL》中提到过,手写 for 循环并不比 STL 算法高明,因为:
- 手写
for循环直观上能难理解:代码太长了,没有清晰的函数名 - 手写
for循环不易维护:只要在for循环内稍微增删,代码就变得很臃肿了 - 手写
for循环并不会在效率上更有优势
因此,更好的写法应该是调用 STL 算法:
void erase_even_index(vector<int> &v) {v.erase(std::remove_if(v.begin(), v.end(), [idx = 0](int) mutable {return idx ++ % 2 == 0;}),v.end());cout << v.size() << endl;for(auto &x : v) cout << x << ' '; cout << endl;
}vector<int> v1{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
vector<int> v2{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10};erase_even_index(v1);
erase_even_index(v2);
很好,你的想法很好,通过 remove_if,在 lambda 匿名对象中设置一个 idx 计数器,在 remove_if 遍历 V 的时候累加计数器,并且当计数器为偶数时就 remove 掉当前元素,最后通过 erase 删除掉。
但是在这里,算法执行之后,实际上把所有奇数下标的元素删除了,最奇怪的是,还把第一个偶数下标的元素也删除了,整个执行结果就很奇怪:
4
2 4 6 8
5
2 4 6 8 10
我们可以打印一下计数器:
void erase_even_index(vector<int> &v) {v.erase(std::remove_if(v.begin(), v.end(), [idx = 0](int) mutable {cout << "idx: " << idx << endl;return idx ++ % 2 == 0;}),v.end());cout << v.size() << endl;for(auto &x : v) cout << x << ' '; cout << endl;
}
idx: 0
idx: 0
idx: 1
idx: 2
idx: 3
idx: 4
idx: 5
idx: 6
idx: 7
idx: 8
4
2 4 6 8
idx: 0
idx: 0
idx: 1
idx: 2
idx: 3
idx: 4
idx: 5
idx: 6
idx: 7
idx: 8
idx: 9
5
2 4 6 8 10
可以发现,计数器在开始时,出现了两次 idx 为 \(0\) 的情况,这显然不合常理。
实际上,问题出在 remove_if 函数上,它的算法逻辑并不是我们想象的那样,你可能以为 remove_if 的逻辑是这样的:
template<class ForwardIt, class UnaryPred>
ForwardIt remove_if(ForwardIt first, ForwardIt last, UnaryPred p)
{ForwardIt i = first;while(!p(*i)) ++ first;for (; i != last; i ++ )if (!p(*i))*first++ = std::move(*i);return first;
}
但是,标准库的 remove_if 可能是这样的:
// remove by value
template<class ForwardIt, class T = typename std::iterator_traits<ForwardIt>::value_type>
ForwardIt remove(ForwardIt first, ForwardIt last, const T& value)
{first = std::find(first, last, value);if (first != last)for (ForwardIt i = first; ++i != last;)if (!(*i == value))*first++ = std::move(*i);return first;
}// remove by predict
template<class ForwardIt, class UnaryPred>
ForwardIt remove_if(ForwardIt first, ForwardIt last, UnaryPred p)
{first = std::find_if(first, last, p);if (first != last)for (ForwardIt i = first; ++i != last;)if (!p(*i))*first++ = std::move(*i);return first;
}
可以发现,在 remove 和 remove_if 执行之前,先执行了一次 first = std::find_if(first, last, value/p);。其中 first 返回的是第一个满足 value/p 的位置。
这和我们自己实现的 remove_if 并无差别,只不过标准库通过调用 find_if 实现了我们手写的内容。但是这隐含了一个容易被忽略的事实,那就是在 remove_if 中,谓词 p 被调用了两次,具体的:
- 匿名 lambda 对象
p传入remove_if remove_if中的匿名对象p又传入find_if
由于 p 是传值,因此在 remove_if 和 find_if 中的两个匿名对象 p 是独立的,也即它们的计数器也是独立的。
并且,first 在 find_if 判断之后,不会在 for (ForwardIt i = first; ++i != last;) 中重复判断,我们可以发现对 i 的递增是在 ++i != last,这意味着在进入循环体之前,i 就递增了一次。
好了,我们应该明白整个过程发生了什么了,以 vector<int> v1{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; 为例:
- 先执行
find_if,判断到v1[0]结束,返回first=&v1[0]。此时第一个计数器打印 0,累加到 1,v1[0]被 remove 掉。 - 从
first+1开始执行for循环,依次判断v1[1]、v1[2],...,v1[n-1]。对于v1[1]来说,此时计数器的值为 0 而不是 \(1\),因此把v1[1]remove 掉,记下来依次把v1[3],v1[5]等奇数下标元素 remove 掉。
那么,C++20 引入的 std::erase_if 是否可行呢?
void erase_even_index(vector<int> &v)
{std::erase_if(v, [idx = 0](int)mutable{return idx ++ % 2 == 0;});
}
经过测试,结果依然是 erase-remove_if 的结果,这是因为 erase_if 实际上就相当于:
auto it = std::remove_if(c.begin(), c.end(), pred);
auto r = c.end() - it;
c.erase(it, c.end());
return r;
也即它只是对 erase-remove 惯用法的封装。
好了,介绍完错误的写法,正确做法应该是:
std::erase_if(v, [&v](const auto &x)mutable{return (&x - &v[0]) % 2 == 0;}
);
也即,不应该依赖于 lambda 自身的计数器,而是外部的计数器。但要注意的是,这种方法只适用于 vector 和 string,因为它依赖于容器内部使用连续数组存储元素。
160. Performance of qsort vs std::sort
161. 宏不适用于复杂计算
当你定义一个带有参数的宏时,务必将宏定义中的每个参数都用小括号括起来。 否则,在宏展开时,由于运算符的优先级问题,可能会导致意想不到的错误。
例如:
#define SQUARE(x) x * x
如果你以这样的方式调用它:
int a = 3, b = 4;
SQUARE(a + b);
它的实际执行并不是 (a + b) * (a + b),而是 a + b * a + b。
修改为:
#define SQUARE(x) (x) * (x)
程序的行为就是 (a + b) * (a + b)
但是,添加小括号并不能解决所有问题,例如:
#define CALL_BY_MAX(a, b) func((a) > (b) ? (a) : (b))
我们以下面的方式调用:
int a = 3, b = 2;
CALL_BY_MAX(a ++ , b);
预期下,CALL_BY_MAX 执行结束后,a=4,b=2,但实际上,a=5,b=2,这是因为宏展开后:
func((a ++ ) > (b) ? (a ++ ) : (b));
也就是说,当 a 大于 b 时,a++ 实际上执行了两次。
所以说,使用宏实现一些稍微复杂的函数时,就可能产生各种各样的问题,最好的方法就是使用 inline template function 来代替宏:
template<typename T>
inline void callWithMax(const T &a, const T &b) {func(a > b ? a : b);
}
inline使得该函数可以获得宏带来的效率template使得该函数可以无视类型
162. static-const 成员变量发展史
162.1 C++98/C++03
允许 static const 整数类型(int, char, short, long 等)和 枚举类型 在类内直接初始化。
但变量仍需在类外定义(分配存储空间),除非仅用于编译期常量表达式(如数组大小)。
如果取变量地址或需要链接时存在,则必须在类外定义。否则:
struct Data {static const int val = 10; // 声明 + 初始化
};
const int *p = &Data::val;
// main.cpp:(.text+0xb): undefined reference to `Data::val'
// collect2: error: ld returned 1 exit status
162.2 C++11
保留了 C++98 的规则,但扩展了对 static constexpr 成员的支持,允许浮点数、自定义等 字面量类型 作为编译期常量。
在之前,我们只能通过 const 初始化整数类型的静态成员变量,但现在通过 constexpr,我们可以声明浮点数和其它字面量类型的静态成员变量:
struct Point { int x, y; };struct Data {static constexpr int i = 10;static constexpr double d = 3.14;static constexpr pair<int,int> p1 = {1, 2};static constexpr Point p2 = {3, 4};// static constexpr string s = "hello"; // compile error// static constexpr vector<int> v = {1, 2, 3}; // compile errorstatic constexpr array<int,i> a{1, 2, 3, 4, 5};
};
注意我们不能初始化一个 string,这是因为 string 不是一个字面量类型,同理,vector 也不是字面量类型。这是显然的,vector 和 string 都需要动态分配内存,而在堆上动态分配内存是运行期行为。但是由于 array 底层就是一个内置数组,因此可以初始化一个 array。
另外就是和 C++98 相同,如果我们要对 static constexpr 成员取地址,依然要在类外定义,否则会链接错误。
struct Data {static constexpr int i = 10;static constexpr double d = 3.14;
};
const double *p = &Data::d;
// main.cpp:(.text+0xb): undefined reference to `Data::d'
// collect2: error: ld returned 1 exit status
162.3 C++17
C++ 17 引入了 inline 变量,并且 static constexpr 成员变量 隐式内联,无需再额外定义:
// 在 C++17 及以后版本合法
struct Data {static constexpr int i = 10;static constexpr double d = 3.14;
};
const double *p = &Data::d;
163. EBO
Empty Base Optimization(EBO) allows the size of an empty base subobject to be zero.
The size of any object or member subobject is required to be at least 1 even if the type is an empty class type (that is, a class or struct that has no non-static data members), (unless with [[no_unique_address]], see below)(since C++20) in order to be able to guarantee that the addresses of distinct objects of the same type are always distinct.
However, base class subobjects are not so constrained, and can be completely optimized out from the object layout:
struct Base {}; // empty classstruct Derived1 : Base
{int i;
};int main()
{// the size of any object of empty class type is at least 1static_assert(sizeof(Base) >= 1);// empty base optimization appliesstatic_assert(sizeof(Derived1) == sizeof(int));
}
Empty base optimization is prohibited if one of the empty base classes is also the type or the base of the type of the first non-static data member, since the two base subobjects of the same type are required to have different addresses within the object representation of the most derived type.
之所以特别强调“第一个非静态数据成员”,是因为内存地址的顺序性和C++标准的“地址唯一性”要求共同作用的结果。
我们来分解一下原因:
1. 核心约束:地址唯一性
C++标准规定,任何两个类型相同的完整对象(包括子对象)必须拥有不同的地址。
struct Empty {};struct Derived : Empty { Empty mem; // 错误:禁止两个相同类型的子对象拥有相同地址 };在上面的例子中,
Derived实例内部有两个Empty类型的子对象:
- 一个是通过继承而来的基类子对象 (
Base subobject)- 一个是数据成员子对象 (
Member subobject)标准要求这两个
Empty子对象必须有不同的地址。因此,即使Empty是空的,编译器也不能让它们共享同一个地址。这就天然禁止了空基优化在这种情况下的发生。
2. 为什么是“第一个”成员?—— 内存布局的关键
现在我们来解释“第一个”为什么特殊。假设我们有另一个类
AlsoEmpty。struct Empty {}; struct AlsoEmpty {};struct Test : Empty { // 基类子对象AlsoEmpty mem1; // 第一个非静态数据成员Empty mem2; // 第二个非静态数据成员 };编译器在布局
Test的对象时,大致会遵循这个顺序:
- 基类子对象部分 (
Empty)- 成员子对象部分,按照声明的顺序
mem1(AlsoEmpty类型)mem2(Empty类型)现在,我们看看空基优化(EBO)可以应用在哪里:
Empty基类 和mem1(AlsoEmpty):
它们的类型不同。EBO允许它们共享同一个地址。编译器很可能会将基类Empty放置在mem1所在的地址,因为那里有“空闲”的空间。这是允许且常见的优化。Empty基类 和mem2(Empty):
它们的类型相同。根据标准,它们禁止共享地址。因此,mem2必须被分配一个与基类Empty不同的地址。- 如果
mem1不存在:
那么mem2就是第一个成员。此时,基类Empty和成员Empty (mem2)类型相同,必须拥有不同地址,EBO被禁止。结论与总结
“第一个非静态数据成员”之所以关键,是因为:
- 布局的起始点:在典型的编译器实现中,基类子对象位于对象的起始地址。编译器会首先尝试将空基类与紧接着它之后的第一个成员进行地址优化(EBO)。
- 类型检查的触发点:
- 如果第一个成员的类型与空基类不同,EBO可以发生,空基类不占用额外空间。
- 如果第一个成员的类型恰好与空基类相同(或者是其派生类,从而也包含一个同类型的基类子对象),那么根据标准,这两个同类型的子对象必须有不同的地址。EBO在它们之间被明确禁止。此时,空基类必须占据一个独立的地址,这通常意味着它需要占用至少1字节的空间(尽管大小是
0,但地址是唯一的)。- 后续成员无关紧要:一旦第一个成员因为类型相同而“挡住”了EBO,那么后面的成员(比如上面例子中的
mem2)是否与空基类类型相同已经不重要了,因为优化在第一步就已经失败了。禁止EBO的“罪魁祸首”就是这个第一个成员。因此,规则中精确地指出“第一个非静态数据成员”是因为它在对象内存布局中的首要位置,决定了编译器能否成功地将空基类的地址与某个成员的地址进行合并。如果这第一个成员就“撞型”了,那么优化的大门就直接关闭了。
A typical example of such situation is the naive implementation of std::reverse_iterator (derived from the empty base std::iterator), which holds the underlying iterator (also derived from std::iterator) as its first non-static data member.
struct Base {}; // empty classstruct Derived1 : Base
{int i;
};struct Derived2 : Base
{Base c; // Base, occupies 1 byte, followed by padding for iint i;
};struct Derived3 : Base
{Derived1 c; // derived from Base, occupies sizeof(int) bytesint i;
};int main()
{// empty base optimization does not apply,// base occupies 1 byte, Base member occupies 1 byte// followed by 2 bytes of padding to satisfy int alignment requirementsstatic_assert(sizeof(Derived2) == 2*sizeof(int));// empty base optimization does not apply,// base takes up at least 1 byte plus the padding// to satisfy alignment requirement of the first member (whose// alignment is the same as int)static_assert(sizeof(Derived3) == 3*sizeof(int));
}
Empty base optimization is required for StandardLayoutTypes in order to maintain the requirement that the pointer to a standard-layout object, converted using reinterpret_cast, points to its initial member, which is why the requirements for a standard layout type include "has all non-static data members declared in the same class (either all in the derived or all in some base)" and "has no base classes of the same type as the first non-static data member".
The empty member subobjects are permitted to be optimized out just like the empty bases if they use the attribute [[no_unique_address]]. Taking the address of such member results in an address that may equal the address of some other member of the same object.
struct Empty {}; // empty classstruct X
{int i;[[no_unique_address]] Empty e;
};int main()
{// the size of any object of empty class type is at least 1static_assert(sizeof(Empty) >= 1);// empty member optimized out:static_assert(sizeof(X) == sizeof(int));
}
163.1 Notes
Empty base optimization is commonly used by allocator-aware standard library classes (std::vector, std::function, std::shared_ptr, etc) to avoid occupying any additional storage for its allocator member if the allocator is stateless. This is achieved by storing one of the required data members (e.g., begin, end, or capacity pointer for the vector) in an equivalent of boost::compressed_pair with the allocator.
In MSVC, empty base optimization is not fully compliant with the standard requirements (Why is the empty base class optimization (EBO) is not working in MSVC?).
164. std::shared_ptr 的资源释放
template <typename _Tp>
class shared_ptr {// ...
private:_Tp* __ptr__;__shared_weak_count* __cntrl__;
};
上面是一个 std::shared_ptr 的粗略结构,它包含两个成员:
__ptr__:指向底层资源的指针__cntrl__:指向控制块的指针
其中,__cntrl__ 的类型名 __shared_weak_count 很有意思,顾名思义,__shared_weak_count 用于管理 std::shared_ptr 和 std::weak_ptr 的引用计数。
需要注意的一点就是,__ptr__ 的释放时机和 __cntrl__ 的释放时机并不相同。当 std::shared_ptr 产生的引用计数为 0 时,__ptr__ 会得到释放,而当 std::weak_ptr 产生的引用计数也为 0 时,__cntrl__ 才会释放。这是因为我们需要确保 std::shared_ptr 可以安全的检查资源是否仍然存在(通过 expired() 或 lock() 方法检查控制块)。
这也体现了 std::weak_ptr 的设计初衷,不增加 __ptr__ 的引用计数,同时还可以安全的访问 std::shared_ptr 对象。
165. Literal Type
字面量类型(Literal Type)是 C++ 中一种特殊的类型,它们的值可以在编译期完全确定,允许在 编译期(compile-time) 进行构造、操作和计算。这类类型可以用于 constexpr 变量、函数和模板元编程,是 C++ 编译期计算 的基础。
165.1 1. 定义
字面量类型必须满足以下条件(C++ 标准要求):
- 可以在编译期构造和初始化
- 必须支持
constexpr构造函数(如果是类类型)。 - 所有成员必须是字面量类型(递归要求)。
- 必须支持
- 析构函数必须是平凡的(trivial)
- 析构函数不能执行任何操作(如释放内存)。
- 默认析构函数或
= default析构函数满足要求。
- 必须是以下类型之一:
- 标量类型(算术类型、指针、枚举、
nullptr_t) - 引用类型
- 数组类型(元素必须是字面量类型)
- 类类型(满足上述条件)
- 标量类型(算术类型、指针、枚举、
例如:
- 基本类型(全部是字面量类型)
constexpr int i = 42; // int
constexpr float f = 3.14f; // float
constexpr char c = 'A'; // char
constexpr bool b = true; // bool
constexpr const char* p = "Hi"; // 指针
constexpr std::nullptr_t np = nullptr; // nullptr_t
std::array(是字面量类型)
constexpr std::array<int, 3> arr = {1, 2, 3}; // 合法
// std::array 是栈上固定大小的容器,不涉及动态内存分配。
- 自定义类(满足条件时)
struct Point {int x, y;constexpr Point(int x, int y) : x(x), y(y) {} // constexpr 构造函数~Point() = default; // 析构函数是平凡的
};constexpr Point p{1, 2}; // 合法,Point 是字面量类型
std::string_view(C++17 起是字面量类型)
constexpr std::string_view sv = "Hello"; // 合法
// string_view 只是字符串的视图,不拥有数据。
凡是涉及动态内存分配的,例如 vector、map、string 都不是字面量类型。
165.2 2 用途
字面量最核心的特性就是它的值可以在 编译期 确定,因此字面量主要用于 编译期计算,例如:
template<int N>
struct Factorial {static constexpr int value = N * Factorial<N - 1>::value;
};template<>
struct Factorial<0> {static constexpr int value = 1;
};
166. C++ 版本发展历史
166.1 1. 前标准化时代(1980s–1998)
- 1979–1983:Bjarne Stroustrup 在贝尔实验室开发了 "C with Classes",逐步加入面向对象特性(如类、继承)。
- 1985:第一个商业编译器 Cfront 1.0 发布,同时出版《The C++ Programming Language》第一版。
- 1989–1997:C++ 在社区中广泛使用,但缺乏统一标准。期间发布了多个编译器实现(如 Borland C++、Microsoft C++)。
166.2 2. 标准化时代
166.2.1 (1) C++98(ISO/IEC 14882:1998)
- 首个国际标准,正式确立了 C++ 的语法和核心库。
- 关键特性:
- 标准化了 STL(Standard Template Library)(容器、迭代器、算法)。
- 支持异常处理、RTTI(运行时类型识别)、模板。
- 明确内存模型和多线程基础(但未定义线程库)。
166.2.2 (2) C++03(ISO/IEC 14882:2003)
- 小幅度修订版,主要修复 C++98 的缺陷和模糊表述。
- 新增
std::vector等容器的初始化列表(但功能有限)。
166.3 3. 现代 C++ 革命
166.3.1 (3) C++11(ISO/IEC 14882:2011)
- 里程碑版本,被称为 "Modern C++" 的起点。
- 关键特性:
- 自动类型推导(
auto)、范围for循环。 - 移动语义(
std::move)、右值引用(&&)。 - 智能指针(
std::shared_ptr、std::unique_ptr)。 - Lambda 表达式、
constexpr、nullptr。 - 多线程支持(
std::thread、std::atomic)。
- 自动类型推导(
166.3.2 (4) C++14(ISO/IEC 14882:2014)
- 对 C++11 的补充和优化。
- 关键特性:
- 泛型 Lambda、
auto返回值类型推导。 constexpr函数支持更复杂的表达式。- 二进制字面量(
0b1010)、数字分隔符(1'000'000)。
- 泛型 Lambda、
166.3.3 (5) C++17(ISO/IEC 14882:2017)
- 进一步简化代码并增强标准库。
- 关键特性:
- 结构化绑定(
auto [x, y] = pair)。 std::optional、std::variant、std::any。- 并行 STL 算法(
std::execution::par)。 if constexpr编译时条件判断。
- 结构化绑定(
166.3.4 (6) C++20(ISO/IEC 14882:2020)
- 重大更新,引入多项变革性特性。
- 关键特性:
- 模块(Modules)替代头文件(
import std.core)。 - 协程(Coroutines)支持异步编程。
- 概念(Concepts)约束模板参数。
std::format格式化库、std::span安全数组视图。
- 模块(Modules)替代头文件(
166.3.5 (7) C++23(ISO/IEC 14882:2023)
- 最新标准(2023 年发布),增量改进。
- 关键特性:
std::mdspan多维数组视图。- 标准库模块化(
std分模块导入)。 - 增强协程和
constexpr支持。
166.4 4. 未来方向(C++26 及以后)
- 计划中的特性:
- 反射(Reflection)元编程。
- 更强大的模式匹配(Pattern Matching)。
- 继续优化模块化和并发支持。
166.5 总结
| 版本 | 主要贡献 | 性质 |
|---|---|---|
| C++98 | 首个 ISO 标准,STL(容器/迭代器/算法)、异常处理、RTTI | 里程碑 |
| C++03 | 修复 C++98 的缺陷,标准化 std::vector 等容器的初始化列表 |
小幅度修订 |
| C++11 | 现代 C++ 起点:移动语义、Lambda、auto、智能指针、多线程库 |
革命性更新 |
| C++14 | 泛型 Lambda、constexpr 增强、二进制字面量 |
对 C++11 的优化 |
| C++17 | 结构化绑定、std::optional、并行 STL、if constexpr |
重要扩展 |
| C++20 | 模块(Modules)、协程(Coroutines)、概念(Concepts)、std::format |
重大变革 |
| C++23 | std::mdspan、模块化标准库、协程改进 |
增量完善 |
167. SSO
SSO 是一种用于优化短字符串存储的技术。与总是从堆上分配内存来存储字符串字符不同,实现了 SSO 的 std::string 会在字符串的长度小于某个阈值时,直接将字符存储在 std::string 对象自身内部。
SSO 的工作原理:
通常,一个 std::string 对象包含类似以下的成员:
- 一个指向字符缓冲区的指针(在堆上分配)。
- 字符串的当前大小。
- 缓冲区的已分配容量。
有了 SSO,std::string 对象还会包含一个小的、固定大小的字符数组(即“小缓冲区”)。当创建一个字符串时:
- 如果字符串的长度小于或等于小缓冲区的大小: 字符会直接存储在这个内部缓冲区中。不需要在堆上进行动态内存分配。
- 如果字符串的长度超过小缓冲区的大小: 实现会回退到传统的做法,即在堆上分配内存来存储字符串的字符,并且使用指针成员指向这个动态分配的内存。
SSO 的优点:
- 减少堆分配: 对于许多应用中非常常见的短字符串,SSO 避免了动态内存分配和释放的开销。这可以显著提高性能,因为堆操作相对较慢。
- 改善缓存局部性: 当字符串数据直接存储在
std::string对象内部时,可以改善缓存局部性,因为字符串对象及其数据很可能位于连续的内存中。这可以加快访问速度。 - 降低开销: 通过避免短字符串的堆分配,SSO 降低了与其相关的内存管理开销。
SSO 阈值:
小缓冲区(SSO 阈值)的具体大小是实现定义的。它在不同的 C++ 标准库实现(例如 GCC 使用的 libstdc++,Clang 使用的 libc++,以及 MSVC 使用的实现)之间有所不同。对于 char 类型的 std::string,常见的 SSO 缓冲区大小约为 15 到 23 个字符。
总而言之,你可以放心地认为现代 C++ 标准库的实现都为 std::string 采用了小字符串优化,从而更高效地处理短字符串。 你不需要为了利用这个优化做任何特殊的操作,它是标准库内部自动处理的。
接下来我们可以看一下 basic_string.h 中的实现:
class basic_string
{/* ... */enum{_S_local_capacity = 15 / sizeof(_CharT)};union{// +1 处理空字符_CharT _M_local_buf[_S_local_capacity + 1];size_type _M_allocated_capacity;};/* ... */
}
168. auto p={1,2,3} 和 auto p{1,2,3} 有什么不同
在 C++11引入{}列表初始化方式之后,所有的初始化方式可以被简化成两种:
-
直接列表初始化
T t{arg0, arg1, arg2, ...} -
拷贝列表初始化
T t = {arg0, arg1, arg2, ...}
当 auto 和 {} 配合使用时,auto 会对初始化列表类型进行推导,依据规则,直接列表初始化将会把类型推导为原始类型 T,而拷贝列表初始化则会把类型推导为为 std::initializer_list<T>,但是在 C++17 以前,某些情况下 auto 会将对象全部推导为 std::initializer_list<T>。
例如:
auto a = { 1 };
auto b{ 1 };
auto c = { 1, 2 };
auto d{ 1, 2 };
直接初始化的理想结果是类型为 int,如果在 C++17 以前,上述代码可能全部被推导为 std::initializer_list<int>,这就是矛盾所在。所以在 C++17 中,对于 auto with braced-init-list 有了新的规则解释以强化 auto 的类型推导:
- 对于直接列表初始化,如果列表具有单个元素,则会推导类型为 T,当列表具有多个元素时不合法。
- 对于拷贝列表初始化,如果所有元素类型相同,则会推导类型为
std::initializer_list<int>。
For copy-list-initialization, auto deduction will either deduce a std::initializer_list (if the types of entries in the braced-init-list are all identical) or be ill-formed otherwise.
For direct list-initialization:
- For a braced-init-list with only a single element, auto deduction will deduce from that entry;
- For a braced-init-list with more than one element, auto deduction will be ill-formed.
The rules for range-based
forare unaffected by the changes proposed here, as the rules for copy-list-initialization are unmodified.Note: C++14 already disallows deduction of an function return type from a braced-init-list, and that's a good thing.
因此,上述代码在 C++17 会被推导为:
auto a = { 1 }; // 拷贝初始化:std::initializer_list<int>
auto b{ 1 }; // 直接初始化:int
auto c = { 1, 2 }; // 拷贝初始化: std::initializer_list<int>
auto d{ 1, 2 }; // 直接初始化:编译不合法
参考:
C++17特性详解1--auto对于列表初始化的新规则
initialization - cppreference
New Rules for auto deduction from braced-init-list
169. 数字分隔符
在 C++14 及更高版本中,数字中的单引号(')用作数字分隔符,目的是为了提高代码的可读性。它不会影响数字的实际值,只是让程序员更容易识别数字的位数。
关键点:
- 兼容性:C++14 引入,现代编译器(如 GCC、Clang、MSVC)支持。
- 不影响数值:
1'000和1000完全等价。 - 灵活性:可以随意分隔(不一定要按千位),比如
1'0'0'0也是合法的(但不推荐)。
例如:
int binary = 0b1010'1101'0001; // 二进制分隔
int hex = 0xDEAD'BEEF; // 十六进制分隔
double pi = 3.141'592'6535; // 浮点数分隔(C++14起支持)
long long i = 1'000'000'000; // 十进制分隔
这种语法糖的目的是让代码更清晰,减少位数误读(比如 100000000 容易看错,而 100'000'000 一目了然)。
170. C++ 项目的文件组织方式
对于完全开源或完全闭源的项目,可以将源文件和头文件都放在一个目录(一般是 src)中,这样在阅读源代码时比较方便和直观,同时在源文件和头文件之间跳转也比较方便,因为不需要跨目录。
如果不是完全开源的代码,或是代码以库的形式提供给别人看的,可以在组织上将公共的头文件(需要开放给它人,也即接口文件)与其它的 .h/.cpp 文件分开,公共的头文件放在单独的目录中(一般是 include/)其它非公共的 .h/.cpp 依然采用放在一起的方式。
大体上如下所示:
include放接口文件src或者impl放实现文件(src一般用于最小构建单位;impl用于模块内部或者说子模块),包含 .h 和 .cpp 文件test放测试文件res放资源文件,例如图片doc放文档output-xxx各种不同构建的输出
参考:
-
知乎:c++的.h和cpp,放在相同目录下好,还是顶层就用include,src分开好?
-
Canonical Project Structure
171. 查看 GCC/G++ 预处理器当前所定义宏
echo "" | gcc -E -dM -
echo "":生成一个空输入。gcc -E:让 GCC 只运行预处理阶段(不编译、不链接)。-dM:输出所有已定义的宏(-dM是 GCC 的调试选项,专门用于打印宏)。-:表示从标准输入(这里是空输入)读取源文件。- 结果:输出所有 GCC 默认定义的宏(如
__linux__、__x86_64__、__STDC__等)。
C++ 版本:
echo "" | g++ -E -dM -x c++ -
-x c++:强制指定语言为 C++(因为默认可能根据文件扩展名判断)
这些命令会输出编译器在预处理阶段默认定义或根据环境定义的所有宏名称及其值。
例如,我们可以通过该命令查看 C++ 版本:
> echo "" | g++ -E -dM -x c++ - | grep "__cplusplus"
#define __cplusplus 201703L
可以通过
man gcc查看手册,注意gcc -E --help可能不显示-dM选项,然后输出\-dM查找-dM编译选项。
172. 指针和引用的区别
关于指针和引用的区别,在 Reddit 上有一个讨论,其中高赞回答说的比较清楚了,简而言之,C++ 标准只是定义了引用了 语义,至于怎么 实现 这种语义,是编译器厂商自己的行为,你可以将引用在底层实现为 const T*,也可以将引用完全优化掉,毕竟引用只是一个别名。
C++FAQ:
"Important note: Even though a reference is often implemented using an address in the underlying assembly language, please do not think of a reference as a funny looking pointer to an object. A reference is the object, just with another name. It is neither a pointer to the object, nor a copy of the object. It is the object. There is no C++ syntax that lets you operate on the reference itself separate from the object to which it refers."
至于在 语义 层面上,引用和指针的行为差别完全就是八股文了:
- 引用永远不能为空,创建时必须初始化
- 不能重新赋值
下面是高赞的具体回答:
Pointer’s Semantics
A pointer is a distinct value type. It has a defined size on a given platform, which can be obtained by
sizeof(void*). On a platform with a true 64 bit address space, this will return8. All pointers to any object type will have this same size:sizeof(T*) == sizeof(U*). The value of a pointer is an actual, real memory location. You can introspect this directly; if you print a pointer without dereferencing it, the value printed is the memory address value the pointer holds.Basically, pointers are “true” value types in an of themselves and can exist independently of having an object to point to. This is why you can have a null pointer. And since they are true value types, they have their own definition of the following operations, independent of the type they point to:
- To compare a pointer means to compare the memory address, not the content at that memory address.
- To copy/assign a pointer means to copy/assign the memory address, not the content at that memory address.
- Taking the address of a pointer will produce the address of the pointer object itself, not the pointed to content.
- “Dereferencing” a pointer produces a reference to the pointed to content. I know, it’s confusing. It made more sense in C when there wasn’t a distinct thing called a reference, so pointers were said to have “reference semantics” and thus “dereferencing” them produced the actual content.
When we say a type is “pointer like”, we mean it models the properties above. This is why the smart pointers behave the way they do. It’s also why
std::spanis so confusing.std::spanlooks like a container, so you expect it to behave the way a reference to a container would: comparing it would compare the elements in the span. But you can reassign spans, and that assignment doesn’t reassign the underlying elements, it just reassigns the underlying pointer to point to new elements. So to be consistent, the standard chose the lesser of two evils and made span model a pointer, and thus has pointer semantics all around. When you compare a span, you’re asking “do these spans provide a view onto literally the same content in memory,” just like comparing a pointer. In C++20 they formalized this “container reference API but pointer semantics” notion with the view concept.Reference’s Semantics
A reference is not actually a distinct type, just like that the second quote states: there is no actual object that “represents” a reference. You cannot take the address of the reference itself. If you do
sizeof(T&), you will get the same answer assizeof(T). So most accurately, a reference is an alias for the actual instance of an object.Because references are not true value types in and of themselves, but instead are aliases for the instance, they have the following semantics:
- They must be “bound” to an instance at initialization. A reference can never be “null.”
- They cannot be re-assigned. Assigning to a reference after it has been “bound” to an instance is equivalent to assigning to the bound instance, not the reference.
- Comparing references is equivalent to comparing the referenced instances.
- Taking the address of a reference gives the address of the bound instance.
Implementations
As discussed above, a pointer is an explicitly defined value type. They take up memory on the stack/heap.
A reference, on the other hand, is not an explicitly defined value type. Because a reference isn’t a “real” value type, but instead defined as an alias to the bound instance, the compiler is free to implement references however it wants, as long as it maintains the semantics.
This means a compiler is free to optimize out a reference completely. The compiler cannot inherently do this with a pointer: a pointer has to actually exist because you can take its address, reassign it, etc. However, the compiler is just as free to implement a reference as a pointer (in the machine sense, see below), and often will do so if the reference passes between functions that cannot be inlined.
不过,纸上得来终觉浅,我们可以从汇编的角度看一下,编译器到底怎么实现指针和引用的,编译器版本为:g++ (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
// test.cpp
int main()
{int a = 0x1234abcd;int *pa = &a;int &ra = a;++ (*pa);++ ra;
}
对上面的程序,得到其汇编代码:g++ -S test.cpp -o test.s -O0
.file "test.cpp".text.globl main.type main, @function
main:
.LFB0:.cfi_startprocendbr64; 栈帧建立pushq %rbp ; 保存旧的 %rbp(基指针).cfi_def_cfa_offset 16.cfi_offset 6, -16movq %rsp, %rbp ; 设置新的栈基址 %rbp = %rsp(栈指针).cfi_def_cfa_register 6subq $32, %rsp ; 分配 32 字节栈空间; 栈溢出保护movq %fs:40, %rax ; 从线程局部存储(TLS)加载栈保护值movq %rax, -8(%rbp) ; 将保护值存入栈(位于局部变量之后)xorl %eax, %eax ; 清零 %eax(同时避免优化干扰); 变量初始化movl $305441741, -28(%rbp) ; 局部变量 int a = 305441741leaq -28(%rbp), %rax ; %rax = &amovq %rax, -24(%rbp) ; int *p1 = &aleaq -28(%rbp), %rax ; %rax = &amovq %rax, -16(%rbp) ; int *p2 = &a; 指针解引用与修改movq -24(%rbp), %rax ; %rax = p1movl (%rax), %eax ; %eax = *p1(即 a 的值)leal 1(%rax), %edx ; %edx = *p1 + 1movq -24(%rbp), %rax ; %rax = p1movl %edx, (%rax) ; *p1 = *p1 + 1(a++)movq -16(%rbp), %rax ; %rax = p2movl (%rax), %eax ; %eax = *p2(即 a 的值)leal 1(%rax), %edx ; %edx = *p2 + 1movq -16(%rbp), %rax ; %rax = p2movl %edx, (%rax) ; *p2 = *p2 + 1(a++); 函数返回movl $0, %eax ; main 函数返回值movq -8(%rbp), %rdx ; 加载保存的栈保护值subq %fs:40, %rdx ; 与当前值比较je .L3 ; 如果一致,跳转到 .L3call __stack_chk_fail@PLT ; 否则调用栈保护失败处理
.L3:leave ; 恢复 %rsp 和 %rbp.cfi_def_cfa 7, 8ret ; 返回 0.cfi_endproc
.LFE0:.size main, .-main.ident "GCC: (Ubuntu 12.3.0-1ubuntu1~22.04) 12.3.0" ; 编译器标识.section .note.GNU-stack,"",@progbits ; ELF 节信息.section .note.gnu.property,"a" ; ELF 节信息.align 8.long 1f - 0f.long 4f - 1f.long 5
0:.string "GNU"
1:.align 8.long 0xc0000002.long 3f - 2f
2:.long 0x3
3:.align 8
4:
可以看到,在机器码层面,指针和引用的效果都是一样的。
reference:https://zhuanlan.zhihu.com/p/89175296
173. C++ 风格头文件
在 C++ 中,.hpp 是一种常见的头文件扩展名,其命名逻辑和用途如下:
173.1 1. 命名逻辑
.hpp 是 "Header Plus Plus" 的缩写,其中的 ++ 对应 C++ 语言名称,明确表示这是一个 C++ 专用的头文件,与传统的 C 语言头文件(.h)区分开来。这种命名方式是一种约定俗成的规范,并非语言标准强制要求,但被广泛接受。
173.2 2. 为什么用 .hpp 而不是 .h?
- 区分 C 和 C++ 头文件
.h通常用于 C 语言头文件 或 兼容 C/C++ 的头文件。.hpp明确表示该头文件 包含 C++ 特性(如类、模板、命名空间等),避免与 C 头文件混淆。
- 模板代码的显式声明
C++ 的模板(template)实现通常需要直接放在头文件中(因为模板实例化需要编译器看到完整定义)。使用.hpp可以直观表明该头文件可能包含模板的实现代码。 - 构建系统的清晰性
在混合 C/C++ 的项目中,构建工具(如 CMake)可以通过扩展名快速识别文件类型,避免误处理。
173.3 3. .hpp vs .h vs .cpp
| 扩展名 | 用途 |
|---|---|
.h |
C 语言头文件,或兼容 C/C++ 的头文件(如 extern "C" 声明的接口)。 |
.hpp |
纯 C++ 头文件,可能包含类定义、模板实现、内联函数等。 |
.cpp |
C++ 源文件,包含实际代码实现(如类成员函数、非内联函数等)。 |
173.4 4. 实际项目中的使用场景
-
纯 C++ 项目
通常统一使用.hpp和.cpp,例如:my_class.hpp // 类声明和模板实现 my_class.cpp // 类成员函数的定义 -
混合 C/C++ 项目
-
用
.h提供 C 兼容接口,用.cpp实现 C++ 代码。 -
例如:
// my_lib.h (C 兼容接口) #ifdef __cplusplus extern "C" { #endif void c_compatible_function(); #ifdef __cplusplus } #endif// my_lib.hpp (C++ 实现细节) class MyClass { ... };
-
173.5 5. 其他变体
.hxx/.hh
类似.hpp,也是 C++ 头文件的常见变体(不同团队习惯不同)。.inl
用于存放内联函数或模板实现的头文件(通常被.hpp包含)。
173.6 总结
.hpp= C++ 头文件,强调内容包含 C++ 特性(如类、模板)。- 使用它是为了与 C 头文件区分,提高代码可读性和工具链兼容性。
- 并非强制,但遵循团队/项目的约定即可。
174. noexcept 和 const 到底有什么用
const 并不是真的常量,被 const 修饰的变量可以被我们修改,尽管这种行为是 UB 的。 noexcept 并不真的是没有异常,被 noexcept 修饰的函数依然可以抛出异常,只不过此时程序直接 terminated。
那么问题来了,既然如此,noexcept 和 const 到底有什么用呢?有的,它们的一个重要意义是为编译器提供优化指示信息:
- 对于 const,编译器可以考虑 constant folding 和 common subexpression elimination。
- 对于 noexcept,编译器不会为该函数提供保证异常异常的代码。这会在后面的 [C++ 代码究竟膨胀在哪里?] 中提到。
175. C++ 代码究竟膨胀在哪里?
参考链接:
- https://zhuanlan.zhihu.com/p/686296374
这里所说的 C++ 代码膨胀,指的是 C++ 源代码在经过编译之后,产生的可执行二进制目标文件膨胀严重。
175.1 用 inline 标记会导致代码膨胀吗
不会,因为重复的 inline 代码在优化后会合并。由于 C++ inline 的特性,不同模块中的同名 inline 函数的定义必须完全相同(ODR,one define rule),因此多份 inline 代码肯定能合并为一份。
175.2 模板导致代码膨胀的真正原因
本来能合并的代码却没有合并,就是真正的模板膨胀开销所在。
例如下面的代码:
// main.cpp
#include <cstdio>
#include <utility>template <std::size_t N>
struct X {size_t x;[[gnu::noinline]]void f() {printf("X<%zu>::f() called\n", x);}
};template <std::size_t... Is>
void call_f(std::index_sequence<Is...>) {((X<Is>{Is}).f(), ...); // 折叠表达式展开调用
}int main()
{call_f(std::make_index_sequence<100>{}); // 生成序列 0,1,2,...,99return 0;
}
我们通过模板创建了 100 个不同的实例 X<N>。在 -O2 优化下,如果编译器无法内联成员函数 f() (考虑 f() 十分复杂的情况),此时代码经过编译后会生成 100 份模板实例,在但是它们实际上底层都是 size_t 类型,所以进行最终编译生成的二进制代码是完全相同的。也就意味着,这里生成了 100 份完全相同的代码,造成了严重的代码膨胀。
175.3 异常导致的代码膨胀
C++ 的异常号称 zero cost exception,其本质是以空间换时间,通过打表的方式记录异常的传播路径,而打表的内容就会导致可执行文件的膨胀。
例如:
#include <vector>void foo();void bar() {std::vector<int> v(12);foo();
}
在上面的代码中,由于 foo() 是外部引入的函数,可能会抛出异常,而在函数 bar() 中,如果 foo() 抛出异常,vector<int> v(12) 就不会被正常析构,因此这里编译器需要为 v 做异常处理:
0000000000000000 <_Z3barv>:0: f3 0f 1e fa endbr644: 41 54 push %r126: bf 30 00 00 00 mov $0x30,%edib: 55 push %rbpc: 48 83 ec 08 sub $0x8,%rsp10: e8 00 00 00 00 call 15 <_Z3barv+0x15>15: 66 0f ef c0 pxor %xmm0,%xmm019: c7 00 00 00 00 00 movl $0x0,(%rax)1f: 48 89 c5 mov %rax,%rbp22: 48 c7 40 24 00 00 00 movq $0x0,0x24(%rax)29: 002a: c7 40 2c 00 00 00 00 movl $0x0,0x2c(%rax)31: 0f 11 40 04 movups %xmm0,0x4(%rax)35: 0f 11 40 14 movups %xmm0,0x14(%rax)39: e8 00 00 00 00 call 3e <_Z3barv+0x3e>3e: 48 83 c4 08 add $0x8,%rsp42: 48 89 ef mov %rbp,%rdi45: be 30 00 00 00 mov $0x30,%esi4a: 5d pop %rbp4b: 41 5c pop %r124d: e9 00 00 00 00 jmp 52 <_Z3barv+0x52>52: f3 0f 1e fa endbr6456: 49 89 c4 mov %rax,%r1259: e9 00 00 00 00 jmp 5e <_Z3barv+0x5e>Disassembly of section .text.unlikely:0000000000000000 <_Z3barv.cold>:0: 48 89 ef mov %rbp,%rdi3: be 30 00 00 00 mov $0x30,%esi8: e8 00 00 00 00 call d <_Z3barv.cold+0xd>d: 4c 89 e7 mov %r12,%rdi10: e8 00 00 00 00 call 15 <_Z3barv.cold+0x15>
其中,<_Z3barv.cold> 就是额外的异常处理代码。
如果我们将 vector<int> v(12) 的声明放到 foo() 之外,此时 vector 不需要异常处理保证正常析构,编译器就不用为它产生额外二进制数据:
0000000000000000 <_Z3barv>:0: f3 0f 1e fa endbr644: 48 83 ec 08 sub $0x8,%rsp8: e8 00 00 00 00 call d <_Z3barv+0xd>d: bf 30 00 00 00 mov $0x30,%edi12: e8 00 00 00 00 call 17 <_Z3barv+0x17>17: 66 0f ef c0 pxor %xmm0,%xmm01b: be 30 00 00 00 mov $0x30,%esi20: c7 00 00 00 00 00 movl $0x0,(%rax)26: 48 89 c7 mov %rax,%rdi29: 48 c7 40 24 00 00 00 movq $0x0,0x24(%rax)30: 0031: c7 40 2c 00 00 00 00 movl $0x0,0x2c(%rax)38: 0f 11 40 04 movups %xmm0,0x4(%rax)3c: 0f 11 40 14 movups %xmm0,0x14(%rax)40: 48 83 c4 08 add $0x8,%rsp44: e9 00 00 00 00 jmp 49 <_Z3barv+0x49>
当然,这是一个很简单的情况,对于很复杂的情况,人工处理异常代码以避免额外的异常处理是很困难的!
不过,我们可以使用 C++11 的新特性:noexcept,通过 noexcept 修饰的函数虽然可以抛出异常,但程序会直接 terminated,因此编译器不会为该函数提供保证异常安全的代码,因此,如果将 bar() 修改为:
#include <vector>
oid bar() noexcept {std::vector<int> v(12);foo();
}
所产生二进制数据和上面相同。
最后,就是一个非标准编译选项 fno-exceptions,用于 禁用 C++ 的异常处理机制。如果打开该选项,GCC 会默认所有函数不会抛出异常。它会从根本上改变编译器生成代码的方式,移除异常处理相关的开销。也因此,他会达到和 noexcept 相同的效果。
176. C++ style Doxygen 注释
Doxygen 是一个非常流行的文档生成器,它可以解析 C++(以及 C, Java, Python, Objective-C, PHP, C#, Fortran, VHDL 等)源代码中的特定格式注释,并生成各种格式(如 HTML, LaTeX, RTF, man pages, XML 等)的专业文档。
Doxygen 支持多种注释样式来标记文档块,其中最常见且推荐的两种是:
-
C-style Doxygen 注释:
/*** @brief 这是一个简短的函数描述。** 这是一个更详细的描述。* 它可以跨越多行。** @param arg1 参数1的描述。* @param arg2 参数2的描述。* @return 返回值的描述。* @throws std::runtime_error 如果发生错误。*/ int myFunction(int arg1, double arg2);这种风格与 Javadoc 非常相似,因为它也使用
/** ... */来包围文档块,并且每行通常以*开头。 -
C++-style Doxygen 注释:
C++
/// @brief 这是一个简短的函数描述。 /// /// 这是一个更详细的描述。 /// 它可以跨越多行。 /// /// @param arg1 参数1的描述。 /// @param arg2 参数2的描述。 /// @return 返回值的描述。 /// @throws std::runtime_error 如果发生错误。 int myFunction(int arg1, double arg2);这种风格使用三重斜杠
///来标记Doxygen注释行。
Doxygen 标签(Commands):
Doxygen 提供了丰富的标签来描述代码元素,类似于 Javadoc。你提到的 @pragma 和 @return 都是 Doxygen 支持的标签。一些常用的 Doxygen 标签包括:
@brief或\brief: 提供简短的描述。@param或\param: 描述函数参数。@return或\return: 描述函数返回值。@throws或\throws/@exception或\exception: 描述可能抛出的异常。@see或\see: 引用其他相关内容。@note或\note: 添加一个注意事项。@warning或\warning: 添加一个警告。@todo或\todo: 标记待办事项。@code/@endcode或\code/\endcode: 包裹代码示例。@mainpage或\mainpage: 定义文档的主页。@file或\file: 描述文件。@class或\class: 描述类。@fn或\fn: 描述函数。
选择风格的考虑:
- 团队约定:在团队项目中,通常会有一个统一的注释风格指南,以确保代码库的一致性。
- 工具支持:如果你使用 Doxygen 来生成文档,那么遵循 Doxygen 支持的注释风格是最佳选择。
- 个人偏好:在个人项目中,你可以根据自己的偏好选择 C-style Doxygen 注释或 C++-style Doxygen 注释。
总而言之,在 C++ 中看到 /* ... @return ... */ 这种注释,几乎可以肯定它是在遵循 Doxygen 风格的文档注释。
看个例子吧:
/*** @file calculator.h* @brief 包含 Calculator 类定义的头文件。*/#ifndef CALCULATOR_H
#define CALCULATOR_H/*** @class Calculator* @brief 一个提供基本算术运算的类。** Calculator 类封装了常用的加法、减法、乘法和除法操作。* 它不保留内部状态,所有方法都是静态的,方便直接调用。*/
class Calculator {
public:/*** @brief 执行两个整数的加法运算。** 该方法将两个给定的整数相加并返回它们的和。** @param a 第一个加数。* @param b 第二个加数。* @return 两个整数的和。*/static int add(int a, int b);/*** @brief 执行两个整数的减法运算。** 从第一个整数中减去第二个整数并返回结果。** @param a 被减数。* @param b 减数。* @return 两个整数的差。*/static int subtract(int a, int b);/*** @brief 执行两个整数的乘法运算。** 将两个整数相乘并返回它们的积。** @param a 第一个乘数。* @param b 第二个乘数。* @return 两个整数的积。*/static int multiply(int a, int b);/*** @brief 执行两个整数的除法运算。** 将第一个整数除以第二个整数。** @warning 如果除数为零,该方法将抛出 `std::invalid_argument` 异常。** @param a 被除数。* @param b 除数。* @return 两个整数的商。* @throws std::invalid_argument 如果除数 `b` 为零。*/static double divide(int a, int b);
};#endif // CALCULATOR_H
177. -ggdb 编译选项
-g 选项用于生成标准的调试信息,这些信息可以被多种调试器使用,该选项生成的调试信息符合 DWARF(Debugging With Attributed Record Formats)标准,DWARF 这是一种通用的调试信息格式。
-ggdb 选项专门为GDB生成调试信息。它生成的调试信息可能包含一些 GDB 特有的扩展,这些扩展可能不适用于其他调试器,不过使用-ggdb选项可以确保生成的调试信息对 GDB 是最优的。
178. 程序调试器 GNU Debugger (GDB) 的原理
GDB 实现的原理,简单说就是 中断,具体来说是 trap。
在 x86 平台下,参考以下关键字:
- ptrace系统调用
- int 3 指令(0xCC)
- TF Flag
- 调试寄存器(DR0 - DR3)
参考自:
- 知乎:Linux 下常用的程序调试器 gdb 是什么原理?
- Articles in tag "Debuggers"
理解 GDB 原理需要拆解三个关键机制:
一、程序控制权争夺战
GDB 通过 Linux 的 ptrace 系统调用获得对被调试程序的完全控制权。启动调试时,GDB 会 fork 出子进程执行被调试程序,父进程通过ptrace(PTRACE_TRACEME) 建立监控关系。这种关系允许 GDB:
- 劫持所有系统调用(收到 SIGTRAP 信号时暂停程序)
- 任意读取/修改寄存器和内存(相当于直接改写程序 DNA)
- 单步执行时设置 TF 陷阱标志(让 CPU 主动上报每条指令)
二、断点背后的魔术戏法
设置断点时,GDB 施展了精妙的二进制魔术:在目标地址用 0xCC(INT3指令)覆盖原指令。当程序执行到此处会触发软中断,GDB 立即接管控制权,此时它会:
- 恢复被覆盖的原始指令
- 将程序计数器回退到断点地址
- 等待调试者查看现场后继续执行
硬件断点则依赖 CPU 的调试寄存器(DR0-DR3),通过设置地址匹配模式实现读写监控,这种稀缺资源通常只有 4 个可用。
三、符号翻译的密码本
调试信息(-g 编译选项生成的 DWARF 格式数据)是 GDB 的密码本。这个二进制字典里记录着:
- 机器地址与源代码行号的对应关系
- 栈帧内变量的内存布局
- 数据类型结构等元信息
当程序员查看变量值时,GDB 就像查字典一样,先通过符号表找到变量地址,再结合类型信息解析内存中的二进制数据,最终呈现为有意义的数值或结构体。
四、内存操作的微观视角
GDB 通过 ptrace 的 PTRACE_PEEKTEXT 原语操作内存时,实际上在与操作系统玩捉迷藏。每次内存读取都要经过:用户态→内核态→复制数据→返回用户态的完整循环,这种设计虽然安全但效率较低,因此大规模内存修改时会明显感觉卡顿。
理解这些底层机制的价值在于:当遇到调试信息丢失的崩溃现场时,程序员可以直接通过反汇编地址定位问题;在多线程程序卡死时,能快速通过寄存器状态判断线程阻塞原因。
断点的原理跟修改代码差不多,使用 ptrace() 这个函数调用可以对函数的汇编代码进行一定的修改,在x86上有一条汇编指令,叫做 INT 3(这里的 INT 不是 int,而是 interrupt 的缩写),当出现这个指令的时候,程序就会发生中断,也就是 trap 了,然后父进程就可以捕获这个信号,子进程就会中断在那里,等待下一步的命令,也就是说当 gdb 进行打断点的时候,就是将一条汇编代码修改一下,将其修改成 INT 3 的机器码 0xcc,所以说当打断点的时候,就会将原来的汇编代码对应的机器码中的机器码指令换成 0xcc,当程序执行到一步的时候,就会触发断点,等到调试,那如何跳出这个断点?将要被替换的机器码指令保存下来就可以了,等到执行的时候将其重新替换上去就可以了(相当于恢复现场)。
x86 系列处理器有自己的四个中断寄存器。只要写入一个地址,执行到这个地址就会断下来。但是明显不够用。
所以软件也有中断实现。x86 系列有个特别指令
int 3; 一旦执行到指令,就会触发一次中断。所以软件实现的方式,就是在这里插入一条,或者替换一条int 3指令(替换必须在执行了断点之后把替换之前的指令也执行,不然会丢失了指令)。
ptrace 一次是四个字节的修改,但是 0xcc 是一个字节,你就需要修改四字节的低位进行处理,然后替换的时候就是将其替换到低位(也就是末位),替换到低位是因为大部分机器是小端存储,等到一执行到这里的时候就会立马发生中断,等到恢复的时候,不仅要恢复现场(将修改的数值修改到原来的位置上),并且要将寄存器的 PC 指针 -1(pc 寄存器向上递增的,这里要将寄存器的数值 -1 就是 0xcc之前的那个位置,然后继续执行,执行到下一个断点)
打断点的本质上是在机器码上进行打断点的,那么如何知道机器码和你写的代码之间的关系呢,有一个调试信息文件,也就是 dwarf(Debugging With Attributed Record Formats) 文件,dwarf 上会记录上一些相关的信息,如函数的low_pc() 和 high_pc(),(low_pc() 就是最低的那个地址,是函数开头,可以通过 objdump 这个工具反汇编得到的信息和 dwarf 文件上的信息对比一下,刚好是对应上的)函数名所在的机器码指令在哪,都可以找到,所以说在 gdb 上就可以直接进行在函数名上打断点了。
ptrace() 能干的事情有很多,具体可以查一查手册,其他原理如 bt(gdb 打印出函数调用)等,涉及到了函数栈帧的调用,保存在寄存器上,ptrace() 也可以直接看寄存器上保存的数值,然后读取出来,做一些有用的事情。
至于变量的位置和大小的信息,这个很复杂。因为数据在变量中可能存在不同的地方,比如说在内存里面,在寄存器上,或者说编译器能够开一些优化直接将数据优化掉。更甚至有些对象象数据甚至有可能一半是在寄存器中,一半还在内存中,这时候读取并且修改就高了。dwarf文件也记录了这些,但是比前面的那个要复杂的多了,当然,这些都是一些基础原理,平常进行 C/C++ 调试,用 gdb 就行了,
int main() {char s[3] = {'a', 'b', 'c'};std::cout << s << std::endl;
}
然后就是 0xcc 烫烫烫的梗,如果说你写上这样的代码,输出就有可能出现烫烫烫的字样,就是因为在 x86 上 int 3 是软中断的命令,没有初始化的内存会用0xcc 来填补,std::cout 需要最后的 \0 来表示这个 char s[] 的结束,结果没有,就将 0xcc 的字样输出上去了,结果就成了烫烫烫的字样了。
179. std::iota
179.1 1. 使用
template< class ForwardIt, class T >
void iota( ForwardIt first, ForwardIt last, T value );
std::iota 定义在 <numeric> 头文件中,可以方便地填充范围 [first, last) 的连续值。
**示例代码: **
#include <iostream>
#include <numeric> // 包含 std::iota
#include <vector>int main() {// 1. 初始化 C 风格数组int arr[10];std::iota(std::begin(arr), std::end(arr), 1); // 从 1 开始填充std::cout << "C 风格数组: ";for (int num : arr) {std::cout << num << " ";}std::cout << "\n";// 2. 初始化 std::vectorstd::vector<int> vec(10);std::iota(vec.begin(), vec.end(), 1); // 从 1 开始填充std::cout << "std::vector: ";for (int num : vec) {std::cout << num << " ";}std::cout << "\n";return 0;
}
输出
C 风格数组: 1 2 3 4 5 6 7 8 9 10
std::vector: 1 2 3
179.2 2. 为什么叫 iota?
std::iota 并不是一个缩写,而是来源于 APL 编程语言 中的 ι (iota) 函数,它用于生成一个连续的整数序列。
- APL 语言(1960 年代)有一个
⍳(希腊字母 iota)操作符,用于生成索引序列(如⍳5生成1 2 3 4 5)。 - C++ 在 C++11 引入
std::iota,沿用了这个概念,表示 填充连续递增的值。
std::iota 的正确发音是 /aɪˈoʊtə/。
179.3 原理
如果 T 是浮点数(如 float、double),std::iota 仍然可以正常工作,但需要注意 浮点数的递增是逐次加 1.0,可能会因浮点精度问题导致累积误差。
这是因为 std::iota 的内部逻辑是 *it = value; ++it; ++value;,对于浮点数:
- 每次
++value等价于value += 1.0 - 如果连续多次递增,可能因浮点舍入误差导致结果不精确(尤其是
value较大时)。
180. Folly 库
Folly (acronymed loosely after Facebook Open Source Library) is a library of C++17 components designed with practicality and efficiency in mind. Folly contains a variety of core library components used extensively at Facebook. In particular, it's often a dependency of Facebook's other open source C++ efforts and place where those projects can share code.
It complements (as opposed to competing against) offerings such as Boost and of course std. In fact, we embark on defining our own component only when something we need is either not available, or does not meet the needed performance profile. We endeavor to remove things from folly if or when std or Boost obsoletes them.
Performance concerns permeate much of Folly, sometimes leading to designs that are more idiosyncratic than they would otherwise be (see e.g. PackedSyncPtr.h, SmallLocks.h). Good performance at large scale is a unifying theme in all of Folly.
主要组件可以参考 docs 和 overview。
180.1 FBstring
string 常见的三种实现方式:
- eager-copy
- COW
- SSO
很有意思,通过 folly 的设计,我们也能窥见一些对 string 可行的优化。
- 根据字符串的大小,使用不同的内存模型
- small:SSO 优化,直接分配在栈上,不用动态分配,并且局部性更好
- medium:动态分配,但是使用 eager-copy,并发安全
- large:动态分配,使用 COW,以优化拷贝的开销,但是引用计数在并发环境下会带来额外开销
- 针对内存分配器进行优化,提到了 一篇论文,看不太懂...
- 对末尾
\0优化,因为这里有 C++ 字符串转化为 C 字符串的需要,所以需要处理\0的问题。- folly 使用的是 lazy-append
\0,也即只有在需要 C++ 转 C 或者取得底层数据时才添加\0,例如调用data()或c_str(),从而避免每次修改字符串时\0带来的额外开销(特别是push_back)
- folly 使用的是 lazy-append
realloc的处理:- 当字符串的内存利用率很少时(
size * 2 < capacity),也即使用率不到 50% 时,放弃使用realloc(因为realloc需要拷贝全部内存,但是其中一半多是无效内容),而是通过free+malloc+copy的方式(只拷贝有效内容)重新分配内存,减少拷贝开销。 - 当内存使用率大于 50% 时,则使用
realloc,并寄希望于realloc可以直接合并后面的空闲内存(依赖于内存分配器?),以避免拷贝开销
- 当字符串的内存利用率很少时(
find的优化:这里似乎是针对 FaceBook 的常用场景做了特定优化
参考自:
- std::string的Copy-on-Write:不如想象中美好
- 漫步Facebook开源C++库folly(1):string类的设计
- C++ folly库解读(一) Fbstring —— 一个完美替代std::string的库
- Legality of COW std::string implementation in C++11
180.2 small_vector
https://zhuanlan.zhihu.com/p/353485606
181. string::data
referenec:
- std::basic_string<CharT,Traits,Allocator>::data
const CharT* data() const;
CharT* data() noexcept;
Returns a pointer to the underlying array serving as character storage. The pointer is such that the range。
The returned array is null-terminated, that is, data() and c_str() perform the same function.
If empty() returns true, the pointer points to a single null character.
182. auto& 和 auto&&
在 C++ 中,auto 关键字用于类型推导,它让编译器根据初始化表达式来确定变量的类型。而 & 和 && 是引用声明符,它们与 auto 结合使用时,会影响类型推导的行为,特别是涉及到引用折叠(reference collapsing)的规则。
182.1 auto& (左值引用推导)
当你在 auto 后面加上 & 时,你是在告诉编译器,你希望推导出的类型是一个左值引用。
工作原理:
- 如果初始化表达式是左值:
auto&会推导出该左值的引用类型。例如,如果x是int类型,那么auto& ref = x;会推导出ref的类型是int&。 - 如果初始化表达式是右值: 编译会失败。因为左值引用不能绑定到右值(除非是
const左值引用,但auto&默认不是const)。
主要用途:
-
绑定到左值:
最常见的用法是绑定到一个现有的左值对象,以便可以通过引用修改它。
int x = 10; auto& ref_x = x; // ref_x 的类型是 int& ref_x = 20; // x 现在是 20 std::cout << x << std::endl; // 输出 20std::vector<int> myVec = {1, 2, 3}; for (auto& elem : myVec) { // elem 的类型是 int&,可以在循环中修改元素elem *= 2; } // myVec 现在是 {2, 4, 6} -
避免拷贝:
当处理大对象时,使用
auto&可以避免不必要的拷贝,提高性能。std::string long_string = "very long string..."; auto& view = long_string; // view 是 std::string&,没有拷贝 -
泛型代码中的类型推导: 在模板或泛型编程中,
auto&确保你处理的是一个引用,而不是拷贝。
182.2 auto&& (万能引用/转发引用推导)
当你在 auto 后面加上 && 时,这称为“万能引用”(Universal Reference)或“转发引用”(Forwarding Reference)。这是一个非常强大的特性,它的行为取决于初始化表达式是左值还是右值。
工作原理(基于引用折叠规则):
- 如果初始化表达式是左值:
auto&&会推导出该左值的左值引用类型。- 根据引用折叠规则:
Lvalue& &&折叠为Lvalue&。 - 例如,如果
x是int类型,auto&& ref = x;会推导出ref的类型是int&。
- 根据引用折叠规则:
- 如果初始化表达式是右值:
auto&&会推导出该右值的右值引用类型。- 根据引用折叠规则:
Rvalue&& &&折叠为Rvalue&&。 - 例如,
auto&& ref = 10;会推导出ref的类型是int&&。
- 根据引用折叠规则:
主要用途:
-
完美转发(Perfect Forwarding):
这是
auto&&最重要的用途。在模板函数中,结合std::forward,它可以无损地转发参数的值类别(左值保持左值,右值保持右值),这对于通用函数模板的编写至关重要。template<typename T> void process(T&& arg) { // arg 是一个万能引用some_other_function(std::forward<T>(arg)); // 完美转发 }int main() {int x = 10;process(x); // arg 被推导为 int&, 并转发为 int&process(20); // arg 被推导为 int&&,并转发为 int&& } -
避免拷贝和处理临时对象:
当你需要处理一个既可能是左值也可能是右值的表达式时,
auto&&提供了一个统一的接口。// 假设有一个函数返回一个可能是左值或右值的对象 std::string get_string_lvalue() { return "hello"; } std::string& get_string_rvalue(std::string& s) { s = "world"; return s; }// 使用 auto&& 接收结果 auto&& s1 = get_string_lvalue(); // s1 是 std::string&& std::string temp = "original"; auto&& s2 = get_string_rvalue(temp); // s2 是 std::string&// 注意:这里 s1 是一个绑定到临时对象的右值引用,延长了临时对象的生命周期。 // s2 是一个绑定到左值 temp 的左值引用。 -
范围-based for 循环:
在 C++11 引入的范围-based for 循环中,
auto&&经常被隐式地用于遍历容器,以优化性能并处理各种元素类型(包括临时对象)。// 即使 std::vector<bool>::reference 是一个代理对象, // auto&& 也能正确处理,因为它能绑定到临时对象 for (auto&& elem : some_vector_of_bools) {// ... }
182.3 总结比较
| 特性 | auto& |
auto&& (万能引用) |
|---|---|---|
| 推导行为 | 只绑定左值,推导为左值引用 T&。 |
绑定左值或右值。如果初始化是左值,推导为左值引用 T&;如果初始化是右值,推导为右值引用 T&&。 |
| 错误情况 | 初始化表达式为右值时编译错误。 | 无特定错误情况(除非绑定不兼容类型)。 |
| 主要用途 | 修改现有左值对象,避免拷贝。 | 完美转发,处理左值和右值,统一接口。 |
| 性能 | 避免拷贝。 | 避免拷贝。 |
| 语义 | 明确表示你需要一个对现有对象的引用。 | 表示你希望保持被绑定表达式的值类别(左值性或右值性)。 |
183. Deducing this(Explicit Object Parameter) [C++ 23]
reference:
- C++23’s Deducing this: what it is, why it is, how to use it
Deducing this (P0847) is a C++23 feature which gives a new way of specifying non-static member functions. Usually when we call an object’s member function, the object is implicitly passed to the member function, despite not being present in the parameter list. P0847 allows us to make this parameter explicit, giving it a name and const/reference qualifiers. For example:
struct implicit_style {void do_something(); //object is implicit
};struct explicit_style {void do_something(this explicit_style& self); //object is explicit
};
The explicit object parameter is distinguished by the keyword this placed before the type specifier, and is only valid for the first parameter of the function.
The reasons for allowing this may not seem immediately obvious, but a bunch of additional features fall out of this almost by magic. These include de-quadruplication of code(const&, const&&, &, &&), recursive lambdas, passing this by value, and a version of the CRTP which doesn’t require the base class to be templated on the derived class.
This post will walk through an overview of the design, then many of the cases you can use this feature for in your own code.
For the rest of this blog post I’ll refer to the feature as “explicit object parameters”, as it makes more sense as a feature name than “deducing this“. Explicit object parameters are supported in MSVC as of Visual Studio 2022 version 17.2. A good companion to this post is Ben Deane’s talk Deducing this Patterns from CppCon.
简而言之,Deducing this 就是可以显式声明一个参数来接受调用对象本身(即 this),格式为
this + 类型 + 参数名。并且和常规的成员函数一样,这里的 this 也必须作为第一个成员函数参数。也因此,deducing this 也被称为“explicit object parameter”。不过需要注意的是,通过 deducing this 声明的对象并不等价于 this 指针,因为 this 表示的是一个指向调用对象本身的指针,而这里声明的的直接就是对象本身。以下面的例子为例,简而言之,
self == *this。struct S { // 隐式 this 参数(传统方式) void traditional_member_function() const & { // this 的类型是 const S* }// 显式 object 参数 void deduced_this_member_function(this auto& self) { // self 的类型是 S& (如果 S 是左值) // self 的类型是 const S& (如果 S 是 const 左值) }// 你也可以更具体地指定类型 void deduced_this_const_ref(this const S& self) { // self 的类型是 const S& }void deduced_this_rvalue_ref(this S&& self) { // self 的类型是 S&& } };
183.1 减少代码重复
如果我们想要实现一个 operator[],一般需要分别实现 const 和非 const 的版本,但现在只需要一个函数来实现 operator[]:
template<typename T>
struct Vector {
public:// T& operator[](size_t idx) { return data_[idx]; } // this*// const T& operator[](size_t idx) const { return data_[idx]; } // const this*auto operator[](this auto& self, size_t idx) -> decltype(self.data_[idx]) { return self.data_[idx]; }private:T *data_;int size_;int capacity_;
}
183.2 递归 lambda
#include <iostream>
#include <string>
#include <functional>using namespace std;/* 注意下面这种写法是错误的,因为在捕获 factorial_v0 时,它的类型还没有确定* auto factorial_v0 = [&factorial_v0](int n) -> int {* return n <= 1 ? 1 : n * factorial_v0(n - 1);* };* 显然的,factorial_v0 的返回类型和参数列表都是捕获列表的后面* 一种解决方案是使用 functional,这是因为在声明 function 对象时* 我们需要将函数签名作为模板参数传入 function*/
function<int(int)> factorial_v1 = [](int n) -> int {return n <= 1 ? 1 : n * factorial_v1(n - 1);
};// 使用 deducing this,这里 self 就是 lambda 对象本身
auto factorial_v2 = [](this auto &&self, int n) -> int {return n <= 1 ? 1 : n * self(n - 1);
};// 模板元编程
template<int N>
struct factorial_v3 {static const int value = N * factorial_v3<N - 1>::value;
};template<>
struct factorial_v3<0> {static const int value = 1;
};int main()
{cout << factorial_v1(4) << endl;cout << factorial_v2(4) << endl;cout << factorial_v3<4>::value << endl;return 0;
}
184. C++ ABI
很复杂,看了一遍,只能说一知半解吧:
- https://zhuanlan.zhihu.com/p/692886292
185. heap 自定义排序规则
注意,heap(priority_queue) 三个模板参数传入的都应该是类型,因为 heap 是一个类模板。
下面以实现小顶堆为例,介绍一些自定义 heap 排序规则的方法:
185.1 自定义结构体
创建一个结构体,并在结构体内重载 operator<:
struct Node {int val;bool operator<(const Node &rhs) const {return val > rhs.val;}
};
priority_queue<Node> min_heap;
185.2 函数对象
heap 默认以 operator< 进行排序,我们可以将其修改为 operator>,这里可以直接使用标准库提供的函数对象:
priority_queue<int, vector<int>, greater<int>> min_heap;
或者我们自己实现一个函数对象:
template<typename T>
struct Greater {bool operator()(const T &a, const T &b) const {return a > b;}
};priority_queue<int, vector<int>, Greater<int>> min_heap;
185.3 函数指针
如果我们是自己手写的函数而不是函数对象,则稍微复杂一些:
template<typename T>
bool cmp(const T &a, const T &b) {return a > b;
}priority_queue<int, vector<int>, decltype(&cmp<int>)> min_heap(&cmp<int>);
这里需要注意,第三个模板参数要传入 decltype(&cmp<int>) 而不是 decltype(cmp<int>),前者表示的是 “函数的类型”,后者表示的是 “可调用对象”(函数指针) 的类型。而 priority_queue 希望接受的是 可调用对象 的类型。
另外就是,不同于函数对象,在这里我们需要在声明的对象中传入可调用对象本身。至于具体原因,我们可以看一下源代码:
template <typename _Tp, typename _Sequence = vector<_Tp>,typename _Compare = less<typename _Sequence::value_type> >
class priority_queue {
public:typedef typename _Sequence::value_type value_type;typedef typename _Sequence::reference reference;typedef typename _Sequence::const_reference const_reference;typedef typename _Sequence::size_type size_type;typedef _Sequence container_type;typedef _Compare value_compare;protected:_Sequence c;_Compare comp;public:// 默认构造函数// is_default_constructible 确保 _Compare 和 _Seq 可以默认构造template<typename _Seq = _Sequence, typename _Requires = typenameenable_if<__and_<is_default_constructible<_Compare>,is_default_constructible<_Seq>>::value>::type>priority_queue(): c(), comp() { }// 指定比较器和容器的构造函数explicitpriority_queue(const _Compare& __x, const _Sequence& __s): c(__s), comp(__x){ std::make_heap(c.begin(), c.end(), comp); }explicitpriority_queue(const _Compare& __x, _Sequence&& __s = _Sequence()): c(std::move(__s)), comp(__x){ std::make_heap(c.begin(), c.end(), comp); }
// ...
};
在 priority_queue 中,它会尝试使用我们指定的比较器和容器类型的默认构造函数来初始化 priority_queue 内部的容器对象 c 和比较器对象 comp,显然的,对于函数对象,可以调用其默认构造函数完成默认初始化。
但是对于函数指针而言,它连一个类都算不上,自然也就没有默认构造函数,因此我们只能显示的指定比较器对象。
185.4 function
在上一节,我们提到了,priority_queue 的第三个参数接受的是 可调用对象 的类型,而我们知道,function 也是一个接受可调用对象的包装器,那么理论上,function 可以作为 priority_queue 的第三个参数:
template<typename T>
bool cmp(const T &a, const T &b) {return a > b;
}priority_queue<int, vector<int>,function<bool(const int&,const int&)>> min_heap(&cmp<int>);
经过测试,没有任何问题。
186. std::decay
186.1 什么是 std::decay?
std::decay 是 C++11 中在 <type_traits> 头文件中引入的一个类型特性(type trait)。它的主要作用是对一个类型 T 进行“退化”处理,使其失去一些顶层修饰,变得更为“原始”或“基础”。
这种“退化”模拟了按值传递函数参数时发生的类型转换过程。当你将一个变量传递给一个按值接收的函数参数时,会发生以下变化:
- 数组会退化为指针。
- 函数类型会退化为函数指针。
- 会移除顶层的
const、volatile以及引用修饰符。
std::decay 正是在编译时,在类型层面上执行这个转换过程。
cppreference 上给出的一个可能的实现:
template<class T>
struct decay
{
private:typedef typename std::remove_reference<T>::type U;
public:typedef typename std::conditional<std::is_array<U>::value, // 1. 是不是数组typename std::add_pointer<typename std::remove_extent<U>::type>::type, // 1.1 是:转换为指针类型typename std::conditional<std::is_function<U>::value, // 1.2 否:是不是函数typename std::add_pointer<U>::type, // 1.2.1 是:转换为指针类型typename std::remove_cv<U>::type // 1.2.2 否:去掉 CV>::type>::type type;
};
std::conditional是 C++11 引入的一个模板元编程工具,它的作用就像是 C++ 代码里的三元运算符? :,但它是在编译时期根据一个布尔条件选择两种类型中的一种。
std::conditional定义在<type_traits>头文件中,它的基本语法如下:template<bool B, class T, class F> struct conditional;它有三个模板参数:
B:一个bool类型的常量表达式。T:当B为true时,std::conditional将选择的类型。F:当B为false时,std::conditional将选择的类型。它的结果类型可以通过
typename std::conditional<B, T, F>::type来获取。
186.2 语法和定义
#include <type_traits>// 主模板通常不直接使用
template< class T >
struct decay;// 辅助类型别名 (C++14 起)
template< class T >
using decay_t = typename decay<T>::type;
你通常通过访问其 ::type 成员来获取退化后的类型,或者在 C++14 之后使用更便捷的 decay_t<T>。
使用示例:
static_assert(std::is_same_v<std::decay<volatile const int&>::type, int>, "111");
static_assert(std::is_same_v<std::decay_t<const int&>,int>, "111");
186.3 std::decay 执行的具体转换
给定一个类型 T,std::decay<T>::type 会按顺序执行以下操作:
- 处理引用: 首先,移除
T的引用部分(无论是左值引用&还是右值引用&&)。得到类型U。T = const int&->U = const intT = int&&->U = int
- 处理数组和函数: 然后,对
U进行判断:- 如果
U是数组类型U[N],则将其退化为指向其元素的指针类型U*。U = int[5]->decayed type = int*U = const char[10]->decayed type = const char*
- 如果
U是函数类型U(Args...),则将其退化为函数指针类型U(*)(Args...)。U = int(double)->decayed type = int(*)(double)
- 否则,直接进入下一步。
- 如果
- 处理 CV 限定符: 最后,对第二步得到的结果类型,移除其顶层的
const和volatile限定符。- 经过第二步后是
const int->decayed type = int - 经过第二步后是
const int*(指针本身的 const 是底层,不会被移除)->decayed type = const int*- 注意:
std::decay只移除顶层的const/volatile。指针所指向的类型的const(如const int*中的const)是底层 const,不会被移除。 - 另外就是,这里只考虑指针,因为我们会去掉引用。
- 注意:
- 经过第二步后是
186.4 示例汇总
让我们通过一个表格和代码来清晰地展示各种情况:
输入类型 T |
std::decay_t<T> |
说明 |
|---|---|---|
int |
int |
基本类型,保持不变。 |
int& |
int |
移除引用。 |
const int& |
int |
移除引用和顶层的 const。 |
int&& |
int |
移除右值引用。 |
int[5][3] |
int(*)[3] |
数组退化为指针(指向第一个元素的指针)。多维数组退化为指向其第一维的指针。注意这里是指向 int[3] 的指针。 |
int(int) |
int(*)(int) |
函数类型退化为函数指针。 |
const char* const |
const char* |
移除顶层的 const(指针本身的 const),保留底层的 const(指向的字符的 const)。 |
volatile int* |
volatile int* |
指针的 volatile 是底层限定符,不会被移除。 |
顶层
const:修饰的是变量本身,表示变量的值不可变。对于指针,就是指针的指向不能改变。底层
const:修饰的是所指对象,表示你不能通过这个变量来修改它所指向的值。
代码验证:
#include <type_traits>
#include <iostream>void func() {}int main() {// 使用 static_assert 在编译期验证static_assert(std::is_same_v<std::decay_t<int>, int>);static_assert(std::is_same_v<std::decay_t<int&>, int>);static_assert(std::is_same_v<std::decay_t<const int&>, int>);static_assert(std::is_same_v<std::decay_t<int&&>, int>);static_assert(std::is_same_v<std::decay_t<int[5]>, int*>);static_assert(std::is_same_v<std::decay_t<int(int)>, int(*)(int)>);static_assert(std::is_same_v<std::decay_t<const char*>, const char*>);static_assert(std::is_same_v<std::decay_t<char* const>, char*>);static_assert(std::is_same_v<std::decay_t<const char* const>, const char*>);std::cout << "All static assertions passed!" << std::endl;return 0;
}
186.5 主要用途和应用场景
std::decay 非常实用,常见于模板元编程中:
-
模拟按值传递:
这是它的设计初衷。在编写通用代码(如包装器、转发函数)时,你可能想确定“如果这个类型被按值传递,它会变成什么样子”。 -
在元编程中获取“基础”类型:
当你只关心数据的实际类型,而不关心它是引用、常量还是数组时,std::decay可以帮你“标准化”类型。例如,在存储数据时,你通常不想存储引用或数组,而是想存储其指针或基础值类型。 -
std::make_pair和std::make_tuple:
这些函数使用std::decay来确定它们所构造的pair或tuple中元素的类型。这就是为什么make_pair返回的pair类型不会包含引用,除非你显式使用std::ref。int a = 10; const int& b = a;// p 的类型是 std::pair<int, int>,而不是 std::pair<const int&, double> auto p = std::make_pair(b, 20.0); -
std::thread的参数传递:
std::thread构造函数会衰减参数类型,然后按值传递参数的副本给新线程。如果你想真正传递引用,必须使用std::ref。 -
与其他类型特性结合:
常用于比较类型时,先对类型进行“标准化”处理。template<typename T, typename U> void foo(T param1, U param2) {// 比较退化后的类型,忽略引用、const、数组等差异if constexpr (std::is_same_v<std::decay_t<T>, std::decay_t<U>>) {// ... T 和 U 的基础类型相同} }
186.6 需要注意的点
- 只移除顶层 CV 限定符:这是最重要的一点。
std::decay不会移除指针或引用所指向的类型的const(底层 const)。 - 与
std::remove_reference和std::remove_cv的区别:std::remove_reference<T>:只移除引用。std::remove_cv<T>:只移除顶层的const和volatile。std::decay<T>:功能是它们的超集,还额外处理数组和函数的退化。
187. 现代 C++ 性能优化
参考链接:
- 现代C++性能优化
粗略的看了一下,主要就是了解一下,知道有这么一回事即可,往后用到了不至于脑袋空空。
针对 C++ 程序的性能优化,可以从以下三方面着手:
- 源代码
- 算法时间复杂度优化
- 拷贝优化
- 重复计算优化
- 等等
- 编译器优化
- 尝试使用不同的编译器和编译选项
- 面向硬件优化
188. 值初始化
值初始化是 C++ 中的一种对象初始化方式,它的核心目标是确保对象的所有成员都有一个明确的初始值,特别是对基本类型(如 int、float、指针等)进行零初始化,而对类类型调用其默认构造函数。
188.1.1 值初始化可以通过以下方式触发:
T obj{}; // C++11 起推荐(最安全)
T obj = T(); // C++98/03 风格(仍然有效)
其中 T 可以是:
- 基本类型(
int,double,char*等) - 类类型(
class/struct) - 数组
188.1.2 值初始化的具体行为取决于 T 的类型:
| 类型 | 值初始化的行为 |
|---|---|
基本类型(int等) |
零初始化:0、0.0、nullptr 等 |
| 类类型(有构造函数) | 调用默认构造函数(如果默认构造函数是 = default,则成员可能被零初始化) |
| 类类型(无构造函数) | 所有成员递归值初始化(基本类型→零,类类型→调用默认构造函数) |
| 数组 | 每个元素值初始化 |
188.1.3 值初始化的典型用例
(1) 基本类型的零初始化
int x{}; // x = 0
double d{}; // d = 0.0
char* p{}; // p = nullptr
(2) 类类型的成员初始化
struct Point {int x, y;
};Point p1{}; // p1.x=0, p1.y=0(值初始化)
Point p2; // p2.x=?, p2.y=?(默认初始化,未定义)
(3) 动态分配的数组零初始化
int* arr = new int[10](); // 所有元素初始化为 0
(4) STL 容器的默认值
std::vector<int> v(10); // 10 个 0(值初始化)
std::array<int, 5> a{}; // 5 个 0
188.1.4 类类型值初始化陷阱
需要注意的是,对于类类型,是调用它的默认构造函数,因此我们需要确保在默认构造函数中,对所有需要初始化的变量完成了初始化,也即,不要让类成员的初始化逃离默认构造函数,以免在将来使用时产生未定义的行为。
例如:
class Foo {
public:Foo() {}Foo(int _a, int _b, int _c) : a(_a), b(_b), c(_c) {}public:short a;int b;double c;
};
在这里,我们自定义的默认构造函数 Foo(){} 本质上什么也不做,当我们调用 Foo 的默认构造函数时,它不会初始化我们的成员变量,因此这里即使我们通过 Foo f{} 的形式对 f 进行值初始化,它的成员变量 a,b,c 的值也依然是垃圾值。
所以,好的实现是在默认构造函数中完成成员变量的初始化工作。
class Foo {
public:Foo() a(0), b(0), c(0) {}Foo(int _a, int _b, int _c) : a(_a), b(_b), c(_c) {}public:short a;int b;double c;
};
或者使用编译器生成的默认构造函数:
class Foo {
public:Foo() = default;Foo(int _a, int _b, int _c) : a(_a), b(_b), c(_c) {}public:short a;int b;double c;
};
189. Foo(){} 和 Foo()=default 有什么不同
基于 【值初始化】 中我们对类类型值初始化陷阱的讨论,我们发现,Foo(){} 和 Foo()=default 做的并不是同一件事。具体来说,Foo(){} 就如同它的空函数体一样,什么也不做,但是它会带来一些隐式的副作用;而编译器生成的默认构造函数 Foo()=default 会帮我们做一些事情:
- 值初始化
- 保持聚合类型(如果符合条件)
- 编译器生成,可能优化初始化
具体来说:
- 编译器生成的默认构造函数,会对基本类型进行值初始化,对类类型调用默认构造函数。
Foo() {}会使Foo不再是聚合类型(因为我们提供了自定义的构造函数),因此无法用{}初始化成员。Foo() = default;不影响聚合类型(如果其他条件满足,仍可用{}初始化)。
190. atoi vs. std::stoi
190.1 (1) atoi(ASCII to Integer)
特点
-
头文件:
<stdlib.h>(C 标准库) -
原型:
int atoi(const char *str); -
行为:
- 从字符串开头解析,跳过前导空格(
、\t等)。 - 遇到 非数字字符(如字母、符号) 立即停止,返回已解析的部分。
- 如果字符串无法转换(如
"abc"),返回0。
- 从字符串开头解析,跳过前导空格(
-
错误处理:
❌ 无错误检测,无法区分
"0"和无效输入(如"xyz")。 -
示例:
#include <stdlib.h> int num = atoi("123"); // 返回 123 int zero = atoi("abc"); // 返回 0(无法区分错误)
190.1.1 缺点
- 不检查溢出(如
"2147483648"超出int范围时行为未定义)。 - 无法处理非十进制(如十六进制
"0xFF")。
190.2 (2) stoi(String to Integer)
特点
-
头文件:
<string>(C++ 标准库) -
原型:
int stoi(const string& str, size_t* pos = 0, int base = 10); -
行为:
- 从字符串解析,跳过前导空格。
- 可指定进制(如
base=16解析十六进制)。 - 如果解析失败(如
"123abc"的非数字部分),抛出std::invalid_argument异常。 - 如果数值超出
int范围,抛出std::out_of_range异常。
-
错误处理:
✅ 支持异常机制,能明确区分无效输入和合法0。 -
示例:
#include <string> #include <stdexcept> try {int num = stoi("123"); // 返回 123int hex = stoi("FF", nullptr, 16); // 返回 255(十六进制)int bad = stoi("abc"); // 抛出 std::invalid_argument } catch (const std::exception& e) {std::cerr << "Error: " << e.what() << std::endl; }
190.2.1 优点
- 支持进制转换(如二进制、十六进制)。
- 严格的错误处理机制。
- 可获取解析结束位置(通过
pos参数)。
190.3 关键区别总结
| 特性 | atoi (C) |
stoi (C++) |
|---|---|---|
| 所属标准 | C 标准库 | C++ 标准库 |
| 错误处理 | 无(返回 0) |
抛出异常(invalid_argument/out_of_range) |
| 溢出行为 | 未定义行为 | 抛出 out_of_range |
| 支持进制 | 仅十进制 | 可指定进制(如 2、8、16) |
| 参数类型 | const char* |
std::string |
| 线程安全 | 是 | 是 |
| 推荐使用场景 | 简单的、受控的输入 | 需要健壮性和灵活性的场景 |
190.4 何时选择?
- 用
atoi:- 快速处理已知安全的输入(如硬编码字符串)。
- C 语言环境或无需复杂错误检查的场景。
- 用
stoi:- 需要验证用户输入或外部数据。
- 需要处理不同进制(如十六进制)。
- C++ 项目(优先使用 C++ 标准库)。
190.5 替代方案
-
C++17 的
std::from_chars:
更高效且无异常的开销(但需手动处理错误):#include <charconv> const char* str = "123"; int num; auto [ptr, ec] = std::from_chars(str, str + strlen(str), num); if (ec == std::errc()) {std::cout << num; // 成功 }
191. malloc/free 与 new/delete 的区别
// c style memory management
T *p = (T*)malloc(sizeof(T * count));
free(p);// C++ style memory management
int *p = new int(10);
delete p;int *arr = new int[5]{1, 2, 3, 4, 5};
delete[] arr;int *arr2 = new int[] {1, 2, 3, 4, 5};
delete[] arr2;
- 本质:new/delete是C++运算符;malloc/free是C++标准库函数,在使用时需要加入头文件类型
- 内存分配:new无需指定内存块大小(自动计算);malloc需要显式指定所需内存的大小(手工计算)
- 内存分配失败:new不会返回NULL,而是会抛出bad_alloc异常;malloc会返回NULL
- 是否调用构造/析构函数:new/delete会调用类中的构造/析构函数;malloc不会
- 是否能够重载:new/delete能够重载;malloc/free不能重载
- 安全:new 安全;malloc不安全返回类型:new返回的是定义时具体类型的指针;malloc返回void *类型的指针,使用时需要强制转换
- 数组支持:new支持new[],malloc需要手动计算大小
192. 标记为 noexcept 的函数仍然可以抛出异常
noexcept 标识符的目的是告诉编译器和运行时系统,这个函数不打算抛出异常。它的主要作用是:
- 优化: 编译器可以进行更积极的优化,因为它知道不需要为异常传播生成栈展开代码。这可以提高性能。
- 明确意图: 它清楚地表明了函数的异常行为契约。
那么,什么情况下会抛出异常并导致终止?
如果一个标记为 noexcept 的函数内部确实抛出了异常,例如:
- 调用了另一个未标记为
noexcept并且抛出了异常的函数。 - 内部逻辑错误导致
throw语句被执行。
在这种情况下,C++ 运行时会立即调用 std::terminate(),默认情况下这会终止程序。这不会进行正常的异常栈展开,也不会调用局部对象的析构函数来清理资源。
什么时候应该使用
noexcept?
通常在以下情况下使用 noexcept:
- 资源管理器的析构函数: 析构函数应该总是
noexcept,因为如果在析构过程中抛出异常,会导致不可预测的行为甚至程序崩溃。 - 移动构造函数和移动赋值运算符: 如果它们是
noexcept,标准库算法(如std::vector的扩容)可以进行更高效的优化,使用移动语义而不是复制语义。 - 交换函数(
swap): 交换两个对象状态的函数通常应该是noexcept,因为它们是底层操作(操纵内置类型),不应该失败。 - 你知道绝对不会抛出异常的函数: 如果你非常确定一个函数在任何情况下都不会抛出异常,可以将其标记为
noexcept以获得性能优势和更明确的契约。
总而言之,noexcept 是一种承诺,而不是一个保证不会抛出异常的机制。如果违背了这个承诺,程序就会以 std::terminate 的方式结束。
193. 为什么需要先将 char 转型为 unsigned char?
// $: 0x24
// a: 0x61
// ^: 0x5e
// A: 0x41
int value = 0x245e4161; // 从高位到低位分别对应 $^Aa
char *caddr = reinterpret_cast<char *>(&value); // non-portable
for (int i = 0; i < 4; i++) {cout << hex << caddr[i] << ':';cout << hex << (unsigned int)(unsigned char)caddr[i] << endl;
}
在 cout << hex << (unsigned int)(unsigned char)caddr[i] << endl; 中,为什么需要先将 caddr[i] 转型为 unsigned char 再转换为 unsigned int 输出?
问题出在,char 可能是 signed char,如果此时 char 的值是一个负数,由于 int 是一个 char 更大的整数类型,char 会发生符号扩展,
例如,考虑 0x61 ('a')。它的二进制是 0110 0001。如果它被解释为有符号 char,它的值是 97,转换为 unsigned int 仍然是 97。这没问题。
现在,假设我们取一个字节的值是 0xF0。
- 如果
char是无符号的,0xF0的值是 240。转换为unsigned int仍然是 240。 - 如果
char是有符号的,0xF0的值是 -16(因为最高位是 1,表示负数,采用补码表示)。当这个 -16 被转换为unsigned int时,为了保持值的等价性(在二进制层面),编译器会进行符号扩展。它会将所有高位填充为 1。例如,一个 8 位的0xF0转换为 32 位的unsigned int可能会变成0xFFFFFFF0。
这并不是我们想要的结果!你想要的是这个字节的原始无符号值,而不是它的有符号解释在扩展后产生的 unsigned int 值。
所以 (unsigned char)caddr[i] 的作用就是:
- 明确地将
caddr[i]解释为无符号类型。 这样,无论char在你的系统上默认是有符号还是无符号,0x61都被视为 97,0x41被视为 65,0x5e被视为 94,0x24被视为 36。 - 在将其提升到
unsigned int之前,截断任何潜在的符号信息。 一旦它被视为unsigned char,即使它的原始char值是“负的”(例如0x80到0xFF),它也会被正确地解释为128到255之间的无符号值。 - 然后,当这个
unsigned char值转换为unsigned int时,不会发生符号扩展,只会进行零扩展(zero extension)。 也就是说,高位会被填充为 0,而不是 1。
194. std 为什么不允许添加新东西?
之前常常在书上看过一段话:“我们可以在 namespace std 中对标准库已有的组件提供特化版本,但是不可以添加全新的函数、类或修改现有标准库类型的行为”,例如我们可以特化 class hash<>,函数 swap<>。
之所以这样设计,其实是 Open-Closed Princlple 的一种体现,这也是 C++ 标准所规定的一条核心规则。C++ 标准库的设计是为了提供一个稳定、可靠且可移植的基础。如果用户可以随意向 std 命名空间中添加内容,可能会导致:
- 名称冲突 (Name Collisions):你添加的名称可能与未来 C++ 标准中引入的新名称冲突,导致代码编译失败或行为不一致。
- 行为不确定 (Undefined Behavior):标准库的内部实现可能会依赖于特定的假设。在
std命名空间中添加或修改内容可能会破坏这些假设,导致程序出现未定义行为。 - 可移植性问题 (Portability Issues):不同的编译器或标准库实现可能对
std命名空间有不同的内部结构。你在一个系统上能用的修改,在另一个系统上可能就无效了。
除非你是为了对用户自定义类型进行标准库模板的特化,否则绝对不要在 std 命名空间中添加任何内容。这样做几乎总会导致未定义行为或可移植性问题。如果你需要添加全局函数、类或模板,请在你的自己的命名空间中定义它们。
195. C++ 主流编译器及其对应标准库实现
主流的 C++ 编译器,如 GCC (GNU Compiler Collection)、Clang (LLVM)、以及 MSVC (Microsoft Visual C++),都紧跟 C++ 标准的更新,并为这些标准库提供了全面的支持。
GCC: 通常在每个主要版本发布后很快就会实现新的 C++ 标准特性。例如,GCC 11 已经全面支持了 C++20,GCC 13 也在积极实现 C++23 的特性。
Clang: 和 GCC 一样,Clang 的更新速度也非常快。它以其优秀的错误信息而闻名,也是一个非常流行的现代 C++ 编译器。Clang 12 开始全面支持 C++20。
MSVC: 微软的 Visual C++ 编译器在 Visual Studio 中提供。从 Visual Studio 2017 和 2019 开始,MSVC 提供了对 C++17 和 C++20 的大部分支持,并在最新的 Visual Studio 2022 版本中,对 C++20 和 C++23 的支持更加完善。
libstdc++ 是 GCC (GNU Compiler Collection) 的官方标准库。
平台:它主要用于基于 Linux 的系统,并且是许多 Linux 发行版的默认 C++ 标准库。它也支持 Windows (通过 MinGW 和 Cygwin) 以及其他类 Unix 系统。
特点:libstdc++ 历史悠久,功能全面且稳定。由于其与 GCC 捆绑,它在开源社区中的使用非常广泛。
libc++ 是 Clang 编译器背后的标准库。
平台:它在 macOS 和 iOS 平台上的应用尤为突出,因为 Clang 是 Apple 开发环境(Xcode)中的默认编译器。它在 Android(NVIDIA Nsight),以及各种 Linux 发行版和 FreeBSD 上也得到了广泛支持。
特点:libc++ 的设计目标是轻量化和高性能,并且对 C++ 11 及其后续标准的支持非常激进。它的代码简洁,且与 LLVM 生态系统集成得很好。
MSVC STL 是微软的 Visual C++ 编译器专用的标准库。
平台:它主要用于 Windows 平台,特别是通过 Visual Studio IDE 进行 C++ 开发时。MSVC STL 是 Windows 应用程序开发生态系统的核心组成部分。
特点:MSVC STL 紧密集成于 Windows 系统和 Visual Studio 开发工具链中,提供了良好的调试支持。微软在遵循 C++ 标准的同时,也确保了它在 Windows 平台上的最佳表现。
196. Type Erasure
在 C++ 中,“类型擦除”(Type Erasure)是一种强大的技术,它允许将不同类型的对象通过统一的接口进行操作,隐藏了具体类型信息,同时保留了对象的行为特性。
其实说白了就是多态,例如继承是动态多态,模板是静态多态,
void*是 C 语言中的多态。
196.1 Type Erasure 的几种形式
196.1.1 void *
在 C 语言中,void* 可以与任意类型指针进行转换,但它 不能保证类型安全。例如程序可能要求我们传入类型 T,但我们传入类型 U 也是可以通过编译的,但程序的运行结果是未定义的。
196.1.2 继承
C++ 中的继承显然是一个 Type Erasure 的例子,通过虚机制与动态绑定,我们可以通过将一个基类指针绑定到不同的派生类对象,使得调用同一接口(虚函数)时展现出不同的行为。
继承显然是比 void* 要好的,它要求我们必须使用基类的派生类对象,不会出现两个毫不相关的类型进行转换的情况。
继承的问题在于,它的 侵入性,即它要求每个实现类型都继承自某个基类,这意味着我们需要修改已有的实现。在很多情况下,这是很难做到的,或者是很别扭的。
196.1.3 Duck Typing 和 Template
如果一个东西,走路像鸭子,叫声也像鸭子,那么它就是鸭子。换句话说,如果一个东西,满足我们对鸭子的所有要求,那么它就是鸭子。如果一个 T,满足我们对 X 的所有要求,那么它就是 X。这就是 duck typing,即鸭子类型。
C++ 的 Template 是一种鸭子类型。好处:
- 完整的类型安全性,没有任何环节丢掉了类型信息。
- 不需要动态绑定,所有环节都是静态的,没有运行时性能损失。
但也有坏处:
- 模板类型会作为模板函数或模板类的原型的一部分,即
vector<int>和vector<double>是两个类型,没办法用一个类型来表示。 - 每次用不同的参数类型来实例化模板时,都会新生成一份代码,导致编译出来的二进制文件很大。
196.2 C++ 中结合继承与 Template 的 Type Erasure
在之前我们介绍了两种 C++ 中 Type Erasure 的形式 —— 继承和模板。但两者都有其缺点:
- 仅用继承,要求类型必须派生自同一基类(侵入性)
- 仅用模板,无法统一类型。例如,模板本身不能把
int和std::string放进同一个std::vector,这意味着我们没有“共同类型”来存储不同模板实例化的对象。
不过我们可以结合继承与模板,实现出一种 Type Erasure,它既有模板的优点(捕获任意类型,并且保留类型信息,解决了继承必须侵入的问题),又有继承的优点(通过基类管理所有类型,解决了模板无法统一类型的问题)。
一般来说,当我们使用 继承+模板 形式的类型擦除时,实现步骤是相似的:
- 定义抽象接口:通常是一个抽象基类,或者说是接口类,在类内声明纯虚函数,代表需要支持的操作
- 封装具体类型:创建一个模板类并且继承自前面的抽象基类,将具体类型的对象存储为成员变量;实现基类的虚函数,将操作转发给具体类型的对象。
- 提供包装类:持有基类的指针,并提供统一的接口;使用模板构造函数,构造具体的模板派生类对象。
在设计时,我们通常会将 抽象接口 和 模板派生类 作为 包装类 的内部类。
接口 -> 适配器 -> 包装器
例如我们要设计一个计数器类。
- 首先,我们定义一个抽象基类,声明我们需要支持的操作(纯虚函数)
class CounterBase {
public:virtual ~CounterBase() = default;virtual void Increase(int v) = 0;virtual void Decrease(int v) = 0;virtual int Count() const = 0;
};
- 接下来创建一个继承自抽象基类的模板类,将具体类型的对象存储为成员变量,并将接口操作转发给具体类型的对象
template <typename T>
class CounterImpl: public CounterBase {
public:explicit CounterImpl(T t): mImpl(std::move(t)) {}void Increase(int v) override { mImpl.Increase(v); }void Decrease(int v) override { mImpl.Decrease(v); }int Count() const override { return mImpl.Count(); }
private:T mImpl; // 具体类型的对象
};
- 最后我们还用定义一个
Counter类型,它的构造函数是一个模板函数但它不是一个模板类,这样可以隐藏“类型擦除”或者说是“多态”的细节。同样的该类也不需要包含虚函数或继承自其它类,具体的实现细节通过一个pImpl成员指针实现(指针可以动态绑定),其实我们可以发现,Counter类其实就是对CounterImpl类的包装,CounterBase类定义了接口的规格,CounterImpl类提供了具体的实现,最后Counter类对CounterImpl类进行了包装并提供给外界。
class Counter {
public:template <typename T>Counter(T t): mPtr(new CounterImpl(t)) {}void Increase(int v) {mPtr->Increase(v);}void Decrease(int v) {mPtr->Decrease(v);}int Count() const {return mPtr->Count();}
private:std::unique_ptr<CounterBase> mPtr;
};
然后我们就可以使用 Counter 来表示所有满足条件的类型了:
Counter c1(ClassA{});
Counter c2(ClassB{5});
Counter c3 = ClassC{3, 6};
对于没有 Increase、Decrease、Count 接口的类型,比如内置类型 int,我们还可以特化模板来满足要求:
template <>
class CounterImpl<int>: public CounterBase {
public:explicit CounterImpl(int v): mValue(v) {}void Increase(int v) override {mValue += v;}void Decrease(int v) override {mValue -= v;}int Count() const override {return mValue;}
private:int mValue;
};
然后我们就可以写:
Counter c = 5;
196.3 C++ 中 Type Erasure 的例子
196.3.1 std::shared_ptr
我们知道 std::shared_ptr 的 Deleter 不是 std::shared_ptr 类型的一部分(参见为什么 unique_ptr 的 Deleter 是模板类型参数,而 shared_ptr 的 Deleter 不是),这给使用者带来了很多好处(相比 std::unique_ptr):
- 对于
std::shared_ptr<T>,使用者不需要知道T的完整类型(当然创建者需要)。 - 两个
std::shared_ptr<T>对象类型相同,可以相互赋值,即使它们的 Deleter 类型不同。 - 销毁
T使用的 Deleter 永远是来自创建std::shared_ptr的编译单元,跨 DLL 和 so 使用时不会有销毁问题。
它的秘诀就是 Type Erasure。参考 clang 的实现,std::shared_ptr 只有两个成员变量:
template <typename _Tp>
class shared_ptr {
private:_Tp* __ptr__;__shared_weak_count* __cntrl__;
};
其中 __shared_weak_count 的定义为:
class __shared_weak_count;
可以看到它不包含 Deleter 的类型。实际上构造的类型是它的子类__shared_ptr_pointer:
template <class _Tp, class _Dp, class _Alloc>
class __shared_ptr_pointer: public __shared_weak_count;
具体的实现略。可以看到这里就使用了我们上面提到的继承与 Template 结合的方法。
196.3.2 std::function
std::function中使用了一个基类 __base:
template<class _Rp, class ..._ArgTypes>
class __base<_Rp(_ArgTypes...)> {
public:...virtual _Rp operator()(_ArgTypes&& ...) = 0;
};
同样地,具体的子类通过模板来保留类型信息,而通过基类来实现统一的存储与调用。(也即继承 + Template 实现 Type Erasure)
196.3.3 boost::any
boost::any 是非常典型的应用了 Type Erasure 方法的类型。它允许你用一种类型来保存任何类型的对象,且通过 type_info 方法返回具体的对象类型。这样我们可以使用一个 boost::any 的容器保存任意类型的对象。它的实现很短,只有313 行,很值得看一下。
196.3.4 std::any
std::any 是 C++17 引入的新类型,与 boost::any 的接口几乎完全相同,区别在于,它使用了 SBO(Small Buffer Optimization) 方法,可以把小对象直接构造在类型内部,性能更好。
196.4 基于 Type Erasure 实现 Unified Call Syntax
假设我们想实现一个接口类型 Fooable,它有一个方法 foo,使得 Fooable::foo 和 foo(Fooable) 都可以用来表示 T::foo 和 foo(T) 两种调用方式,即:
struct Member {void foo();
};struct NonMember{};
void foo(const NonMember&);Fooable member_erased {Member{}};
foo(member_erased);
member_erased.foo();Fooable non_member_erased {non_member{}};
foo(non_member_erased);
non_member_erased.foo();
第一步我们先定义基类:
class Storage {virtual void call() = 0;
};
第二步定义模板子类:
template <typename T, bool HasMemberFoo = has_member_foo<T>::value>
struct StorageImpl: Storage {T m_t;StorageImpl (T t): m_t {std::move(t)} {}void call() override {m_t.foo();}
};
其中has_member_foo是用来判断T::foo是否存在的辅助类型:
template <typename T, typename = void>
struct has_member_foo: std::false_type{};template <typename T>
struct has_member_foo<T, std::void_t<decltype(std::declval<T>().foo())>> : std::true_type{};
当T::foo()是个合法的表达式时,has_member_foo<T>::value就是true,否则就是false。
然后我们为false准备一个特化版本:
template <typename T>
struct StorageImpl<T, false> : Storage {T m_t;StorageImpl (T t) : m_t {std::move(t)} {}void call() override {foo(m_t);}
};
最后实现Fooable类型:
class Fooable {
public:template <typename T>Fooable (T t): m_storage {std::make_unique<StorageImpl<T>>(std::move(t))}{}void foo() { m_storage->call(); }
private:std::unique_ptr<Storage> m_storage;
};void foo(Fooable& f) { f->foo(); }
196.5 什么时候需要 Type Erasure
简单来说,如果你有下面两个需求,你可能是需要 Type Erasure 的:
- 你需要用同一种方式处理不同的类型。
- 你需要用同一种类型或容器保存不同类型的对象。
然而在很多情况下,你可能只需要用 std::shared_ptr 或 std::function 就能达到这个目的,这个时候就不需要自己实现 Type Erasure 了。
196.6 参考文章
- https://fuzhe1989.github.io/2017/10/29/cpp-type-erasure/
- https://akrzemi1.wordpress.com/2013/11/18/type-erasure-part-i/
- https://akrzemi1.wordpress.com/2013/12/06/type-erasure-part-ii/
- https://stackoverflow.com/questions/5450159/what-type-erasure-techniques-are-there-and-how-do-they-work
197. std::any
197.1 使用
std::any 是 C++17 引入的一个标准库类,用于安全地存储和检索任何类型的单个值。它的核心思想和您之前看到的 Function 类非常相似,都使用了类型擦除(Type Erasure)技术。
简单来说,std::any 的作用就像一个可以装下任何东西的魔法盒子。你可以把一个 int 放进去,也可以把一个 std::string 放进去,而且它会记住里面到底是什么类型,并在你需要的时候安全地取出来。
197.1.1 存储任意类型
你可以用任何类型的对象来初始化或赋值给 std::any 实例。
#include <any>
#include <string>
#include <vector>
#include <iostream>int main() {std::any a; // 默认构造,a 是空的a = 123; // a 现在存储了一个 intstd::cout << "当前存储的是整数: " << std::any_cast<int>(a) << std::endl;a = std::string("Hello, any!"); // a 现在存储了一个 std::stringstd::cout << "当前存储的是字符串: " << std::any_cast<std::string>(a) << std::endl;a = std::vector<int>{1, 2, 3}; // a 现在存储了一个 std::vector<int>std::cout << "当前存储的是向量: " << std::any_cast<std::vector<int>>(a).size() << std::endl;
}
197.1.2 安全的类型转换
std::any 的核心功能是它的类型安全。当你想要取回存储的值时,必须明确告知它你期望的类型,这通过 std::any_cast 函数来实现。
- 如果类型匹配:
std::any_cast会成功返回存储的值。 - 如果类型不匹配:
std::any_cast会抛出std::bad_any_cast异常,防止了未知的行为和数据损坏。
#include <any>
#include <string>
#include <iostream>int main() {std::any a = std::string("test");try {// 尝试将 any_cast 转换为错误的类型int value = std::any_cast<int>(a);std::cout << value << std::endl;} catch (const std::bad_any_cast& e) {// 捕获到异常,程序不会崩溃std::cerr << "捕获到异常: " << e.what() << std::endl;}
}
197.1.3 检查和状态
std::any 还提供了一些方法来检查其当前状态:
a.has_value():返回true如果a存储了值,否则返回false。a.type():返回一个std::type_info对象,你可以用它来获取存储值的类型信息。
197.2 实现
参考:https://zhuanlan.zhihu.com/p/597223487
首先,在 C 语言中,很多库都是使用 void* 来实现动态类型的。
因为 void* 可以指向任意类型。
struct A {};
struct B {};
int main() {void *p;p = new A();p = new B();p = new vector<int>();
}
同样的,还有父类型的指针可以指向子类型,以此将 base* 设计为 void* 的效果。
struct A{};
struct B : A{};
struct C : A{};
int main() {A *p;p = new B();p = new C();
}
当然在一些限定了类型种类的语言解析器中。会使用 union 加一个 type 来实现动态类型。
struct Val {union {int i_val;float f_val;string s_val;// ...}int type;
}int main()
{Var x;if(x.type == INT) {// ...}// ...
}
这里,我们准备利用父类指针可以指向子类来实现一个 std::any。
我们一步步的来,首先是父类与子类
struct Base {virtual ~Base() = default;
};template<typename T>
struct Data : Base{T data;Data(const T& t) : data(t) {}
};int main() {Base* p;p = new Data<string>("hello world");p = new Data<int>(3);
}
通过模板,生成不同的 Data 子类。
不过上面的实现很不美观和简介,例如:
- 每次都要写
Data<type>,需要显式指定类型 - 需要自己手动 new 内存
所以用一个类包一下吧
struct Any {struct Base {virtual ~Base() = default;};template<typename T>struct Data : Base{Data(const T& t) : data(t) {}T data;};Any() : p(nullptr) {}template<typename T>Any(const T &t) : p(new Data<T>(t)) {}Base *p;
};struct Point {int x, y;
};int main() {Any a;a = 1;a = string("hello world");a = Point{1, 2};
}
将 Base 和 Data<Type> 以及 Base *p 都封装在了 Any 中,我们使用起来就更加简洁明了了。
但现在还有一个问题,这个 Any 里面只有一个 Base 的指针,而真正的内容在子类里面。所以呢?我们需要加上一个强制类型转换来获得子类的数据。
struct Any {struct Base {virtual ~Base() = default;};template<typename T>struct Data : Base{Data(const T& t) : data(t) {}T data;};Any() : p(nullptr) {}template<typename T>Any(const T &t) : p(new Data<T>(t)) {}// 注意,在这里需要指定 Data<Type> 的 Type,因此这个 Type 是运行时信息template<typename T>T& get_data() {return dynamic_cast<Data<T>*>(p)->data;}Base *p;
};struct Point {int x, y;
};ostream& operator<<(ostream &os, const Point &p) {return os << '(' << p.x << ',' << p.y << ')';
}int main() {Any a;a = 1;cout << a.get_data<int>() << endl;a = string("hello world");cout << a.get_data<string>() << endl;a = Point{1, 2};cout << a.get_data<Point>() << endl;return 0;
}
好,现在可以获取 Any 保存的数据了, 那么如何实现复制呢?我们可以像实现 get_data<T> 一样显式指定类型,然后 new 出来一个,但是这样不够优雅,我们不想显式指定类型。一个可行的方案是引入运行时消耗,也即虚函数(虽然将 Base 设计为基类已经引入了运行时消耗 -- 虚析构函数):
struct Base {virtual Base* clone() = 0;virtual ~Base() = default;
};template<typename T>
struct Data : Base{virtual Data<T>* clone() { return new Data<T>(data); }Data(const T& t) : data(t) {}T data;
};
这样通过 clone 就可以完成深拷贝了。
最后,我们再添加移动操作以及一些别的辅助函数等,完整代码如下所示:
class Any {
private:struct Base {virtual Base* clone() = 0;virtual const std::type_info& type() const = 0;virtual std::type_index type_index() const = 0;virtual ~Base() = default;};template<typename T>struct Data : Base{virtual Data<T>* clone() override { return new Data<T>(data); }virtual const std::type_info& type() const override { return typeid(T); }virtual std::type_index type_index() const override { return std::type_index(type()); }Data(const T& t) : data(t) {}T data;};public:void reset() {if(p) {delete p;p = nullptr; // 别忘了}}bool has_value() const noexcept { return p != nullptr; }const std::type_info& type() const {if(!p) return typeid(void);return p->type();}std::type_index type_index() const {if(!p)return std::type_index(typeid(void));return p->type_index();}template<typename T>T& get_data() {check_validity<T>();return static_cast<Data<T>*>(p)->data;}template<typename T>const T& get_data() const {check_validity<T>();return static_cast<const Data<T>*>(p)->data;}public:Any() : p(nullptr) {}template<typename T>Any(const T &t) : p(new Data<T>(t)) {}~Any() { reset(); }Any(const Any &a) : p(nullptr) {if(a.p != nullptr) {p = a.p->clone();}}Any(Any &&a) noexcept : p(a.p) {a.p = nullptr;}void swap(Any &other) noexcept {using std::swap;swap(p, other.p);}Any& operator=(const Any &a) {Any tmp(a); // copyswap(tmp); // swapreturn *this;}Any& operator=(Any &&a) noexcept {// 这里比较 (&a,this) 而不是 (a,*this) 是因为前者是地址的比较:存在相应 operator==// 而后者是对 Any 的比较:不存在相应 operator==if(this != &a) {reset();p = a.p;a.p = nullptr;}return *this;}template<typename T>Any& operator=(const T value) {Any tmp(value);swap(tmp);return *this;}private:Base *p;template<typename T>void check_validity() const {if(!p)throw std::runtime_error("Any is empty");if(typeid(T) != p->type())throw std::bad_cast();}
};struct Point {int x, y;bool operator==(const Point &other) const {return x == other.x && y == other.y;}
};ostream& operator<<(ostream &os, const Point &p) {return os << '(' << p.x << ',' << p.y << ')';
}ostream& operator<<(ostream &os, const vector<int> &v) {os << "[";for(auto it = v.begin(); it != v.end(); it ++ ) {if(it != v.begin()) os << ",";os << *it;}os << "]";return os;
}int main()
{Any a;// 测试大小cout << "size of Any: " << sizeof(Any) << endl;cout << "size of std::any: " << sizeof(any) << endl;cout << endl;// 测试 non-const 版本a = 1;cout << "Integer: " << a.get_data<int>() << endl;cout << "Type: " << a.type().name() << endl;a = string("hello world");cout << "String: " << a.get_data<string>() << endl;cout << "Type: " << a.type().name() << endl;a = Point{1, 2};cout << "Point: " << a.get_data<Point>() << endl;cout << "Type: " << a.type().name() << endl;a = vector<int>{1, 2, 3, 4, 5};cout << "vector<int>: " << a.get_data<vector<int>>() << endl;cout << "Type: " << a.type().name() << endl;cout << endl;// 测试 const 版本const Any const_any(42);cout << "Const int: " << const_any.get_data<int>() << endl;cout << endl;// 测试 reset 和 has_valueAny b(3.14);cout << "Has value: " << boolalpha << b.has_value() << endl;b.reset();cout << "After reset - Has value: " << b.has_value() << endl;cout << endl;// 测试类型比较Any c(100);Any d(string("test"));cout << "Same type? " << (c.type_index() == d.type_index()) << endl;cout << endl;// 测试异常处理try {Any empty;empty.get_data<int>(); // 应该抛出异常} catch (const exception& e) {cout << "Expected exception: " << e.what() << endl;}try {Any num(42);num.get_data<string>(); // 应该抛出bad_cast} catch (const bad_cast& e) {cout << "Expected bad_cast: " << e.what() << endl;}return 0;
}
2025/9/15 第二次实现
// implement Type Erasure by Template + Inheritance
class Any {
private:struct Base {virtual Base* clone() const = 0;virtual const std::type_info& type() const = 0;virtual ~Base() = default;};template<typename T>struct Impl : Base {T data;Base* clone() const override { return new Impl<T>(data); }const std::type_info& type() const override { return typeid(T); }explicit Impl(const T &t) : data(t) {}};public:// constructorAny() : pimpl(nullptr) {}template<typename T>Any(const T &t) : pimpl(new Impl<typename std::decay<T>::type>(t)) {}template<typename T>Any(T &&t) : pimpl(new Impl(std::forward<typename std::decay<T>::type>(t))) {}Any(const Any &t) : pimpl(t.pimpl ? t.pimpl->clone() : nullptr) {}Any(Any &&t) noexcept : pimpl(t.pimpl) { t.pimpl = nullptr; }// destructor~Any() { delete pimpl; }// assignmentAny& operator=(const Any &t) {Any tmp(t); // copytmp.swap(*this); // swapreturn *this;}Any& operator=(Any &&t) noexcept {if(this != &t) {delete pimpl;pimpl = t.pimpl;t.pimpl = nullptr;}return *this;}template<typename T>Any& operator=(T &&value) {Any tmp(std::forward<T>(value));tmp.swap(*this);return *this;}// return datatemplate<typename T>T& get_data() {if(pimpl == nullptr)throw bad_any_cast();Impl<typename std::decay<T>::type>* derived = dynamic_cast<Impl<typename std::decay<T>::type>*>(pimpl);if(derived == nullptr)throw bad_any_cast();return derived->data;}template<typename T>const T& get_data() const {if(pimpl == nullptr)throw bad_any_cast();const Impl<typename std::decay<T>::type>* derived = dynamic_cast<const Impl<typename std::decay<T>::type>*>(pimpl);if(derived == nullptr)throw bad_any_cast();return derived->data;}public:// swapvoid swap(Any &other) noexcept {std::swap(pimpl, other.pimpl);}bool has_value() const noexcept {return pimpl != nullptr;}void reset() noexcept {delete pimpl;pimpl = nullptr;}const std::type_info& type() const noexcept {return pimpl ? pimpl->type() : typeid(void);}private:// manage low-level resource by smart pointerBase *pimpl;
};// global swap
void swap(Any &lhs, Any &rhs) noexcept {lhs.swap(rhs);
}// Any_cast
template<typename T>
T Any_cast(const Any &operand) {return operand.get_data<T>();
}template<typename T>
T Any_cast(Any &operand) {return operand.get_data<T>();
}template<typename T>
T Any_cast(Any &&operand) {return std::move(operand.get_data<T>());
}template<typename T>
T* Any_cast(Any *operand) noexcept {if(operand == nullptr) return nullptr;try {return &operand->get_data<T>();} catch (const bad_any_cast&) {return nullptr;}
}template<typename T>
const T* Any_cast(const Any* operand) noexcept {if (operand == nullptr) return nullptr;try {return &operand->get_data<T>();} catch (const bad_any_cast&) {return nullptr;}
}ostream& operator<<(ostream &os, const vector<int> &v) {os << "[";for(auto it = v.begin(); it != v.end(); ++ it) {if(it != v.begin())os << ",";os << *it;}return os << "]";
}int main()
{// std::any a;// cout << sizeof(any) << endl;// a = 123;// cout << "int: " << any_cast<int>(a) << endl;// a = string("hello");// cout << "string: " << any_cast<string>(a) << endl;Any a;a = 3;cout << a.get_data<int>() << endl;a = string("hello");cout << a.get_data<string>() << endl;a = vector<int>{1, 2, 3, 4, 5};cout << a.get_data<vector<int>>() << endl;Any a1;Any a2(a1);Any a3 = a1;a1= a2;a1 = string("hello");a2 = 3.14;a3 = a1;a3 = a2;// 测试 Any_castAny test_cast = 42;cout << "Any_cast: " << Any_cast<int>(test_cast) << endl;// 测试指针版本的 Any_castint* ptr = Any_cast<int>(&test_cast);if (ptr) {cout << "Pointer Any_cast: " << *ptr << endl;}// 测试错误情况try {Any wrong_type = string("test");int wrong = Any_cast<int>(wrong_type); // 应该抛出异常} catch (const bad_any_cast& e) {cout << "Caught exception: " << e.what() << endl;}return 0;
}
198. std::variant
198.1 使用
1. 什么是 std::variant?
std::variant是 C++17 标准库中加入的一个类模板,它代表一个类型安全的联合体(union)。它可以持有其模板参数列表中指定的任何类型的值,并且在任何时刻,该值都只能是其中一种类型。你可以把它想象成一个“智能的”、“类型安全的”
union,它自动管理当前激活的类型、对象的构造和析构,并且提供了方便的API来访问存储的值。头文件:
#include <variant>2. 为什么需要 std::variant?
在
std::variant出现之前,如果你想实现类似的功能,通常有几种选择:
- 使用
union: 但原生的union是非类型安全的,它无法知道当前存储的是哪种类型。你需要额外维护一个标签变量来记录当前类型,手动管理不同成员的构造和析构(对于非平凡类型非常麻烦且容易出错)。- 使用继承和多态: 定义一个公共基类,然后派生出各种子类。这种方式是类型安全的,但需要动态内存分配(使用指针),可能引入性能开销,并且代码结构会更复杂。
- 使用
std::any:std::any可以容纳任何类型的值,但它完全不限制类型,失去了编译时的类型信息。你需要使用any_cast来尝试转换,如果类型不匹配会抛出异常。
std::variant解决了上述问题:
- 类型安全: 它知道当前存储的确切类型。
- 无需动态内存分配: 它在栈上分配足够大的空间以容纳其类型列表中的最大类型。
- 自动生命周期管理: 自动调用所含对象的构造函数和析构函数。
- 编译时类型检查: 其类型集合在编译时是固定的,这使得编译器可以进行更多优化和检查。
3. 核心特性和用法
a. 创建和赋值
#include <variant> #include <string> #include <iostream>int main() {// 定义一个可以容纳 int, float, std::string 的 variantstd::variant<int, float, std::string> v;// 当前持有的是第一个类型(int)的默认值,即 0v = 12; // 现在持有 intv = 2.5f; // 现在持有 float,之前的 int 被正确析构v = "hello"; // 现在持有 std::string,之前的 float 被正确析构// 使用 std::get 来获取值(如果类型正确)try {std::string s = std::get<std::string>(v); // 获取成功std::cout << "String: " << s << std::endl;// 如果当前类型不是 int,则会抛出 std::bad_variant_access 异常int i = std::get<int>(v);} catch (const std::bad_variant_access& e) {std::cout << "Exception: " << e.what() << std::endl;}// 使用索引来获取也是可以的float f = std::get<1>(v); // 索引 1 对应 float,但当前是 string,同样会抛出异常return 0; }b. 访问值:std::visit (推荐方式)
使用
std::get需要提前知道确切的类型或索引,否则会抛出异常。更安全、更通用的方式是使用std::visit,它接受一个访问器(Callable对象,如函数、Lambda表达式)和variant,然后根据variant当前存储的类型来调用相应的重载。访问器通常是一个重载了
operator()的对象,使用std::visit时需要为variant所能持有的每一种类型都提供一个重载版本。C++17/20 的现代写法(推荐):
// 定义一个访问器,它是一个重载了多个 operator() 的类 struct MyVisitor {void operator()(int i) {std::cout << "int: " << i << std::endl;}void operator()(float f) {std::cout << "float: " << f << std::endl;}void operator()(const std::string& s) {std::cout << "string: " << s << std::endl;} };int main() {std::variant<int, float, std::string> v = "hello";// 使用访问器std::visit(MyVisitor{}, v); // 输出: string: hello// 更常用的方式是使用泛型 Lambda (C++17) 和模板推导指引 (C++20 的 overloaded 模式)// 一个常见的技巧:使用 overloaded 模式组合多个 lambdaauto visitor = overloaded {[](int i) { std::cout << "int: " << i << std::endl; },[](float f) { std::cout << "float: " << f << std::endl; },[](const std::string& s) { std::cout << "string: " << s << std::endl; }};std::visit(visitor, v); // 输出: string: helloreturn 0; }// 需要定义 overloaded 辅助类(C++20 之前) template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; }; // CTAD 推导指引(C++17 起) template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
std::visit是处理variant最强大和最安全的方式,它保证了所有类型分支都会被处理。c. 查询当前状态
index(): 返回当前持有的值的类型在模板参数列表中的索引(从0开始)。std::variant<int, float, std::string> v = 42; std::cout << v.index(); // 输出 0 (int 是第0个类型) v = 3.14f; std::cout << v.index(); // 输出 1 (float 是第1个类型)
valueless_by_exception(): 检查variant是否因为异常而处于无值的状态(非常罕见)。d. 其他实用函数
std::get_if: 尝试获取指向特定类型值的指针。如果当前类型不匹配,则返回nullptr。这对于避免异常很有用。if (auto pval = std::get_if<int>(&v)) {std::cout << "value is int: " << *pval << std::endl; } else if (auto pval = std::get_if<std::string>(&v)) {std::cout << "value is string: " << *pval << std::endl; }
std::holds_alternative: 检查variant当前是否持有特定类型。if (std::holds_alternative<int>(v)) {std::cout << "v holds an int\n"; }4. 与 std::monostate 一起使用
std::variant的默认构造函数会使用其第一个类型的默认构造函数来初始化。如果第一个类型没有默认构造函数(例如std::mutex),那么variant的默认构造就会编译失败。为了解决这个问题,可以引入
std::monostate作为第一个类型。std::monostate是一个空类,它的唯一作用就是作为一个“可默认构造”的占位符类型,表示“空”或“无值”的状态。#include <variant> #include <mutex>// 错误:std::mutex 没有默认构造函数,因此这个 variant 也无法默认构造 // std::variant<std::mutex, int> v;// 正确:引入 std::monostate 作为第一个类型 std::variant<std::monostate, std::mutex, int> v; // 现在可以默认构造了,初始状态为 monostateif (std::holds_alternative<std::monostate>(v)) {std::cout << "v is empty\n"; } v = 42; // 现在持有 int v = std::mutex(); // 现在持有 mutex5. 总结
特性 描述 类型安全 编译时已知的可能类型集合,运行时知道当前类型。 资源管理 自动处理所含对象的构造、析构和赋值。 访问方式 通过 std::get(可能抛出异常)、std::get_if(返回指针)或std::visit(访问者模式,最安全强大)。空间效率 大小约为其所能容纳的最大类型的大小加上少量管理开销(如存储当前类型的索引)。 典型用例 解析器的结果(可能是不同种类的Token或AST节点)、函数返回值(可能是正常值或错误码)、状态机中的状态数据、解析JSON/XML等数据格式等。 简单来说,
std::variant是当你需要一个变量可以偶尔持有不同类型值时的首选工具,它比union更安全,比继承更轻量,比std::any更精确。
198.2 实现
199. std::function
199.1 源码分析
std::function 模板类的定义如下,有一个标准函数指针类型的成员变量 _M_invoker。
template<typename _Res, typename... _ArgTypes>
class function<_Res(_ArgTypes...)>: public _Maybe_unary_or_binary_function<_Res, _ArgTypes...>, private _Function_base
{
private:using _Invoker_type = _Res (*)(const _Any_data&, _ArgTypes&&...);_Invoker_type _M_invoker;
// ... ...
// 还有很多成员函数
};
上面 std::function 模板类的定义,实际上是一个 偏特化版本(模板参数为函数类型 _Res(_ArgTypes...)),_Res 为可调用目标的返回值类型,可调用目标的入参为 ..._ArgTypes。
我们之所以能通过 operator() 完成对 function 的调用是因为 function 重载了 operator() 操作符,如下所示,operator() 对函数指针 _M_invoker 进行调用,_M_invoker 其中一个入参为 _M_functor(_M_functor 为基类 _Function_base 中的成员变量),类型为 _Any_data。可以推测:_M_invoker 并不直接管理可调用目标,而是对可调用目标进行调用;而可调用目标是被 _Function_base::_M_functor 管理的。
template<typename _Res, typename... _ArgTypes>
_Res function<_Res(_ArgTypes...)>::operator()(_ArgTypes... __args) const
{if (_M_empty())__throw_bad_function_call();return _M_invoker(_M_functor, std::forward<_ArgTypes>(__args)...);// _M_functor 的类型为 _Any_data
}
要证明上述的推测,就需要看看 function 实例在声明时是怎么初始化 _M_invoker 的。构造函数 function(_Functor __f) 完成了对 _M_invoker 的初始化。
template<typename _Res, typename... _ArgTypes>template<typename _Functor, typename = _Requires<__not_<is_same<_Functor, function>>, void>, typename = _Requires<_Callable<_Functor>, void>>
function<_Res(_ArgTypes...)>::function(_Functor __f): _Function_base()
{typedef _Function_handler<_Res(_ArgTypes...), _Functor> _My_handler;if (_My_handler::_M_not_empty_function(__f)){// 由此可以看出_Function_base::_M_functor管理了可调用目标。_My_handler::_M_init_functor(_M_functor, std::move(__f));// class function的_M_invoker被初始化为_My_handler::_M_invoke// 推测在_My_handler::_M_invoke中完成对_Function_base::_M_functor管理目标的调用_M_invoker = &_My_handler::_M_invoke;_M_manager = &_My_handler::_M_manager;}
}
通过 _My_handler::_M_init_functor(_M_functor, std::move(__f)); 可以证实,_Function_base::_M_functor 管理了可调用目标。而 _M_invoker 被初始化为 _My_handler::_M_invoke,因此在 _My_handler::_M_invoke 中完成对 _Function_base::_M_functor 所管理可调用目标的调用。
为了进一步完成探究,我们需要查看模板类 _Function_handler 的源码,一共有四个特化版本。
// 版本一
template<typename _Res, typename _Functor, typename... _ArgTypes>
class _Function_handler<_Res(_ArgTypes...), _Functor>: public _Function_base::_Base_manager<_Functor>
{typedef _Function_base::_Base_manager<_Functor> _Base;
public:static _Res _M_invoke(const _Any_data& __functor, _ArgTypes&&... __args){return (*_Base::_M_get_pointer(__functor))(std::forward<_ArgTypes>(__args)...);}
};
// 版本二
template<typename _Functor, typename... _ArgTypes>
class _Function_handler<void(_ArgTypes...), _Functor>: public _Function_base::_Base_manager<_Functor>
{typedef _Function_base::_Base_manager<_Functor> _Base;
public:static void _M_invoke(const _Any_data& __functor, _ArgTypes&&... __args){(*_Base::_M_get_pointer(__functor))(std::forward<_ArgTypes>(__args)...);}
};
// 版本三,继承_Function_handler<void(_ArgTypes...), _Member _Class::*>
// _Function_handler<void(_ArgTypes...), _Member _Class::*>为版本四
template<typename _Class, typename _Member, typename _Res, typename... _ArgTypes>
class _Function_handler<_Res(_ArgTypes...), _Member _Class::*>: public _Function_handler<void(_ArgTypes...), _Member _Class::*>
{typedef _Function_handler<void(_ArgTypes...), _Member _Class::*>_Base;
public:static _Res _M_invoke(const _Any_data& __functor, _ArgTypes&&... __args){return std::__invoke(_Base::_M_get_pointer(__functor)->__value,std::forward<_ArgTypes>(__args)...);}
};
// 版本三,继承_Function_base::_Base_manager的特化版本
// _Function_base::_Base_manager<_Simple_type_wrapper< _Member _Class::* > >
// 模板实参为_Simple_type_wrapper< _Member _Class::* >
template<typename _Class, typename _Member, typename... _ArgTypes>
class _Function_handler<void(_ArgTypes...), _Member _Class::*>: public _Function_base::_Base_manager<_Simple_type_wrapper< _Member _Class::* > >
{typedef _Member _Class::* _Functor;typedef _Simple_type_wrapper<_Functor> _Wrapper;typedef _Function_base::_Base_manager<_Wrapper> _Base;
public:static bool _M_manager(_Any_data& __dest, const _Any_data& __source,_Manager_operation __op){switch (__op){case __get_functor_ptr:__dest._M_access<_Functor*>() =&_Base::_M_get_pointer(__source)->__value;break;default:_Base::_M_manager(__dest, __source, __op);}return false;}static void _M_invoke(const _Any_data& __functor, _ArgTypes&&... __args){std::__invoke(_Base::_M_get_pointer(__functor)->__value,std::forward<_ArgTypes>(__args)...);}
};
四个特化版本均继承自 _Function_base::_Base_manager。其中第一个和第二个特化版本存储的可调用目标是普通函数、类实例或者Lambda表达式,两个特化版本的区别为第一个存储的可调用目标有返回值,第二个存储的可调用目标没有返回值;其中第三个和第四个特化版本存储的可调用目标是类的成员函数,区别在于成员函数是否有返回值。
每一个特化版本均包含一个静态函数 _M_invoke,使用该函数初始化 function::_M_invoker,在该函数中完成可调用目标的调用。关于_Function_handler 的详细介绍将在下一节进行,本节主要关注可调用目标的存储和调用。
到此还未看到可调用目标到底是怎么存储起来的,存储在哪里。
由前面的分析,我们知道可调用目标是通过 _M_init_functor 函数存储的,_M_init_functor 是_Function_base::_Base_manager 的成员函数,因此我们接着分析 _Function_base 的源代码,_Function_base 是整个 std::function 的核心,_Function_base::_M_functor 接管了可调用目标。
class _Function_base
{
public:// 实际为2个指针的大小static const std::size_t _M_max_size = sizeof(_Nocopy_types);static const std::size_t _M_max_align = __alignof__(_Nocopy_types);_Function_base() : _M_manager(nullptr) { }~_Function_base(){if (_M_manager)_M_manager(_M_functor, _M_functor, __destroy_functor);}bool _M_empty() const { return !_M_manager; }typedef bool (*_Manager_type)(_Any_data&, const _Any_data&,_Manager_operation);// _M_functor接管可调用目标_Any_data _M_functor;_Manager_type _M_manager;// 含有一个内部模板类_Base_managertemplate<typename _Functor>class _Base_manager{// 源码分析见下文};
};
类型 _Any_data 如下所示,为联合体。含有两个联合体成员:_M_unused 和 _M_pod_data,接管的可调用对象存储在 _M_pod_data 中,并通过四个 _M_access() 函数将 _M_pod_data 转换(或者说是还原)为可调用对象返回出去。
union [[gnu::may_alias]] _Any_data
{void* _M_access() { return &_M_pod_data[0]; }const void* _M_access() const { return &_M_pod_data[0]; }template<typename _Tp>_Tp& _M_access(){ return *static_cast<_Tp*>(_M_access()); }template<typename _Tp>const _Tp& _M_access() const{ return *static_cast<const _Tp*>(_M_access()); }_Nocopy_types _M_unused;char _M_pod_data[sizeof(_Nocopy_types)];
};
_Nocopy_types 的定义如下,从成员的名称很容易看出它们的作用:
union _Nocopy_types
{void* _M_object; // 接管类实例或者Lambda表达式const void* _M_const_object; // 接管类实例或者Lambda表达式void (*_M_function_pointer)(); // 接管普通函数指针void (_Undefined_class::*_M_member_pointer)(); // 接管类成员函数指针
};class _Undefined_class;
对 _Function_base 的操作均是通过其内部模板类 _Base_manager 进行的,_Base_manager 提供了很多接口,源码如下所示,大家看注释可以有一个宏观的理解,具体细节后面逐步详解。
template<typename _Functor>
class _Base_manager // 所有成员均为静态的
{
protected:// __stored_locally 用来判断可调用目标存储在哪里// 决定 _M_pod_data 是作为存储空间存储接管对象// 还是作为指针,指向存储了接管对象所在的地址空间static const bool __stored_locally =(__is_location_invariant<_Functor>::value&& sizeof(_Functor) <= _M_max_size&& __alignof__(_Functor) <= _M_max_align&& (_M_max_align % __alignof__(_Functor) == 0));typedef integral_constant<bool, __stored_locally> _Local_storage;// 从_Function_base::_M_functor中取出可调用目标实体static _Functor* _M_get_pointer(const _Any_data& __source){const _Functor* __ptr =__stored_locally? std::__addressof(__source._M_access<_Functor>())/* have stored a pointer */ : __source._M_access<_Functor*>();return const_cast<_Functor*>(__ptr);}// 千呼万唤始出来,完成对 _Function_base::_M_functor 的初始化// 形参 __functor 接收实参 _Function_base::_M_functor// 将形参 __f 存储在实参 _Function_base::_M_functor中static void _M_init_functor(_Any_data& __functor, _Functor&& __f){ _M_init_functor(__functor, std::move(__f), _Local_storage()); }// 根据 _Local_storage() 调用以下两个 private 函数// _Local_storage() 返回 __stored_locally 的取值// 实际就是存储在哪里的问题,第一个存储在栈上,第二个存储在堆上// 使用 placement newstatic void _M_init_functor(_Any_data& __functor, _Functor&& __f, true_type){ ::new (__functor._M_access()) _Functor(std::move(__f)); }// 使用newstatic void _M_init_functor(_Any_data& __functor, _Functor&& __f, false_type){ __functor._M_access<_Functor*>() = new _Functor(std::move(__f)); }// 还有一个比较有趣的静态成员函数为_M_manager// 从命名就可以看出起“管理”的作用,实际上就是对内存的管理// 根据第三个参数来确定执行的功能:获取、克隆、析构static bool _M_manager(_Any_data& __dest, const _Any_data& __source,_Manager_operation __op){switch (__op){case __get_functor_ptr:__dest._M_access<_Functor*>() = _M_get_pointer(__source);break;case __clone_functor:_M_clone(__dest, __source, _Local_storage());break;case __destroy_functor:_M_destroy(__dest, _Local_storage());break;}return false;}// _M_destroy的源码如下所示,根据_Local_storage()调用不同的重载函数// Destroying a location-invariant object may still require// destruction.static void_M_destroy(_Any_data& __victim, true_type){// 存在本地的时候使用了placement new,所以需要显示调用析构__victim._M_access<_Functor>().~_Functor();}// Destroying an object located on the heap.static void_M_destroy(_Any_data& __victim, false_type){// 存在堆上的使用的是new,所以成对使用deletedelete __victim._M_access<_Functor*>();}
};
首先,详细分析一下 _Base_manager::__stored_locally:
static const bool __stored_locally =(__is_location_invariant<_Functor>::value&& sizeof(_Functor) <= _M_max_size&& __alignof__(_Functor) <= _M_max_align&& (_M_max_align % __alignof__(_Functor) == 0));
_Base_manager::__stored_locally 的取值由 sizeof(_Functor) <= _M_max_size 决定,_M_max_size 即为 sizeof(_Nocopy_types) 的大小。_Nocopy_types 的大小由关键成员 void (_Undefined_class::*_M_member_pointer)() 决定,类成员函数指针的大小为 2 个指针的大小。
所以当接管的可调用对象的大小大于 2 个指针大小时,就会动态分配内存。否则就会直接使用 _M_pod_data 作为存储空间,直接存储可调用对象。
普通函数指针的大小为一个指针大小,类成员函数指针的大小为 2 个指针,Lambda表达式、类对象的大小均有可能大于一个指针大小,甚至大于两个指针大小,取决于其成员变量的数量。
例如,使用 function 接管 Lambda 表达式:
#include <functional>int main()
{int a = 0;int b = 0;int c = 0;std::function<bool(int)> myFunc = [=](int i){ return i == (a+b); };return 0;
}
利用 C++ Insight 看一下实际代码,发现 Lambda 表达式实际上是被转换为一个仿函数 class __lambda_8_39,function 实际包裹的是一个类对象 __lambda_8_39(__lambda_8_39{a, b}),大小已经大于两个指针的大小。
#include <functional>int main()
{int a = 0;int b = 0;int c = 0;class __lambda_8_39{public:inline bool operator()(int i) const {return i == (a + b);}private:int a;int b;public:// inline /*constexpr */ __lambda_8_39(const __lambda_8_39 &) noexcept = default;// inline /*constexpr */ __lambda_8_39(__lambda_8_39 &&) noexcept = default;__lambda_8_39(int & _a, int & _b): a{_a}, b{_b}{}};std::function<bool (int)> myFunc = std::function<bool (int)>(std::function<bool (int)>(__lambda_8_39(__lambda_8_39{a, b})));return 0;
}
因此,如果你的代码极度注重性能,那么在使用 function 时就要注意到会动态分配内存这个点。
因此,根据 __stored_locally 来存储和释放内存。当 __stored_locally 为 true 时,_M_pod_data 作为存储空间,使用 placement new:
static void _M_init_functor(_Any_data& __functor, _Functor&& __f, true_type)
{ ::new (__functor._M_access()) _Functor(std::move(__f)); }
对应在 function 对象释放时,需要显示调用接管对象的析构函数:
static void
_M_destroy(_Any_data& __victim, true_type)
{// 存在本地的时候使用了placement new,所以需要显示调用析构__victim._M_access<_Functor>().~_Functor();
}
当 __stored_locally 为 false 时,_M_pod_data 作为指针指向包裹对象的存储空间,包裹对象存储在堆上,使用 new 动态分配内存:
static void _M_init_functor(_Any_data& __functor, _Functor&& __f, false_type)
{ __functor._M_access<_Functor*>() = new _Functor(std::move(__f)); }
对应在 function 对象释放时,需要使用 delete 释放内存:
static void
_M_destroy(_Any_data& __victim, false_type)
{delete __victim._M_access<_Functor*>();
}
看了这么多了,我们总结一下 std::function 涉及到的几个类之间的简化关系:

function的函数指针成员变量_M_invoker被初始化为_Function_handler的成员函数_M_invoker;_M_invoker调用了接管的可调用对象;function的基类_Function_base的成员_M_functor真正接管可调用对象,当可调用对象的大小大于两个指针时存储在堆上,_M_functor指向存储位置;_Function_base的内部类_Base_manager提供接口函数。
1️⃣
std::function
- 是最外层的模板类,用户直接使用的接口。
- 内部持有两个关键成员:
_M_functor:存储被接管的可调用对象;_M_invoker:函数指针,用于调用_M_functor中的对象。
_M_invoker的类型是一个统一的函数指针:_Res (*_M_invoker)(_Any_data&, _ArgTypes&&...);它负责在运行时调用不同类型的可调用对象,实现类型擦除。
2️⃣
_Function_base
- 是
std::function的基类,负责资源管理:
- 包含
_M_functor(实际存储可调用对象);- 定义
_M_manager(函数指针)用于拷贝、移动、销毁等操作;- 提供
_M_get_pointer()、_M_empty()等辅助方法。_M_functor使用 小对象优化(Small Object Optimization, SOO):
- 如果可调用对象的大小 ≤ 两个指针大小,就直接放在栈上;
- 否则动态分配在堆上,
_M_functor指向堆内存。
3️⃣
_Base_manager
- 是一个抽象层,提供统一的接口函数集合,用于:
- 拷贝、移动、销毁
_M_functor;- 获取指针;
- 判断是否在堆上;
- 返回类型信息。
_Base_manager是一个非模板的接口类,针对不同类型的_Functor,由_Function_handler进行具体实现。
4️⃣
_Function_handler
- 模板类:
_Function_handler<_Functor, _Res(_Args...)>- 继承自
_Base_manager,实现具体类型的可调用对象的操作逻辑:(非类成员函数,类成员函数,再根据有无返回值划分为四种具体类型)
_M_invoke():调用被包装的可调用对象;_M_manager():完成拷贝、移动、销毁操作;_M_get_pointer():返回内部对象地址。当
std::function绑定一个可调用对象(如 lambda、函数指针、std::bind结果等)时,
就会实例化一个对应类型的_Function_handler,并设置_M_invoker指向_Function_handler::_M_invoke
199.2 实现
#include <iostream>
#include <ctime>
#include <vector>
#include <utility>
#include <memory>
#include <functional>template<typename Ret, typename... Args>
struct Function;template<typename Ret, typename... Args>
struct Function<Ret(Args...)> {struct Base {virtual ~Base() = default;virtual Ret invoke(Args&&... args) = 0;virtual std::unique_ptr<Base> clone() const = 0;};template<typename T>struct Data : Base {T func;// pass by value and use std::moveexplicit Data(T t) : func(std::move(t)) {}std::unique_ptr<Base> clone() const override {return std::make_unique<Data<T>>(func);}Ret invoke(Args&& ...args) {return func(std::forward<Args>(args)...);}};std::unique_ptr<Base> ptr;Ret operator()(Args... args) {if(!ptr) throw std::bad_function_call{};return ptr->invoke(std::forward<Args>(args)...);}explicit operator bool() const noexcept { return static_cast<bool>(ptr); }Function() noexcept = default;template<typename T>Function(T t) : ptr(std::make_unique<Data<T>>(std::move(t))) {}Function(const Function &rhs) : ptr(rhs.ptr ? rhs.ptr->clone() : nullptr) {}Function(Function &&rhs) noexcept = default;Function& operator=(Function rhs) noexcept {swap(rhs);return *this;}void swap(Function &other) noexcept { ptr.swap(other.ptr); }void reset() noexcept { ptr.reset(); }~Function() = default;
};template<typename Ret, typename... Args>
void swap(Function<Ret(Args...)> &a, Function<Ret(Args...)> &b) noexcept {a.swap(b);
}using namespace std;void sum(int n, Function<void()>& call_back) {int s = 0;for (int i = 1; i <= n; i++) s += i;call_back = [=]() {cout << "sum from 1 to " << n << " is " << s << endl;};
}class Foo {
public:Foo() {}Foo(const Foo&) { cout << "copy ctor" << endl; }Foo(Foo&&) { cout << "move ctor" << endl; }Foo& operator=(const Foo&) { cout << "copy assignment" << endl; }Foo& operator=(Foo&&) noexcept { cout << "move assignment" << endl; }~Foo() {}void do_something() { int x = 1 + 1; int y = x; }
};void func(const Foo &t)
{Foo tmp = t;tmp.do_something();
}int main() {srand(static_cast<unsigned>(time(nullptr)));vector<Function<void()>> func(3);for (auto &f : func) {sum(rand() % 100, f);}Function<int(int)> fib = [&](int n) {if (n == 0 || n == 1) return 1;return fib(n - 1) + fib(n - 2);};for (int i = 0; i < 10; i++) cout << fib(i) << " ";cout << endl;for (auto& f : func) {if (f) f();}
}
199.3 参考资料
[1] https://zhuanlan.zhihu.com/p/560964284
[2] https://zhuanlan.zhihu.com/p/602560111
200. 深度解析虚表
https://www.zhihu.com/question/389546003
问题描述:直接用函数指针实现虚函数不是更好吗?而且比虚表还更灵活,可以直接修改对象的方法,不用通过继承重新创建对象。还有就是我觉得 private 关键字也是个毒瘤。在 C++ 这种语言上,本来就不存在真正 private 的东西。这个关键字的本意应该是开发的时候,通过报错来提醒调用了不该调用的东西。但是到了调用第三方库的时候,这个机制反倒成了开发的阻碍。所以我觉得应该有个关键字,用于外部调用时绕过 private。
参考回答:
- https://www.zhihu.com/question/389546003/answer/1194780618
- https://www.zhihu.com/question/29251261
总而言之:虚表是编译器的实现,而非 C++ 的语言标准,就如同引用的实现一样(编译器可能将引用实现为
const T*,但 C++ 标准并未规定引用就是指针)。其它参考:
- https://shaharmike.com/cpp/vtable-part2/
- https://en.wikipedia.org/wiki/Thunk#Object-oriented_programming
200.1 回顾:从 C 的 POD 到 C++ 的类
回顾一下 C 语言纯 POD(Plain Old Data) 的结构体(struct)。
在 C 语言中,没有严格意义上的“POD”类型这个术语。POD (Plain Old Data,普通旧数据) 是一个主要用于 C++ 语言中的概念,用来描述一类与 C 语言中的数据类型兼容的类或结构体。
在 C++ 中,一个类型被称为 POD,通常意味着它满足以下几个条件:
- 兼容 C 语言的特性:它的内存布局是连续的,并且与 C 语言中对应的结构体或数据类型有相同的内存排布。
- 可以进行按字节复制:由于内存布局简单,可以直接通过
memcpy等函数进行内存块复制,而无需调用特殊的构造函数或复制赋值运算符。- 不包含复杂的特性:它通常不包含用户定义的构造函数、析构函数、虚函数、虚基类,以及引用类型的非静态成员等。
POD 概念之所以在 C++ 中被强调,主要是为了确保 C++ 代码能和 C 语言编写的库或系统进行互操作。
例如,当你需要将一个 C++ 结构体传递给一个 C 函数,或者将数据写入一个文件(例如用于二进制 I/O)时,你需要确保这个结构体的内存布局是简单且可预测的。一个 POD 类型就保证了这一点,因为它与 C 语言的结构体有相同的内存模型。
虽然 C 语言没有“POD”这个术语,但 C 语言中的
struct(结构体) 默认就具有类似 POD 的特性。C 语言的结构体本身就是由简单数据类型组成的集合,它们的内存是连续的,因此可以安全地进行按字节复制和读写。所以,如果你在 C 语言中看到类似 “POD” 的说法,它通常是指那些简单的、只包含基本数据类型(如
int,char,float,double)和数组的结构体,它们可以被看作是 C++ 中 POD 概念的“原型”。总之,POD 是 C++ 为了向后兼容 C 语言而引入的一个重要概念,它确保了数据类型在 C++ 和 C 之间可以无缝地进行交互和数据交换。
如果用 C 语言实现一个类似面向对象的类,应该怎么做呢?
// 写法一
typedef struct Actress {int height; // 身高int weight; // 体重int age; // 年龄(注意,这不是数据库,不必一定存储生日)void (*desc)(struct Actress*);
} Actress;// obj中各个字段的值不一定被初始化过,通常还会在类内定义一个类似构造函数的函数指针,这里简化
void profile(Actress* obj) {printf("height:%d weight:%d age:%d\n", obj->height, obj->weight, obj->age);
}int main() {Actress a;a.height = 168;a.weight = 50;a.age = 20;a.desc = profile;a.desc(&a);return 0;
}
想达到面向对象中数据和操作封装到一起的效果,只能给 struct 里面添加函数指针,然后给函数指针赋值。然而在 C 语言的项目中你很少会看到这种写法,主要原因就是函数指针是有空间成本的,这样写的话每个实例化的对象中都会有一个指针大小(比如 8 字节)的空间占用,如果实例化 N 个对象,每个对象有 M 个成员函数,那么就要占用 \(N*M*8\) 的内存。所以通常 C 语言不会用在 struct 内定义成员函数指针的方式,而是直接:
// 写法二
#include <stdio.h>
typedef struct Actress {int height; // 身高int weight; // 体重int age; // 年龄(注意,这不是数据库,不必一定存储生日)} Actress;void desc(Actress* obj) {printf("height:%d weight:%d age:%d\n", obj->height, obj->weight, obj->age);
}int main() {Actress a;a.height = 168;a.weight = 50;a.age = 20;desc(&a);return 0;
}
Redis 中 AE 相关的代码实现,便是如此。
再看一个 C++ 普通的类:
#include <stdio.h>
class Actress {
public:int height; // 身高int weight; // 体重int age; // 年龄(注意,这不是数据库,不必一定存储生日)void desc() {printf("height:%d weight:%d age:%d\n", height, weight, age);}
};int main() {Actress a;a.height = 168;a.weight = 50;a.age = 20;a.desc();return 0;
}
你觉得你这个 class 实际相当于 C 语言两种写法中的哪一个?
看着像写法一?其实相当于写法二。C++ 编译器实际会帮你生成一个类似上例中 C 语言写法二的形式(这也算是 C++ zero overhead 指导方针的一个体现,如果是写法一,就会产生 8 字节的指针空间开销)。当然实际并不完全一致,因为 C++ 支持重载的关系,会存在命名崩坏。但主要思想相同,虽不中,亦不远矣。
You shouldn't pay for what you don't use.
看到这,你会明白:C++ 中类和操作的封装只是对于程序员而言的。而编译器编译之后其实还是 面向过程 的代码。编译器帮你给成员函数增加一个额外的类指针参数,运行期间传入对象实际的指针。类的数据(成员变量)和操作(成员函数)其实还是分离的。
看到这里你或许会明白,为什么成员函数会有一个隐式的
this指针了吧?
每个函数都有地址(指针),不管是全局函数还是成员函数在编译之后几乎类似。
在类不含有虚函数的情况下,编译器在编译期间就会把函数的地址确定下来,运行期间直接去调用这个地址的函数即可。这种函数调用方式也就是所谓的『静态绑定』(static binding)。
200.2 虚函数
虚函数的出现其实就是为了实现面向对象三个特性之一的『多态』。何谓多态(polymorphism)?
#include <stdio.h>
#include <string>
using std::string;class Actress {
public:Actress(int h, int w, int a):height(h),weight(w),age(a){};virtual void desc() {printf("height:%d weight:%d age:%d\n", height, weight, age);}int height; // 身高int weight; // 体重int age; // 年龄(注意,这不是数据库,不必一定存储生日)
};class Sensei: public Actress {
public:Sensei(int h, int w, int a, string c):Actress(h, w, a),cup(c){};virtual void desc() {printf("height:%d weight:%d age:%d cup:%s\n", height, weight, age, cup.c_str());}string cup;};int main() {Sensei s(168, 50, 20, "36D");s.desc();return 0;
}
上例子,最终输出显而易见:
height:168 weight:50 age:20 cup:36D
再看:
Sensei s(168, 50, 20, "36D");Actress *a = &s;
a->desc();Actress &a2 = s;
a2.desc();
这种情况下,用父类指针指向子类的地址,最终调用 desc 函数还是调用子类的。
输出:
height:168 weight:50 age:20 cup:36D
height:168 weight:50 age:20 cup:36D
这个现象称之为『动态绑定』(dynamic binding)或者『延迟绑定』(lazy binding)。
但倘若你 把父类 Actress 中 desc 函数前面的 vitural 去掉,这个代码最终将调用父类的函数 desc,而非子类的 desc!输出:
height:168 weight:50 age:20
height:168 weight:50 age:20
这是为什么呢?指针实际指向的还是子类对象的内存空间,可是为什么不能调用到子类的 desc?这个就是我在第一部分说过的:类的数据(成员变量)和操作(成员函数)其实是分离的。仅从对象的内存布局来看,只能看到成员变量,看不到成员函数。因为调用哪个函数是编译期间就确定了的,编译期间只能识别父类的 desc。
好了,现在我们对于 C++ 如何应用多态有了一定的了解,那么多态又是如何实现的呢?
200.3 虚表
C++ 具体多态的实现一般是编译器厂商自由发挥的。但无独有偶,使用虚表指针来实现多态几乎是最常见做法(基本上已经是最好的多态实现方法)。废话不多说,继续看代码,有微调:
class Actress {
public:Actress(int h, int w, int a):height(h),weight(w),age(a){};virtual void desc() {printf("height:%d weight:%d age:%d\n", height, weight, age);}virtual void name() {printf("I'm a actress");}int height; // 身高int weight; // 体重int age; // 年龄(注意,这不是数据库,不必一定存储生日)
};class Sensei: public Actress {
public:Sensei(int h, int w, int a, const char* c):Actress(h, w, a){snprintf(cup, sizeof(cup), "%s", c);};virtual void desc() {printf("height:%d weight:%d age:%d cup:%s\n", height, weight, age, cup);}virtual void name() {printf("I'm a sensei");}char cup[4];};int main() {Sensei s(168, 50, 20, "36D");s.desc();Actress* a = &s;a->desc();Actress& a2 = s;a2.desc();return 0;
}
父类有两个虚函数,子类覆写了这两个虚函数。
clang 有个命令可以输出对象的内存布局(不同编译器内存布局未必相同,但基本类似):
clang -cc1 -fdump-record-layouts -stdlib=libc++ test.cpp
可以得到:
*** Dumping AST Record Layout0 | class Actress0 | (Actress vtable pointer)8 | int height12 | int weight16 | int age| [sizeof=24, dsize=20, align=8,| nvsize=20, nvalign=8]*** Dumping AST Record Layout0 | class Sensei0 | class Actress (primary base)0 | (Actress vtable pointer)8 | int height12 | int weight16 | int age20 | char[4] cup| [sizeof=24, dsize=24, align=8,| nvsize=24, nvalign=8]
1 error generated.
内存布局、大小、内存对齐都一目了然。
sizeof=24: 这是Actress类对象在内存中所占的总大小,单位是字节。编译器为了保证内存对齐和容纳虚函数表指针(vtable pointer),可能会在成员变量之后添加 填充(padding),因此这个值通常会大于所有成员变量的总和。dsize=20: 这是Actress类的数据成员所占的总大小,不包括虚函数表指针。在这个例子中,dsize等于(int height) 4+(int weight) 4+(int age) 4=12,再加上虚函数表指针的大小(通常为 8 字节)。但这里dsize=20实际上是 实例数据(包括 vtable pointer)的大小。- 更准确的理解:
dsize(data size) 是对象实际使用的内存大小,包括数据成员和虚函数表指针。在这里int类型为 4 字节,vtable pointer为 8 字节,所以4 + 4 + 4 + 8 = 20。
- 更准确的理解:
align=8: 这是Actress类对象在内存中的 对齐要求,单位是字节。这意味着Actress对象的起始地址必须是 8 的倍数。对齐要求通常由类中占用内存最大的成员决定,这里是虚函数表指针(8 字节),所以整个类的对齐要求也变成了 8 字节。nvsize=20: 这是Actress类的 非虚(non-virtual) 部分的大小。在这个例子中,它等于dsize,因为类中除了虚函数表指针和成员变量外,没有其他非虚基类的数据。nvalign=8: 这是Actress类的 非虚 部分的对齐要求。同样地,它由非虚成员中对齐要求最大的那个决定,在这里是虚函数表指针,所以对齐要求是 8 字节。
可以发现父类 Actress 的起始位置多了一个 Actress vtable pointer。子类Sensei 是在它的基础上多了自己的成员 cup。
也就是说在含有虚函数的类编译期间,编译器会自动给这种类在起始位置追加一个虚表指针(称之为:vptr)。vptr 指向一个虚表(称之为:vtable 或 vtbl),虚表中存储了实际的函数地址。
再看下虚表存储了什么东西。你在网上搜一下资料,肯定会说虚表里存储了虚函数的地址,但是其实不止这些!clang 同样有命令:
clang -Xclang -fdump-vtable-layouts -stdlib=libc++ -c test.cpp
g++也有打印虚表的操作(请在Linux上使用g++),会自动写到一个文件里:
g++ -fdump-lang-class test.cpp
看下 clang 的结果:
Vtable for 'Actress' (4 entries).0 | offset_to_top (0)1 | Actress RTTI-- (Actress, 0) vtable address --2 | void Actress::desc()3 | void Actress::name()VTable indices for 'Actress' (2 entries).0 | void Actress::desc()1 | void Actress::name()Vtable for 'Sensei' (4 entries).0 | offset_to_top (0)1 | Sensei RTTI-- (Actress, 0) vtable address ---- (Sensei, 0) vtable address --2 | void Sensei::desc()3 | void Sensei::name()VTable indices for 'Sensei' (2 entries).0 | void Sensei::desc()1 | void Sensei::name()
Vtable for 'Actress' (4 entries)
这部分展示了
Actress类虚函数表的具体内容,它是一个包含 4 个条目的数组:
0 | offset_to_top (0): 第一个条目是offset_to_top,即“到顶部的偏移量”。当一个类有虚基类(virtual base class)时,这个偏移量用于帮助编译器找到派生类对象的顶部。在这种情况下,Actress类没有虚基类,所以偏移量为0。1 | Actress RTTI: 第二个条目是指向 运行时类型信息(RTTI) 对象的指针。RTTI 包含了类的元数据,比如类名,可以用于动态类型转换(如dynamic_cast)和typeid操作符。-- (Actress, 0) vtable address --: 这是一个标记,表示从这里开始是Actress类的虚函数指针部分。0表示这是主虚函数表,而不是某个虚基类的。2 | void Actress::desc(): 第三个条目是desc()虚函数的地址。3 | void Actress::name(): 第四个条目是name()虚函数的地址。VTable indices for 'Actress' (2 entries)
这部分展示了编译器在生成代码时,如何通过索引来调用
Actress类的虚函数。索引从0开始计数,直接对应虚函数在虚函数表中的位置。
0 | void Actress::desc(): 表明desc()虚函数位于虚函数表中的索引0处(也就是第三个条目,因为前两个条目是元数据)。1 | void Actress::name(): 表明name()虚函数位于虚函数表中的索引1处(也就是第四个条目)。
g++ 的结果(其实也比较清晰,甚至更清晰):
Vtable for Actress
Actress::_ZTV7Actress: 4 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Actress)
16 (int (*)(...))Actress::desc
24 (int (*)(...))Actress::nameClass Actresssize=24 align=8base size=20 base align=8
Actress (0x0x7fe5e4279a80) 0vptr=((& Actress::_ZTV7Actress) + 16)Vtable for Sensei
Sensei::_ZTV6Sensei: 4 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI6Sensei)
16 (int (*)(...))Sensei::desc
24 (int (*)(...))Sensei::nameClass Senseisize=24 align=8base size=24 base align=8
Sensei (0x0x7fe5e42390d0) 0vptr=((& Sensei::_ZTV6Sensei) + 16)
Actress (0x0x7fe5e4279d20) 0primary-for Sensei (0x0x7fe5e42390d0)
可以看出二者其实基本一致,只是个别名称叫法不同。

所有虚函数的的调用取的是哪个函数(地址)是在运行期间通过查虚表确定的。
更新:vptr 指向的并不是虚表的表头,而是直接指向的虚函数的位置。使用 gdb 或其他工具可以发现:
(gdb) p s
$3 = {<Actress> = {_vptr.Actress = 0x555555557d10 <vtable for Sensei+16>, height = 168, weight = 50, age = 20},cup = "36D"}
vptr 指向的是 Sensei 的 vtable + 16 个字节的位置,也就是虚函数的地址。
虚表本身是连续的内存。动态绑定的实现也就相当于(假设 p 为含有虚函数的对象指针):
(*(p->vptr)[n])(p)
但其实上面的图片也只是简化版,不是完整的的虚表。通过 gdb 查看,你其实可以发现子类和父类的虚表是连在一起的。上面 gdb 打印出了虚表指针指向:0x555555557d10。我们倒退16个字节(0x555555557d00)输出一下:
(gdb) set print asm-demangle on
(gdb) x/200xb 0x555555557d00
0x555555557d00 <vtable for Sensei>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x555555557d08 <vtable for Sensei+8>: 0x40 0x7d 0x55 0x55 0x55 0x55 0x00 0x00
0x555555557d10 <vtable for Sensei+16>: 0x22 0x55 0x55 0x55 0x55 0x55 0x00 0x00
0x555555557d18 <vtable for Sensei+24>: 0x72 0x55 0x55 0x55 0x55 0x55 0x00 0x00
0x555555557d20 <vtable for Actress>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x555555557d28 <vtable for Actress+8>: 0x58 0x7d 0x55 0x55 0x55 0x55 0x00 0x00
0x555555557d30 <vtable for Actress+16>: 0x3e 0x54 0x55 0x55 0x55 0x55 0x00 0x00
0x555555557d38 <vtable for Actress+24>: 0x82 0x54 0x55 0x55 0x55 0x55 0x00 0x00
0x555555557d40 <typeinfo for Sensei>: 0x30 0x4c 0xfa 0xf7 0xff 0x7f 0x00 0x00
0x555555557d48 <typeinfo for Sensei+8>: 0x78 0x60 0x55 0x55 0x55 0x55 0x00 0x00
0x555555557d50 <typeinfo for Sensei+16>: 0x58 0x7d 0x55 0x55 0x55 0x55 0x00 0x00
0x555555557d58 <typeinfo for Actress>: 0xa0 0x3f 0xfa 0xf7 0xff 0x7f 0x00 0x00
0x555555557d60 <typeinfo for Actress+8>: 0x80 0x60 0x55 0x55 0x55 0x55 0x00 0x00
可以发现子类和父类的虚表其实是连续的。并且下面是它们的 typeinfo 信息也是连续的。
虚表的第一个槽位(vtable for Sensei)值为 0。
虚表的第二个槽位(vtable for Sensei+8)指向的其实是 0x555555557d40,也就是下面的 typeinfo for Sensei。
再改一下代码。我们让子类 Sensei 只重载一个父类函数 desc。
class Sensei: public Actress {
public:Sensei(int h, int w, int a, const char* c):Actress(h, w, a){snprintf(cup, sizeof(cup), "%s", c);};virtual void desc() {printf("height:%d weight:%d age:%d cup:%s\n", height, weight, age, cup);}char cup[4];};
其他地方不变,重新用 clang 或 g++ 刚才的命令执行一遍。clang 的输出:
Vtable for 'Actress' (4 entries).0 | offset_to_top (0)1 | Actress RTTI-- (Actress, 0) vtable address --2 | void Actress::desc()3 | void Actress::name()VTable indices for 'Actress' (2 entries).0 | void Actress::desc()1 | void Actress::name()Vtable for 'Sensei' (4 entries).0 | offset_to_top (0)1 | Sensei RTTI-- (Actress, 0) vtable address ---- (Sensei, 0) vtable address --2 | void Sensei::desc()3 | void Actress::name()VTable indices for 'Sensei' (1 entries).0 | void Sensei::desc()
可以看到子类的 name 由于没有重载,所以使用的还是父类的。一图胜千言:

在前面分析虚表时,我们发现,虚表的前两个条目不是虚函数。简而言之,第一个槽位存储的 offset,是一种被称为 thunk 的技术(或者说技巧)。第二个槽位存储着为 RTTI 服务的 type_info 信息。
Vtable for 'Actress' (4 entries).0 | offset_to_top (0)1 | Actress RTTI-- (Actress, 0) vtable address --2 | void Actress::desc()3 | void Actress::name()VTable indices for 'Actress' (2 entries).0 | void Actress::desc()1 | void Actress::name()Vtable for 'Sensei' (4 entries).0 | offset_to_top (0)1 | Sensei RTTI-- (Actress, 0) vtable address ---- (Sensei, 0) vtable address --2 | void Sensei::desc()3 | void Sensei::name()VTable indices for 'Sensei' (2 entries).0 | void Sensei::desc()1 | void Sensei::name()
200.4 top_offset
top_offset 是一个整数值,它表示从当前虚函数表(vtable)所关联的子对象(sub-object)的地址到 完整对象 的 顶端地址 的偏移量(通常是 0 或一个负值)。
top_offset 主要用于多重继承和虚继承的场景。当通过一个基类指针调用虚函数时,如果该虚函数是子类实现的,且子类对象在内存中的布局与基类子对象的布局不重合,就需要调整this指针。
- 例如,在一个多重继承的子类对象中,第一个基类子对象位于对象地址的开头,但第二个基类子对象则有一定偏移。当你通过第二个基类指针调用虚函数时,程序需要知道如何把
this指针调整回完整对象的起始地址,以便访问完整的对象数据。 top_offset就提供了这个调整值。在调用虚函数时,程序会使用top_offset来计算完整对象的地址,然后再从这个地址计算出虚函数需要操作的正确地址。
在 GDB 输出中,top_offset 通常以一个负数的十六进制形式出现,例如 0xffffffffffffffff (-1) 或者一个小的负数。
之所以要调整 this 指针,是因为在调用虚函数时是使用子类数据。由于此时 this 指针指向的可能是父类数据,因此需要进行调整。
例如:
class Fish {
public:virtual ~Fish(){};virtual void echo() {cout<<"I'm a fish"<<endl;};
};class Beauty {
public:virtual ~Beauty(){};virtual void echo() {cout<<"I'm a beauty"<<endl;};
};class FishBeauty: public Beauty, public Fish {
public:virtual ~FishBeauty(){};virtual void echo() {cout<<"I'm a fish-beauty"<<endl;};
};
虚表结构如下:

可以发现,beauty 的 top_offset 是 0,而 fish 的 offset 是 0xfffffffffffffff8 也即 -8。
200.5 type_info
typeinfo 是一个指向 std::type_info 对象的指针。这个对象包含了类的运行时类型信息(RTTI)。
它有什么用?
- 运行时类型识别:它提供了在运行时查询对象类型的能力,例如通过
typeid操作符。当你使用typeid(myObject)时,程序就是通过 vtable 中的typeinfo指针来获取对象的类型信息。 - 多重继承关系:在多重继承的情况下,
typeinfo对象中还包含了基类的数量以及指向每个基类的typeinfo对象的指针,从而构建出一个完整的继承链条。
