鸡汤
一份写给真正想把 C++ 用起来的开发者的长期笔记。覆盖语言基础、面向对象、模板与泛型、STL、内存模型、现代 C++、并发、性能优化、工程化与实战项目,共十章。
目录
- 第一章 语言基础:从编译到语法
- 第二章 面向对象:类、生命周期与多态
- 第三章 模板与泛型编程
- 第四章 STL:容器、迭代器与算法
- 第五章 内存管理与对象生命周期
- 第六章 现代 C++:C++11 到 C++23
- 第七章 并发与多线程
- 第八章 性能优化:从缓存到编译器
- 第九章 工程化实践
- 第十章 实战项目
写在前面
C++ 是一门很"诚实"的语言。它不会替你隐藏代价:一次拷贝、一次虚函数调用、一次堆分配、一次缓存未命中,几乎都能在代码里找到出处。也正因为如此,它既容易写出极快的程序,也容易写出极脆弱的程序。
这份笔记不追求"覆盖所有语法点",而是按一条真实的学习路径来组织:先搞清楚一行代码在编译器和机器上究竟变成了什么,再谈抽象、谈泛型、谈并发、谈优化。每一章都尽量给出可以直接编译运行的例子,并且在讲"怎么做"的同时讲"为什么这么做、代价是什么"。
阅读建议:
- 不要跳过第一章。很多"玄学问题"(未定义行为、对象切片、悬垂引用)根子都在最基础的语义上。
- 每段代码都亲手敲一遍。C++ 的坑绝大多数不是"看不懂",而是"想不到"。
- 遇到不确定的行为,去查 cppreference,而不是靠记忆。语言标准每三年一变,记忆会过期。
关于版本:文中示例默认以 C++17 为基线,涉及 C++20/23 的内容会显式标注。编译器以 GCC 13 / Clang 17 为准,MSVC 的差异会在必要处说明。
第一章 语言基础:从编译到语法
1.1 一行 C++ 代码经历了什么
初学者最容易把 C++ 当成"会写 for 循环就能写"的语言,但真正理解 C++ 的起点,是理解从 .cpp 到可执行文件的四个阶段。
阶段一:预处理(preprocessing)
预处理器不是编译器,它做的是纯粹的文本替换:展开 #include、展开宏、处理条件编译 #ifdef、删除注释。所以下面这段代码在编译阶段看到的其实是一坨几万行的文本:
#include <iostream>
#define SQUARE(x) ((x) * (x))
int main() {
std::cout << SQUARE(3 + 1) << '\n';
}
SQUARE(3 + 1) 会被替换成 ((3 + 1) * (3 + 1)),结果是 16。如果宏写成 #define SQUARE(x) x * x,那就变成 3 + 1 * 3 + 1 = 7。宏没有类型、没有作用域、不做参数求值一次的保护,这是它的原罪。现代 C++ 里,能用 constexpr 函数、inline 函数、模板替代的场合,就不要用宏。
想看预处理结果,可以这样做:
g++ -E main.cpp -o main.ii # 只做预处理
g++ -E -P main.cpp # 去掉行号标记,输出更干净
阶段二:编译(compilation)
编译器把预处理后的文本解析成抽象语法树(AST),做语义检查(类型检查、重载解析、模板实例化),然后生成中间表示(GCC 是 GIMPLE,Clang 是 LLVM IR),再优化,最后生成汇编。
g++ -S main.cpp -o main.s # 生成汇编
g++ -O2 -S main.cpp -o main.s # 带优化的汇编
看汇编是理解"抽象有没有代价"的最直接办法。比如 i++ 和 ++i 在 int 上生成的机器码通常完全一样,但在自定义类型上就天差地别------因为前者要多构造一个临时对象。
阶段三:汇编(assembly)
汇编器把 .s 翻译成目标文件 .o,里面是机器码加上符号表、重定位信息。此时地址还没确定,函数调用只是占位。
g++ -c main.cpp -o main.o
nm -C main.o # 查看符号,-C 用于还原 C++ 名字
objdump -d main.o # 反汇编
nm 会暴露一个 C++ 特有的现象:名字修饰(name mangling) 。void foo(int) 在目标文件里可能叫 _Z3fooi。这就是为什么 C++ 函数不能直接和 C 互相调用------需要用 extern "C" 关掉修饰。
extern "C" {
int c_function(int); // 按 C 的规则找符号,不做修饰
}
阶段四:链接(linking)
链接器把多个 .o 和库拼起来,解析符号引用,分配最终地址,生成可执行文件或共享库。常见的链接错误有两类:
undefined reference to ...:声明了但没定义,或者定义在没参与链接的库里。multiple definition of ...:同一个符号被定义了多次,通常是把函数定义写进了头文件而没加inline。
链接顺序在 Linux 上是有讲究的。g++ main.o -lfoo 中,-lfoo 必须放在引用它的目标文件之后,因为传统 ld 是单遍扫描静态库的。循环依赖的静态库需要用 --start-group / --end-group 包起来。
一个关键认知 :C++ 的编译模型是"分离编译"。每个翻译单元(translation unit,即一个 .cpp 加上它包含的所有头文件)独立编译,编译器在编译一个 .cpp 时看不到另一个 .cpp 的内容。这带来了两个后果:
- 头文件里放的必须是"能被重复定义而不出错"的东西(声明、
inline函数、模板、类定义),否则就会重复定义。 - 模板必须在使用者可见的地方定义,所以模板代码通常全写在头文件里。
理解这一点,后面所有的头文件设计、inline、ODR(单一定义规则)、模板的链接问题都会变得理所当然。
1.2 工具链与编译选项
一个最小可用的编译命令:
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 -g main.cpp -o main
逐个说明:
| 选项 | 含义 | 建议 |
|---|---|---|
-std=c++17 |
语言标准 | 新项目直接 c++20 |
-Wall -Wextra |
常用警告 | 必开,且要清零 |
-Wpedantic |
严格标准符合性 | 库代码建议开 |
-Werror |
警告当错误 | CI 里必开 |
-O2 / -O3 |
优化等级 | 发布用 -O2,慎用 -O3 |
-g |
调试信息 | 发布也保留,配合 -g1 减小体积 |
-fsanitize=address,undefined |
运行时检查 | 调试/CI 必开 |
-fno-omit-frame-pointer |
保留帧指针 | 便于 profile |
-march=native |
针对本机 CPU 优化 | 只在明确目标机器时使用 |
关于 -O3:它更激进地做向量化和循环展开,可能让代码体积暴涨、指令缓存命中率下降,实际不一定更快。工程上的做法是先用 -O2 建立基线,再针对热点函数单独用 __attribute__((optimize("O3"))) 或 #pragma GCC optimize 局部提升。
链接时优化(LTO)能跨翻译单元内联,代价是编译时间与内存:
g++ -flto -O2 a.cpp b.cpp -o app
1.3 基本类型:先搞清楚它们到底多大
C++ 标准只规定了最小保证,具体宽度依赖平台。常见平台(x86-64 Linux,LP64 模型):
| 类型 | 典型大小 | 备注 |
|---|---|---|
bool |
1 | sizeof(bool) 标准未强制为 1,但实践都是 1 |
char |
1 | 是否带符号由实现决定,务必用 signed char/unsigned char 表示数值 |
short |
2 | 至少 16 位 |
int |
4 | 至少 16 位,实践中都是 32 位 |
long |
8 | Windows LLP64 下是 4,这是跨平台坑 |
long long |
8 | 至少 64 位 |
float |
4 | IEEE 754 单精度 |
double |
8 | IEEE 754 双精度 |
long double |
16(x86) | 80 位扩展精度填充到 16 字节 |
| 指针 | 8 | 64 位平台 |
跨平台代码里不要假设 long 是 8 字节。需要固定宽度时用 <cstdint>:
#include <cstdint>
int8_t a = -1;
uint8_t b = 255;
int32_t c = 0;
uint64_t d = 0;
intptr_t e = 0; // 能装下指针的整数
int8_t 不保证存在(如果平台没有 8 位类型),但 int_least8_t、int_fast8_t 一定存在。int_fast8_t 在 x86 上通常是 int,因为寄存器操作更快------这类类型适合"循环计数器"而不是"存储格式"。
整数溢出是最常见的未定义行为之一。有符号整数溢出是 UB,编译器可以假设它不发生,从而做出让你意外的优化:
bool check(int x) {
return x + 1 > x; // 有符号情况下,编译器可以优化成 return true
}
这在开启 -O2 后确实是 return true。而无符号整数是模运算,回绕是定义好的:
unsigned u = 0;
u - 1; // 4294967295,合法
无符号回绕带来的经典 bug:
for (unsigned i = n; i >= 0; --i) { ... } // 死循环
因为 i >= 0 永远为真。解决办法是用 int,或者写成 for (unsigned i = n; i-- > 0; )。
浮点数不要用 == 比较:
#include <cmath>
bool nearly_equal(double a, double b) {
return std::fabs(a - b) <= 1e-9 * std::max({1.0, std::fabs(a), std::fabs(b)});
}
更好的做法是引入绝对容差 + 相对容差,参考 Go 语言 math.Nextafter 的思路,或者直接用 ULP 比较。另外注意 -0.0 == 0.0 为真但 1.0/-0.0 是 -inf,NaN != NaN 恒为真------写比较函数时这些边角要考虑进去。
字符类型:
char c = 'A'; // 1 字节
wchar_t w = L'中'; // 宽度平台相关
char16_t u16 = u'中'; // UTF-16 码元
char32_t u32 = U'中'; // UTF-32
处理 UTF-8 时,std::string 里存的只是字节序列,s.size() 是字节数而不是字符数。想正确遍历 UTF-8 字符需要自己解码,或者用 std::wstring_convert(C++17 已废弃)之外的第三方库。
1.4 变量、作用域与初始化
C++ 的变量有三个正交的属性:作用域 (名字在哪可见)、存储期 (对象活多久)、链接性(符号对其他翻译单元是否可见)。
int g; // 全局,静态存储期,外部链接
static int s; // 全局,静态存储期,内部链接
extern int e; // 声明而非定义,定义在别处
const int c = 10; // 命名空间作用域的 const 默认内部链接
void f() {
int a; // 自动存储期,块作用域
static int b = 0; // 静态存储期,首次进入初始化一次
}
关于初始化,C++ 的规则一向混乱,这里只记实用结论:
int a; // 默认初始化:局部变量是未定义值!全局是 0
int b = 0; // 拷贝初始化
int c(0); // 直接初始化
int d{0}; // 列表初始化(推荐)
int e{}; // 值初始化为 0
统一初始化(花括号初始化)是最推荐的写法,它有一个巨大优势:禁止窄化转换。
int x = 3.14; // 编译通过,x = 3(可能警告)
int y{3.14}; // 编译错误:窄化转换
它也有个陷阱:当类同时有 initializer_list 构造函数时,{} 会优先匹配它。
std::vector<int> v1(10, 20); // 10 个元素,每个是 20
std::vector<int> v2{10, 20}; // 2 个元素:10 和 20
记住这个差异,它是无数 bug 的来源。
auto 的适用边界:能用就用,但有三处要谨慎。
auto x = 1; // int
auto y = 1u; // unsigned
auto z = v.size(); // size_t,和 int 比较会有符号问题
auto p = get_ptr(); // 若返回的是代理对象,auto 可能退化(如 vector<bool>::reference)
auto s = std::string("x") + "y"; // std::string,没问题
对于 vector<bool> 的 auto b = v[0];,b 的 type 是 std::vector<bool>::reference 而不是 bool,如果 v 之后被销毁,b 就悬垂了。这种"代理对象"的坑,显式写类型更安全。
作用域与名字查找:C++ 的名字查找是"由内向外",找到即停止,不继续向外层找。
int x = 1;
void f() {
int x = 2;
{
std::cout << x; // 2,不是 1
}
}
这也是为什么成员函数里局部变量会"遮蔽"成员变量,必须用 this->x 才能访问成员。
1.5 运算符与表达式:求值顺序的陷阱
很多运算符的求值顺序是未指定的,不是从左到右:
int i = 0;
i = i++ + 1; // C++17 之前是 UB;C++17 起有明确规则但仍应避免
f() + g(); // 谁先调用?未指定
C++17 引入了更明确的规则:对于 a = b,b 先于 a 求值;对于 <<,左操作数先于右操作数。但 +、* 这类运算符的操作数顺序仍然未指定。
不要依赖这种顺序,尤其在操作数里有副作用时:
// 危险
std::cout << f() << g();
// 安全
auto a = f();
auto b = g();
std::cout << a << b;
短路求值 是有定义的:&& 和 || 从左到右,且短路。
if (p != nullptr && p->value > 0) { ... } // 安全
这是 C++ 里少数"可以依赖"的顺序保证,应该用好它。
逗号运算符 :a, b 先算 a 再算 b,结果取 b。它最容易被 operator[] 重载时误用,现代代码里基本只剩 for 循环里用一用。
位运算的优先级陷阱:
if (x & 0x0F == 0) { ... } // 实际是 x & (0x0F == 0) => x & 0
if ((x & 0x0F) == 0) { ... } // 正确
& 的优先级低于 ==。这是 C 语言遗留的历史问题,现在编译器会给出 -Wparentheses 警告,务必清零。
移位运算要注意:对有符号负数右移是实现定义行为(算术右移 vs 逻辑右移),移位位数大于等于类型宽度是 UB。
1 << 31; // int 溢出,UB
1u << 31; // 正确,unsigned
uint64_t v = 1ull << 63; // 正确
1.6 控制流:写得少,错得也少
C++ 的控制流语句本身很简单,但有几个工程上值得强调的点。
if 与初始化:优先用"初始化 + 早返回",而不是"声明 + 分支赋值"。
// 不推荐
int result;
if (cond) {
result = heavy_compute();
} else {
result = 0;
}
return result;
// 推荐
if (!cond) return 0;
return heavy_compute();
后者避免了"变量可能未初始化"的风险,也让代码的直线路径更清晰。
switch 的坑:
switch (x) {
case 1:
do_a();
// 忘了 break,贯通到 case 2
case 2:
do_b();
break;
default:
break;
}
C++17 引入了 [[fallthrough]] 属性来显式声明"我就是想贯通",编译器就不再警告:
case 1:
do_a();
[[fallthrough]];
case 2:
更好的做法是尽量用 enum class + switch,并让编译器在漏掉枚举值时报警(GCC 的 -Wswitch-enum)。
范围 for 循环:
for (const auto& item : container) { ... } // 只读,不加拷贝
for (auto& item : container) { ... } // 需要修改
for (auto item : container) { ... } // 需要副本
注意:范围 for 在循环体内修改容器(增删元素)会导致迭代器失效,行为未定义。
for (auto& x : vec) {
if (x < 0) vec.push_back(0); // 灾难:push_back 可能重新分配
}
需要边遍历边修改时,用索引循环并小心处理,或者用"标记 + 批量删除"的两阶段法。
goto 的合理用途 :跳出多层嵌套循环时,goto 是唯一干净的办法(不使用标志位或函数拆分)。
for (...) {
for (...) {
if (found) goto done;
}
}
done:
C++ 核心指南(C++ Core Guidelines)也认可这一用法,但要求 goto 只向前跳、不跨过变量初始化。
1.7 函数、重载与名字查找
函数声明与定义:
// 头文件
int add(int a, int b); // 声明,参数名可省略
// 源文件
int add(int a, int b) { return a + b; }
默认参数只能写在声明里,且必须从右往左连续:
void log(const std::string& msg, int level = 0, bool flush = false);
// 调用
log("hi"); // ok
log("hi", 1); // ok
log("hi", 1, true); // ok
默认参数是在调用点展开的,所以它是静态绑定的。如果通过基类指针调用虚函数且基类声明了默认参数,实际用的是基类的默认值,而不是派生类重写后的值------这是个隐蔽的坑。
函数重载与重载解析:编译器按以下顺序挑选:
- 精确匹配(不做任何转换)
- 提升转换(
char→int,float→double) - 标准转换(
int→double,派生类指针 → 基类指针) - 用户定义转换(构造函数、转换运算符)
- 可变参数
...
只要某个阶段找到唯一最佳匹配就停止。如果同一阶段有多个同等好的候选,就是二义性错误。
void f(int);
void f(double);
f(1); // 精确匹配 int
f(1.0f); // float 提升为 double,选 f(double)
f('a'); // char 提升为 int,选 f(int)
f(1LL); // long long:int 和 double 都不是精确匹配,二义性错误
重载与指针 :nullptr 是 std::nullptr_t,传给 f(int*) 和 f(char*) 都能匹配(需要指针转换),因此二义。用 NULL(即 0)更糟,会匹配 int 版本。
inline 的真实含义 :现代 C++ 里,inline 的主要作用是允许在多个翻译单元中重复定义 (放宽 ODR),而不是"建议内联"。编译器是否真的内联,取决于函数大小、调用频率、优化等级,与 inline 关键字无关。
// 头文件中
inline int square(int x) { return x * x; }
类内定义的成员函数天然是 inline 的。
constexpr 函数 可以在编译期求值,比 inline 更强:
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
static_assert(factorial(5) == 120);
C++14 之后 constexpr 函数可以有循环、局部变量、多个返回语句。C++20 的 consteval 则要求必须在编译期求值。
noexcept:声明函数不会抛异常。它不只是文档,而是影响代码生成:编译器可以省掉异常展开的清理代码,标准容器在移动元素时也依赖它来选择"移动还是拷贝"。
class Widget {
public:
Widget(Widget&&) noexcept; // 移动构造必须 noexcept,否则 vector 扩容时会退化成拷贝
};
这条规则极其重要:如果你自定义了移动构造函数却没有标 noexcept,std::vector 在扩容时会认为移动可能抛异常,从而改用拷贝构造,性能直接掉一个量级。
1.8 指针与引用:C++ 的分水岭
指针是一个存地址的对象,可以为空、可以改指向、可以做算术。
引用是别名,必须初始化、不能改绑、不存在"空引用"(但可以通过解引用空指针造出来,那是 UB)。
int a = 1, b = 2;
int* p = &a;
p = &b; // 指针可以改指向
int& r = a;
// r = b; // 这是赋值,不是改绑;r 仍是 a 的别名
什么时候用哪个:
- 参数需要"可修改的原对象"且"必须存在" → 引用
- 参数可以是"没有" → 指针 或
std::optional<std::reference_wrapper<T>> - 需要做指针算术、管理动态内存 → 指针
- 需要表示所有权 → 智能指针(见第五章)
const 与指针的组合(从右往左读):
const int* p1; // 指向 const int 的指针:*p1 不可改,p1 可改
int const* p2; // 同上
int* const p3 = &a; // const 指针:p3 不可改,*p3 可改
const int* const p4; // 都不可改
函数参数的最佳实践:
void f(const std::string& s); // 只读大对象:const 引用
void f(std::string_view s); // C++17,只读且不要求特定容器
void f(int x); // 小类型(<= 16 字节)按值传
void f(std::unique_ptr<T> p); // 转移所有权
void f(T* p); // 可选、可能为空的输出参数
悬垂指针/引用的三种典型来源:
// 1. 返回局部变量的引用
int& bad() { int x = 1; return x; }
// 2. 容器扩容后旧指针失效
std::vector<int> v;
int* p = &v[0];
v.push_back(1); // p 可能悬垂
// 3. 临时对象绑定到 const 引用并延长生命周期,但依赖它的东西没延长
const std::string& s = std::string("a") + "b"; // 合法,生命周期延长到 s 的作用域
第三种虽然合法,但非常容易误用,工程上建议直接用值。
1.9 数组、C 字符串与 std::string
原生数组:
int a[5] = {1, 2, 3}; // 后两个是 0
int b[] = {1, 2, 3}; // 长度推导为 3
int c[5]{}; // 全 0
数组名在表达式中会退化为指向首元素的指针,但在 sizeof 和 & 下不会:
int a[10];
sizeof(a); // 40
void f(int arr[10]); // 等价于 int* arr,长度信息丢失!
这就是为什么函数参数传数组一定要带长度,或者用 std::array / std::span。
C 字符串:
char s[] = "hello"; // 6 字节,含 '\0'
const char* p = "hello"; // 字面量,通常放在只读段
strlen(p); // O(n)
永远不要写 char buf[10]; strcpy(buf, input);,用 snprintf 或 std::string。缓冲区溢出是最古老也最危险的漏洞类别之一。
std::string 是 C++ 里最常用的类,值得单独说几点。
小字符串优化(SSO):libstdc++ 的 std::string 内部是一个指针 + 长度 + 容量,短字符串(<= 15 字符)直接存在对象内部,不发生堆分配。所以"短字符串拷贝很便宜"这个直觉在多数实现上成立。
std::string a = "hi"; // 无堆分配(libstdc++)
std::string b = a + " there"; // 可能分配
常用操作与性能要点:
std::string s;
s.reserve(1000); // 预分配,避免多次扩容
s += "abc"; // 追加,均摊 O(1)
s.append(3, 'x');
s.substr(2, 5); // 返回新 string,O(n) 拷贝!高频调用时是性能杀手
s.find("bc"); // O(n*m)
s.compare(...);
std::stoi(s); // 字符串转整数
substr 每次都拷贝,循环里调用会非常慢。C++17 的 std::string_view 解决了"只读切片"的问题:
std::string_view sv = s;
auto part = sv.substr(2, 5); // 只是指针 + 长度的调整,O(1)
但 string_view 不拥有数据,必须保证底层 string 活得更久:
std::string_view bad() {
std::string tmp = "hello";
return tmp; // 返回后 tmp 销毁,悬垂
}
这是 string_view 最大的风险。工程约定:string_view 只用于函数参数,不用作返回值或成员变量,除非有明确的生命周期管理。
1.10 结构体、枚举与联合
结构体 默认成员是 public 的,class 默认是 private 的,这是二者唯一的语法差异(语义上的差异是约定俗成的)。
struct Point {
double x = 0;
double y = 0;
};
C++11 起支持类内成员初始化(NSDMI),这让"聚合初始化"和"默认值"可以共存:
Point p1{1.0, 2.0};
Point p2{}; // x=0, y=0
内存布局与填充:
struct A { char c; int i; }; // sizeof = 8(c 后有 3 字节填充)
struct B { int i; char c; }; // sizeof = 8(c 后填充到 4 的倍数)
struct C { char a; char b; int i; }; // sizeof = 8
调整成员顺序可以减小 sizeof。对海量小对象(如上亿个节点),这一优化能省下可观内存并提升缓存命中率。可以用 offsetof 和 static_assert 校验布局:
static_assert(sizeof(A) == 8);
#pragma pack(1) 可以取消填充,但会导致非对齐访问,在部分架构上性能下降甚至崩溃(ARM 上某些指令要求对齐)。用于网络协议/文件格式时,通常要求"显式填充 + 字节序转换",而不是 pack。
枚举:
enum Color { Red, Green, Blue }; // 传统枚举,隐式转 int
enum class Color { Red, Green, Blue }; // 强类型枚举,必须加作用域
enum class 不会隐式转换成整数,必须 static_cast,这避免了很多误用。它还可以指定底层类型:
enum class Flags : uint8_t { A = 1, B = 2, C = 4 };
需要位运算时,为它重载运算符,或者用 std::bitset / 专门的 flags 库。
联合(union):
union Value {
int i;
float f;
char bytes[4];
};
union 的大小是最大成员的大小,但同时只有一个成员是"活的"。读取非活跃成员是 UB(尽管在 C 里常被用作类型双关)。C++ 的安全替代是 std::variant。
C++11 允许 union 成员有构造函数,但要自己管理构造/析构:
union U {
std::string s; // 有非平凡构造函数
int i;
U() : i(0) {}
~U() {}
};
实践中基本不需要手写 union,用 std::variant 更安全。
1.11 预处理与头文件工程
头文件保护:
#pragma once // 现代编译器都支持,简单可靠
或传统的 include guard:
#ifndef MY_HEADER_H
#define MY_HEADER_H
// ...
#endif
两者选其一即可,不要同时用。#pragma once 在极少数场景(同一文件通过不同路径/网络挂载被包含)会失效,容错性上 include guard 更稳。
头文件里应该放什么:
- 类型声明、函数声明、类定义
inline函数、模板定义constexpr变量- 枚举、
using别名
不应该放:
- 非 inline 的函数定义(会重复定义)
- 全局变量定义(用
extern声明 + 一处定义) using namespace std;(污染所有包含者的命名空间)
前置声明 vs 包含头文件:前置声明能减少编译依赖,加速编译,但有局限(不能用于需要完整类型的场景,如按值成员、继承、模板实例化)。
// 前置声明
class Widget;
void use(Widget* w); // 指针/引用可以只用前置声明
现代替代方案是PImpl(见第九章)和 C++20 的模块(modules)。
条件编译:
#if defined(_WIN32)
#define PLATFORM_WINDOWS 1
#elif defined(__linux__)
#define PLATFORM_LINUX 1
#endif
注意 #ifdef 里的宏判断不要写 #if PLATFORM_WINDOWS(当宏未定义时会被当成 0,看似可用,但 -Wundef 会警告)。要么统一用 #if defined(...),要么保证宏总被定义为 0 或 1。
1.12 本章练习
- 用
g++ -E观察一个包含<iostream>的文件预处理后有多少行,体会头文件的开销。 - 写一个
sizeof实验,输出各基本类型、指针、空类、带虚函数的类、带成员函数的类的大小,解释差异。 - 构造一个
i = i++ + 1的例子,用 UBSan 观察它是否被检出。 - 实现一个接受
const char*的my_strlen,再实现一个"带长度参数"的版本,比较两者在编译期的优化结果。 - 用
std::array替代原生数组写一个固定长度的环形缓冲区,体会"长度信息不丢失"的价值。
第二章 面向对象:类、生命周期与多态
2.1 类的基本构件
一个完整的类通常包含五类成员:数据成员、成员函数、构造函数/析构函数、类型成员、静态成员。
class BankAccount {
public:
BankAccount(std::string owner, double initial)
: owner_(std::move(owner)), balance_(initial) {}
void deposit(double amount);
bool withdraw(double amount);
double balance() const noexcept { return balance_; }
private:
std::string owner_;
double balance_{0.0};
};
几个约定性但重要的细节:
- 成员初始化列表 里的顺序必须与成员声明顺序一致,否则编译器会警告(
-Wreorder)。实际初始化顺序只由声明顺序决定,与列表写法无关。 - 冒号后面的
std::move(owner)对std::string形参是必要的:形参本身是副本,再拷贝一次给成员就是浪费。 - 只读访问器标
const和noexcept。const让对象可以被const&调用,noexcept让它在容器和std::function场景下更安全。
const 成员函数 的本质是:this 的类型变成 const T*。它不能修改非 mutable 成员,也不能调用非 const 成员函数。
class Cache {
mutable std::mutex mtx_; // 逻辑上不改变对象可见状态,但仍需修改
mutable int hit_count_ = 0;
public:
int get(int key) const {
std::lock_guard<std::mutex> lk(mtx_); // 需要 mutable
...
}
};
mutable 是"逻辑 const 但物理可变"的逃生口,用多了说明类的职责划分有问题。
static 成员 属于类而非对象,需要在类外定义(C++17 起可用 inline 变量在类内定义):
class Config {
public:
static int instance_count;
static constexpr int MAX_RETRY = 3; // C++17 隐式 inline
inline static std::string version = "1.0"; // C++17
};
int Config::instance_count = 0; // 类外定义(C++17 前必需)
嵌套类型与友元:
class Matrix {
public:
class Row; // 嵌套类型
friend std::ostream& operator<<(std::ostream&, const Matrix&);
private:
std::vector<double> data_;
};
友元会破坏封装,但它能避免为了访问私有成员而写出低效的公有接口。工程上把友元限定在"运算符重载"和"紧耦合的辅助类"上。
2.2 构造与析构:对象的一生
构造函数不是"分配内存",而是"在已分配的内存上初始化"。这决定了几个反直觉的事实:
-
构造函数里抛出异常,对象不会被构造出来,但已构造的成员会被正确析构(成员按声明的逆序析构)。
-
构造函数不能是虚函数(虚表还没建好)。
-
在构造函数/析构函数里调用虚函数,不会派发到派生类。
class Base {
public:
Base() { print(); } // 此时派生类还没构造,调用的是 Base::print
virtual void print() { std::cout << "Base\n"; }
};
class Derived : public Base {
public:
void print() override { std::cout << "Derived\n"; }
};Derived d; // 输出 "Base",不是 "Derived"
原因是构造顺序:基类先构造,此时对象的动态类型还是 Base。析构同理,派生类先析构,之后对象的动态类型退回基类。
构造顺序:
- 虚基类(按深度优先、从左到右)
- 直接基类(按声明顺序)
- 非静态数据成员(按声明顺序)
- 构造函数体
析构顺序完全相反。理解这一点,才能解释"为什么成员析构顺序和构造顺序相反",以及"为什么基类析构函数必须是虚的"。
虚析构函数:
class Base {
public:
virtual ~Base() = default;
};
class Derived : public Base { std::vector<int> big_; };
Base* p = new Derived();
delete p; // 若 ~Base 非虚,只调用 ~Base,Derived::big_ 泄漏
规则很简单:只要类可能被通过基类指针 delete,析构函数就必须是虚的 。反过来说,如果一个类不打算被继承,就不要加虚析构------它会让类多一个虚表指针,破坏 POD 性质(不能再用 memcpy、不能用于某些 C 接口)。
特殊成员函数的生成规则(这条规则极其重要,面试高频):
| 你声明了 | 编译器还会生成 | 不会生成 |
|---|---|---|
| 无 | 默认构造、拷贝构造、拷贝赋值、移动构造、移动赋值、析构 | --- |
| 任一构造函数 | 默认构造(不再生成) | --- |
| 拷贝构造 或 拷贝赋值 或 析构 | (已废弃的隐式拷贝生成规则) | 移动构造/移动赋值不生成 |
| 移动构造 或 移动赋值 | --- | 拷贝构造/拷贝赋值被删除 |
第三行是经典陷阱:只要手写了析构函数,移动操作就不会自动生成,所有"移动"会退化成拷贝。
class Foo {
public:
~Foo() { /* 释放资源 */ } // 手写析构
// 结果:Foo 没有移动构造,vector<Foo> 扩容时全部走拷贝
};
// 修复
class Foo {
public:
~Foo() { ... }
Foo(Foo&&) noexcept = default;
Foo& operator=(Foo&&) noexcept = default;
};
这就是"rule of five":如果你需要自定义析构、拷贝构造、拷贝赋值、移动构造、移动赋值中的任何一个,通常五个都要显式处理。
2.3 拷贝语义与深拷贝
默认拷贝构造是逐成员拷贝(memberwise copy)。对含指针的类,这就是浅拷贝:
class Buffer {
char* data_;
size_t size_;
public:
Buffer(size_t n) : data_(new char[n]), size_(n) {}
~Buffer() { delete[] data_; }
};
Buffer a(10);
Buffer b = a; // 浅拷贝:b.data_ 和 a.data_ 指向同一块内存
// 析构时 double free
正确实现(Copy-and-Swap 惯用法):
class Buffer {
char* data_ = nullptr;
size_t size_ = 0;
public:
explicit Buffer(size_t n) : data_(new char[n]()), size_(n) {}
Buffer(const Buffer& other) : data_(new char[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer(Buffer&& other) noexcept : data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
Buffer& operator=(Buffer other) { // 按值传参:拷贝或移动由调用方决定
swap(*this, other);
return *this;
}
friend void swap(Buffer& a, Buffer& b) noexcept {
std::swap(a.data_, b.data_);
std::swap(a.size_, b.size_);
}
~Buffer() { delete[] data_; }
};
operator=(Buffer other) 这个写法有三个好处:一份代码同时处理拷贝赋值和移动赋值;强异常安全(swap 不抛异常);自赋值天然安全(other 是副本)。
但更好的做法是根本不要手写这些 :用 std::vector、std::string、std::unique_ptr 做成员,让编译器生成的默认版本就正确。零法则(Rule of Zero):不需要自定义析构/拷贝/移动时,一个都别写。
删除拷贝:很多资源型类不应该被拷贝。
class Socket {
public:
Socket(const Socket&) = delete;
Socket& operator=(const Socket&) = delete;
Socket(Socket&&) noexcept = default;
};
= delete 的成员函数会参与重载解析,但被选中时报错,这比"声明为 private 不定义"(C++03 的土办法)好得多。
2.4 继承:public / protected / private
class Base { public: int a; protected: int b; private: int c; };
class D1 : public Base { }; // a 仍 public,b 仍 protected,c 不可访问
class D2 : protected Base { }; // a、b 都变 protected
class D3 : private Base { }; // a、b 都变 private
class 默认继承方式是 private,struct 是 public。实践中 99% 用 public 继承(表示"is-a"关系)。
非虚继承 vs 虚继承:普通继承时,派生类对象里直接嵌入基类子对象。多重继承时,同一个基类可能出现两份:
class A { public: int x; };
class B : public A {};
class C : public A {};
class D : public B, public C {}; // D 里有两份 A::x
访问 d.x 会有二义性,必须 d.B::x 或 d.C::x。如果语义上应该只有一份,就用虚继承:
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {}; // 只有一份 A
虚继承通过虚基类表定位基类,对象布局更复杂,构造顺序也不同(虚基类最先构造,且只构造一次)。除非真的需要,不要用虚继承------菱形继承在工程上几乎总是设计问题的信号。
对象切片(slicing):把派生类对象按值赋给基类对象,派生部分被"切掉"。
class Base { public: virtual void f(); };
class Derived : public Base { public: void f() override; std::string extra; };
Derived d;
Base b = d; // 切片:b 只有 Base 部分,b.f() 调用 Base::f
这就是为什么多态必须通过引用或指针传递,也是为什么"容器里存基类对象"无法实现多态------必须存指针或 std::reference_wrapper。
2.5 多态与虚函数机制
静态绑定 vs 动态绑定:
struct Base { virtual void f() { std::cout << "Base"; } };
struct Derived : Base { void f() override { std::cout << "Derived"; } };
Base* p = new Derived;
p->f(); // 动态绑定,调用 Derived::f
Base b = Derived(); // 切片,调用 Base::f
虚函数的实现(Itanium ABI):
每个有虚函数的类有一张虚表(vtable),里面按固定顺序存着虚函数地址。每个对象在内存开头(通常是偏移 0,多继承时可能有多个)有一个虚表指针(vptr)。
class Base {
public:
virtual void f();
virtual void g();
int x;
};
// 内存布局大致是:
// [vptr][x]
// vptr -> [ &Base::f, &Base::g, type_info* ]
调用 p->f() 的实际过程:从 p 指向的内存取 vptr,从 vptr 偏移 0 取函数地址,然后 call。这就是"一次间接跳转"的开销,也是为什么虚函数比普通函数慢------不只是多一次内存访问,更因为无法内联(编译器不知道目标地址)。
sizeof 的体现:
struct Empty {};
struct WithVirtual { virtual void f(); };
// sizeof(Empty) == 1(保证每个对象有唯一地址)
// sizeof(WithVirtual) == 8(64 位下一个 vptr)
override / final / override 检查:
class Derived : public Base {
public:
void f() override; // 编译期检查签名是否真的匹配基类虚函数
void h() final; // 禁止进一步覆盖
};
override 不是可选装饰,是必须写的安全网。没有它,签名写错(比如漏了 const)会变成"新函数"而不是"覆盖",且编译器不会报错。
class Base { virtual void f(int); };
class D : public Base {
void f(double) override; // 编译错误:没有匹配的虚函数
void f(unsigned) const; // 若没写 override,这是个新函数,永远不会被调用
};
纯虚函数与抽象类:
class Shape {
public:
virtual double area() const = 0; // 纯虚,类变成抽象类
virtual ~Shape() = default;
};
纯虚函数可以有定义(在类外),这允许"纯虚 + 有默认实现"的混合语义,但很少用。
虚函数的性能代价与替代方案:
- 去虚化(devirtualization) :当对象是栈上具体类型、或编译器能证明动态类型时,可以静态绑定。
final也能帮助编译器去虚化。 - CRTP(静态多态):见第三章,编译期绑定,零开销,但会增大代码体积。
- 函数指针表:手写 vtable,适合需要 ABI 稳定或跨语言交互的场景。
std::variant+std::visit:封闭集合的多态,避免堆分配,但类型集合必须编译期确定。
2.6 运算符重载:克制地使用
struct Vec2 {
double x, y;
Vec2 operator+(const Vec2& o) const { return {x + o.x, y + o.y}; }
Vec2& operator+=(const Vec2& o) { x += o.x; y += o.y; return *this; }
bool operator==(const Vec2& o) const = default; // C++20
};
规则与建议:
- 至少有一个操作数是类类型或枚举,才能重载。
- 不能重载的运算符:
.、::、?:、sizeof、alignof、typeid。 =、[]、()、->必须是成员函数。- 流运算符
<<、>>必须是全局函数 (左操作数是ostream)。 - 不要重载
&&、||、,,它们会失去短路/顺序语义。 - 一元
&不要重载(会破坏取地址语义)。
operator== 与 operator<=>:
C++20 的 = default 让编译器自动生成逐成员的比较,还能自动生成 !=:
struct Version {
int major, minor, patch;
auto operator<=>(const Version&) const = default; // 同时生成 <, >, <=, >=, ==, !=
};
这比手写六个运算符可靠得多。注意浮点成员用 <=> 默认实现时会有问题(NaN),要自己写。
operator[] 的两个版本:
class Grid {
std::vector<int> data_;
public:
int& operator[](size_t i) { return data_[i]; }
const int& operator[](size_t i) const { return data_[i]; }
};
只提供 const 版本会导致"对非 const 对象也要用 const 重载",性能上没差别但语义不对。
operator() 与函数对象 :让对象可以像函数一样调用,这是 lambda 和 std::function 的基础。
struct Multiplier {
int factor;
int operator()(int x) const { return x * factor; }
};
类型转换运算符:
class Celsius {
double t_;
public:
explicit operator double() const { return t_; }
};
务必加 explicit,否则隐式转换会在意想不到的地方发生。
2.7 RAII:C++ 最重要的设计思想
RAII(Resource Acquisition Is Initialization)的意思是:把资源的生命周期绑定到对象的生命周期。构造函数获取资源,析构函数释放资源,中间不管发生什么(异常、提前 return、goto),析构函数都保证执行。
// 反例:手动管理
void bad() {
FILE* f = fopen("a.txt", "r");
if (!f) return;
if (some_condition) { fclose(f); return; } // 别忘了
if (error) { fclose(f); return; } // 又别忘了
fclose(f);
}
// 正例:RAII
void good() {
std::ifstream f("a.txt"); // 析构时自动关闭
if (!f) return; // 自动关闭
if (some_condition) return; // 自动关闭
}
RAII 的典型应用:
| 资源 | RAII 包装 |
|---|---|
| 堆内存 | unique_ptr / shared_ptr |
| 文件 | std::fstream / std::FILE* 的包装 |
| 互斥锁 | lock_guard / unique_lock |
| 动态库 | 自定义 handle 包装 |
| 网络连接 | 自定义 socket 包装 |
| 数据库事务 | 自定义 transaction 包装 |
| 临时状态 | "scope guard"(如 gsl::finally) |
一个通用的 scope guard 实现:
template <typename F>
class ScopeGuard {
F f_;
bool active_ = true;
public:
explicit ScopeGuard(F f) : f_(std::move(f)) {}
ScopeGuard(ScopeGuard&& o) noexcept : f_(std::move(o.f_)), active_(o.active_) { o.active_ = false; }
~ScopeGuard() { if (active_) f_(); }
void dismiss() { active_ = false; }
};
template <typename F>
ScopeGuard<F> make_guard(F f) { return ScopeGuard<F>(std::move(f)); }
void f() {
auto guard = make_guard([&]{ std::cout << "exit\n"; });
// ...
}
RAII 也是异常安全的基石。有了它,"异常安全"不再需要 try/finally,而是"每个资源都被一个对象持有"。
2.8 本章练习
- 实现一个
String类,带拷贝构造、移动构造、拷贝赋值、移动赋值、析构,并用operator=按值传参的写法,验证自赋值安全。 - 写一个类,故意在构造函数里调用虚函数,观察输出,解释原因。
- 用
sizeof对比"带虚函数的类"和"不带虚函数的类",并尝试用final观察编译器能否去虚化(看汇编)。 - 实现一个
FileGuard:构造时打开文件,析构时关闭并在未提交时回滚。 - 用
std::variant实现一个"三种形状求面积"的多态,与虚函数版本对比内存布局和调用开销。
第三章 模板与泛型编程
3.1 为什么需要模板
在 C 时代,"泛型"只能靠宏或者为每种类型手写一份代码:
#define MAX(a, b) ((a) > (b) ? (a) : (b)) // 无类型检查,参数被求值两次
int max_int(int a, int b) { return a > b ? a : b; }
double max_double(double a, double b) { return a > b ? a : b; }
宏的问题是:没有类型安全、无法调试、无法避免重复求值(MAX(i++, j++) 会自增两次)。函数的问题是要为每种类型复制代码。
模板把"类型"变成了参数,让编译器在实例化时生成针对具体类型的代码:
template <typename T>
T max(T a, T b) { return a > b ? a : b; }
max(1, 2); // 实例化 max<int>
max(1.0, 2.0); // 实例化 max<double>
max(std::string("a"), std::string("b")); // 实例化 max<std::string>
模板的本质是"编译器执行的代码生成"。它在编译期展开,没有运行时开销,但代价是编译时间、代码体积和错误信息的可读性。
3.2 函数模板与类型推导
template <typename T>
T sum(const std::vector<T>& v) {
T result{};
for (const auto& x : v) result += x;
return result;
}
模板参数推导的规则并不总是符合直觉:
template <typename T>
void f(T x); // 按值:数组退化为指针,cv 修饰被丢弃
template <typename T>
void g(T& x); // 按引用:数组保持数组类型,const 被保留
int a[10];
f(a); // T = int*
g(a); // T = int[10]
const int c = 1;
f(c); // T = int
g(c); // T = const int
这就是为什么"万能引用"要用 T&&:
template <typename T>
void perfect_forward(T&& x) {
// T 会被推导为 int& (左值) 或 int (右值)
}
非类型模板参数:模板参数不一定是类型,也可以是整数、枚举、指针等编译期常量。
template <typename T, size_t N>
struct FixedArray {
T data[N];
constexpr size_t size() const { return N; }
T& operator[](size_t i) { return data[i]; }
};
FixedArray<int, 10> arr;
模板参数的默认值:
template <typename T, typename Alloc = std::allocator<T>>
class MyVector { ... };
3.3 类模板与成员函数的延迟实例化
类模板的成员函数只有在被使用时才会实例化。这个特性非常有用:
template <typename T>
class Container {
public:
void push(const T& x) { /* 需要 T 可拷贝 */ }
void serialize() { /* 需要 T 可序列化 */ }
};
Container<NonSerializable> c;
c.push(x); // 只实例化 push,serialize 不实例化,编译通过
这也意味着:模板类的错误可能在你调用某个成员函数时才暴露,而不是在定义时。这是模板错误信息往往指向调用栈深处的原因。
类模板的静态成员:
template <typename T>
struct Counter {
static int count; // 每个 T 有独立的 count
};
template <typename T> int Counter<T>::count = 0;
这是实现"类型级单例"和"类型 ID"的常用手法。
3.4 特化与偏特化
全特化:为某个具体类型提供完全不同的实现。
template <typename T>
struct IsPod { static constexpr bool value = false; };
template <>
struct IsPod<int> { static constexpr bool value = true; };
偏特化:只固定部分模板参数,或对参数施加模式匹配。
template <typename T>
struct RemovePointer { using type = T; };
template <typename T>
struct RemovePointer<T*> { using type = T; }; // 匹配任意指针
template <typename T>
struct RemovePointer<T* const> { using type = T; };
偏特化的匹配是"最特化优先":同时匹配 T* 和 T* const 时,后者更特化。
函数模板只能全特化,不能偏特化 。这是标准的规定,也是为什么"针对 T* 提供不同实现"要用重载或 tag dispatch:
template <typename T> void f(T x) { ... } // 主模板
template <typename T> void f(T* x) { ... } // 重载,优先匹配指针
template <> void f<int>(int x) { ... } // 全特化
特化的顺序陷阱:函数模板全特化必须在使用之前声明,否则编译器会用主模板实例化,之后再也看不到特化版本。这是 ODR 违规的经典案例。
3.5 可变参数模板
C++11 引入的 parameter pack 让模板可以接受任意数量的参数:
template <typename... Args>
void log(Args&&... args) {
(std::cout << ... << args) << '\n'; // C++17 折叠表达式
}
递归展开(C++11 的经典写法):
void print() { std::cout << '\n'; }
template <typename T, typename... Rest>
void print(const T& first, const Rest&... rest) {
std::cout << first;
if constexpr (sizeof...(rest) > 0) std::cout << ", ";
print(rest...);
}
折叠表达式(C++17)让这变得更简洁:
template <typename... Args>
auto sum(Args... args) {
return (args + ...); // 一元右折叠
}
template <typename... Args>
bool all_true(Args... args) {
return (args && ...); // 逻辑与折叠,天然短路
}
template <typename... Args>
void push_all(std::vector<int>& v, Args... args) {
(v.push_back(args), ...); // 逗号折叠:依次执行
}
折叠有四种形式:一元左/右折叠、二元左/右折叠。二元折叠可以带初始值:
template <typename... Args>
auto sum_with_init(Args... args) {
return (0 + ... + args); // 二元右折叠,初值 0
}
sizeof...(args) 返回参数个数,是编译期常量,可用于 static_assert 和数组大小。
3.6 SFINAE:替换失败不是错误
SFINAE(Substitution Failure Is Not An Error)是模板元编程的核心机制:当模板参数替换到函数签名中导致类型不合法时,这个候选被静默丢弃,而不是编译报错。
// 只有当 T 有 size() 方法时才启用
template <typename T>
auto get_size(const T& c) -> decltype(c.size()) {
return c.size();
}
// 只有当 T 有 value 成员时才启用
template <typename T>
auto get_size(const T& c) -> decltype(c.value) {
return c.value;
}
decltype(c.size()) 在返回类型位置------如果 T 没有 size(),这一处替换失败,候选被丢弃,编译器去试下一个。这就是 SFINAE 的典型用法。
std::enable_if 是更显式的写法:
template <typename T,
typename = std::enable_if_t<std::is_integral_v<T>>>
T half(T x) { return x / 2; }
注意第二个模板参数用了默认值,这样调用方不需要写。但这也带来一个问题:两个只有默认参数不同的重载会冲突 (因为签名看起来一样)。解决办法是让 enable_if 出现在返回类型或非类型模板参数上:
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> half(T x) { return x / 2; }
template <typename T>
std::enable_if_t<std::is_floating_point_v<T>, T> half(T x) { return x / 2; }
if constexpr(C++17)在很多场景下取代了 SFINAE,代码可读性大幅提升:
template <typename T>
std::string to_string(const T& x) {
if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(x);
} else if constexpr (has_to_string<T>::value) {
return x.to_string();
} else {
static_assert(always_false<T>, "unsupported type");
}
}
if constexpr 的关键性质:被丢弃的分支不会被实例化 。所以即使 T 没有 to_string(),只要没进入那个分支,代码也合法。这与普通 if 有本质区别。
一个常用的 always_false 技巧,让 static_assert 延迟到实例化时才触发:
template <typename T> inline constexpr bool always_false = false;
3.7 Concepts:C++20 的约束系统
SFINAE 的报错信息是灾难级的。Concepts 把"约束"写成了可读的谓词:
template <typename T>
concept Integral = std::is_integral_v<T>;
template <typename T>
concept Sized = requires(T t) {
{ t.size() } -> std::convertible_to<size_t>;
};
template <Sized C>
size_t count(const C& c) { return c.size(); }
requires 表达式可以检查四类东西:
template <typename T>
concept MyConcept = requires(T a, T b, const T c) {
typename T::value_type; // 1. 类型存在
{ a + b } -> std::same_as<T>; // 2. 表达式合法且返回类型满足约束
{ c.size() } noexcept; // 3. 表达式不抛异常
requires Integral<T>; // 4. 嵌套的 concept 要求
};
组合 concept:
template <typename T>
concept Number = Integral<T> || std::floating_point<T>;
template <typename T>
concept Container = Sized<T> && requires(T t) { t.begin(); t.end(); };
使用 concepts 后,错误信息从"替换失败于 200 层模板"变成"约束 Sized<int> 未满足",体验是质变。
标准库提供了一批现成的:
std::integral<T>
std::floating_point<T>
std::same_as<T, U>
std::convertible_to<From, To>
std::invocable<F, Args...>
std::ranges::range<T>
3.8 type_traits 与编译期计算
<type_traits> 是模板元编程的瑞士军刀。
类型判断:
std::is_same_v<T, U>
std::is_integral_v<T>
std::is_pointer_v<T>
std::is_trivially_copyable_v<T>
std::is_nothrow_move_constructible_v<T>
类型变换:
std::remove_reference_t<T>
std::remove_cv_t<T>
std::decay_t<T> // 去引用 + 去 cv,数组/函数退化为指针
std::conditional_t<B, T, F>
std::common_type_t<T, U>
std::underlying_type_t<Enum>
std::invoke_result_t<F, Args...>
std::decay_t 在实际代码中极其常用,因为模板推导出的类型常常带引用和 const:
template <typename T>
void store(T&& x) {
using V = std::decay_t<T>; // 去掉引用/const,得到可存储的类型
V copy = std::forward<T>(x);
}
编译期分支:
template <bool B>
struct Chooser { using type = int; };
template <>
struct Chooser<true> { using type = double; };
std::conditional_t 是这个模式的封装。
编译期序列生成 (std::index_sequence):
template <typename Tuple, typename F, size_t... I>
void apply_impl(Tuple&& t, F&& f, std::index_sequence<I...>) {
(f(std::get<I>(t)), ...);
}
template <typename Tuple, typename F>
void apply(Tuple&& t, F&& f) {
apply_impl(std::forward<Tuple>(t), std::forward<F>(f),
std::make_index_sequence<std::tuple_size_v<std::decay_t<Tuple>>>{});
}
这是"遍历 tuple 的每个元素"的标准写法,C++17 之后几乎所有需要 tuple 展开的场合都用它。
3.9 CRTP:静态多态
CRTP(Curiously Recurring Template Pattern)让派生类把自己作为模板参数传给基类,实现编译期多态:
template <typename Derived>
class Comparable {
public:
bool operator!=(const Derived& o) const {
return !static_cast<const Derived*>(this)->operator==(o);
}
bool operator>(const Derived& o) const {
return o < *static_cast<const Derived*>(this);
}
};
class Point : public Comparable<Point> {
public:
int x, y;
bool operator==(const Point& o) const { return x == o.x && y == o.y; }
bool operator<(const Point& o) const { return x < o.x || (x == o.x && y < o.y); }
};
好处:零运行时开销、可内联、无 vptr。代价:代码膨胀(每个 Derived 一份)、错误信息更难读、不能存进"基类容器"。
CRTP 的另一个用途:混入(mixin)。
template <typename Derived>
struct Singleton {
static Derived& instance() {
static Derived d;
return d;
}
};
CRTP 的限制 :基类的成员函数在实例化时,Derived 还是不完整类型 。所以不能在基类里做 sizeof(Derived) 或访问 Derived 的成员------除非延迟到成员函数体里(成员函数的实例化发生在类完成定义之后)。
template <typename D>
struct Base {
void f() { sizeof(D); } // ok:成员函数体延迟实例化
// D d; // 错误:类定义时 D 不完整
};
3.10 模板的工程问题
代码膨胀 :每个实例化都是一份独立的代码。std::vector<int>、std::vector<long>、std::vector<MyStruct> 是三份完全不同的代码。对嵌入式或移动端,这可能意味着几 MB 的差异。
缓解手段:
-
把与类型无关的逻辑抽到非模板基类("thin template"技巧)。
-
用
extern template显式实例化,避免每个翻译单元都生成一份。 -
用类型擦除(
std::function、std::any)------代价是运行时开销。// 在某一个 .cpp 中
template class std::vector; // 显式实例化 // 在头文件中
extern template class std::vector; // 告诉编译器别实例化
编译时间 :模板是编译时间的主要杀手。<iostream> 一个头文件就带来几十万行。手段包括:前置声明、PImpl、C++20 模块(modules)、extern template、限制模板的"传染性"。
错误信息的可读性 :Concepts 是最大的改善。另一个技巧是用 static_assert 提供自定义信息:
template <typename T>
void f(T) {
static_assert(sizeof(T) <= 64, "T 太大,请改用指针");
}
ODR 与 inline :模板函数和模板类天然满足"多翻译单元可重复定义",所以不需要 inline。但模板的静态成员在 C++17 之前需要小心处理(隐式 inline 是 C++17 才明确的)。
3.11 本章练习
- 实现一个编译期的
Fibonacci<N>,用static_assert验证Fibonacci<10> == 55。 - 实现
TypeList:支持push_front、push_back、contains的编译期类型列表。 - 用 concepts 写一个
Sortable约束,要求类型支持<且可拷贝,并提供一个sort函数。 - 实现一个
tuple的for_each,对每个元素调用一次函数,用折叠表达式实现。 - 用 CRTP 实现一个
Counted<T>,统计某类对象的构造数量,并验证它在多线程下的行为(引出第七章的原子操作)。
第四章 STL:容器、迭代器与算法
4.1 STL 的整体结构
STL 由六部分组成:容器(container)、迭代器(iterator)、算法(algorithm)、仿函数(functor)、适配器(adapter)、分配器(allocator)。它们通过"迭代器"这个中介解耦:算法只认迭代器接口,容器只暴露迭代器,所以 M 个算法和 N 个容器只需 M+N 份实现,而不是 M*N。
一个典型调用:
std::vector<int> v{3, 1, 4, 1, 5};
std::sort(v.begin(), v.end()); // 算法 + 迭代器
auto it = std::find(v.begin(), v.end(), 4); // 查找
std::distance(v.begin(), it); // 距离
算法的"半开区间"约定 [first, last) 是理解 STL 的关键:last 指向最后一个元素的下一个位置。这样空区间就是 first == last,不需要特判;循环写起来也自然:
for (auto it = first; it != last; ++it) { ... }
4.2 顺序容器
std::vector:动态数组,连续内存,是默认选择。
std::vector<int> v;
v.reserve(100); // 预留容量,避免反复 realloc
v.push_back(1);
v.emplace_back(2); // 原地构造,少一次拷贝
v.pop_back();
v.size(); v.capacity(); v.empty();
v.clear(); // 清空元素但保留 capacity
v.shrink_to_fit(); // 请求释放多余容量(非强制)
扩容策略:GCC 是 2 倍,MSVC 是 1.5 倍。扩容时会把所有元素搬到新内存,因此扩容后所有指针、引用、迭代器都失效。
std::vector<int> v{1,2,3};
int& r = v[0];
v.push_back(4); // 可能扩容
// r 可能悬垂
reserve 的价值:如果你知道大概元素数,一次 reserve 可以避免 log2(n) 次搬迁,对大对象效果显著。
emplace_back 与 push_back 的区别:
struct Big { Big(int, int); Big(const Big&); Big(Big&&) noexcept; };
v.push_back(Big(1, 2)); // 构造临时对象,再移动进容器(C++17 起保证省略临时对象?不保证)
v.emplace_back(1, 2); // 直接在容器内存上构造,零临时对象
C++17 的"guaranteed copy elision"让 v.push_back(Big(1,2)) 也不再产生临时对象,但 emplace_back 对于不可移动的类型仍然必要。
std::deque :双端队列,分段连续。头尾插入都是 O(1),随机访问 O(1)(两次间接)。比 vector 多一层间接,遍历略慢,但头插不会导致全部元素搬迁。
std::deque<int> d;
d.push_front(1); // vector 没有这个
d.push_back(2);
std::list:双向链表。任意位置插入删除 O(1)(前提是你已经有迭代器),但遍历的缓存局部性极差。
现代 CPU 下一次 L3 命中约 40 周期,一次内存访问约 200+ 周期。链表节点分散在堆上,每次 next 都可能 cache miss。所以除非频繁在中间插入删除,不要用 list 。即使如此,vector + 批量重建往往更快。
std::list<int> l;
l.splice(l.end(), other); // list 的杀手级操作:O(1) 拼接,vector 做不到
l.sort(); // 成员函数版,因为 std::sort 需要随机访问迭代器
std::forward_list:单向链表,省一个指针的内存,只支持前向遍历。
容器选择速查:
| 场景 | 选择 |
|---|---|
| 默认 | vector |
| 频繁头尾操作 | deque |
| 频繁中间插入删除 + 已有迭代器 | list |
| 内存极度敏感的单向遍历 | forward_list |
| 编译期固定大小 | array |
| 不需要所有权,只看一段 | span / string_view |
4.3 关联容器
std::map / std::set:红黑树实现,有序,查找 O(log n)。
std::map<std::string, int> m;
m["a"] = 1; // 注意:operator[] 会默认构造不存在的键
m.at("b") = 2; // 不存在时抛异常
m.insert({"c", 3});
m.try_emplace("d", 4); // C++17:键已存在时不构造值
m.contains("a"); // C++20
operator[] 的陷阱:
if (m["key"] == 0) { ... } // 如果 "key" 不存在,会插入一个 0 值!
只读查询应该用 find 或 contains:
auto it = m.find("key");
if (it != m.end()) { ... }
std::unordered_map / unordered_set:哈希表,平均 O(1),无序。
std::unordered_map<std::string, int> um;
um.reserve(100); // 预分配桶,避免 rehash
um.max_load_factor(0.7); // 默认 1.0,调到 0.7 减少冲突
哈希表的性能高度依赖哈希函数质量和桶数量。对于自定义类型做键,需要提供哈希:
struct Point { int x, y; bool operator==(const Point&) const = default; };
struct PointHash {
size_t operator()(const Point& p) const noexcept {
return std::hash<int>{}(p.x) ^ (std::hash<int>{}(p.y) << 1);
}
};
std::unordered_map<Point, int, PointHash> grid;
哈希组合的常见错误是简单相加(h1 + h2),容易产生大量冲突。实践中有几种较好的混合方式:
// FNV-1a 风格
size_t h = 1469598103934665603ull;
h = (h ^ x) * 1099511628211ull;
或者用 boost::hash_combine 的思路:
seed ^= h + 0x9e3779b9 + (seed << 6) + (seed >> 2);
map vs unordered_map 的选择:
- 需要有序遍历(范围查询、按序遍历)→
map - 只做点查、追求速度 →
unordered_map - 键是小整数且范围有限 → 考虑直接用
vector做"直接寻址表",比哈希表快一个数量级 - 对性能极致敏感且键是字符串 → 考虑开放寻址的自研哈希表(如
absl::flat_hash_map、ankerl::unordered_dense)
std::multimap / multiset :允许重复键。常用于"一对多"关系,但通常用 map<K, vector<V>> 更直观。
4.4 容器适配器
std::stack :默认基于 deque,只有 push、pop、top。
std::stack<int> s;
s.push(1);
s.top(); // 注意:top 返回引用,pop 返回 void
s.pop();
std::queue :默认基于 deque,push、pop、front、back。
std::priority_queue :默认大顶堆,基于 vector + make_heap。
std::priority_queue<int> pq; // 大顶堆
std::priority_queue<int, std::vector<int>, std::greater<int>> min_pq; // 小顶堆
自定义比较:
struct Task { int priority; std::string name; };
struct Cmp { bool operator()(const Task& a, const Task& b) const { return a.priority < b.priority; } };
std::priority_queue<Task, std::vector<Task>, Cmp> tasks;
注意 priority_queue 的比较函数语义是"返回 true 表示 a 的优先级低于 b",与 std::sort 相反,容易写反。
4.5 迭代器
五类迭代器(按能力递增):
| 类别 | 能力 | 代表 |
|---|---|---|
| 输入/输出 | 单向读/写 | istream_iterator |
| 前向 | 可多次遍历 | forward_list |
| 双向 | 可后退 | list、map |
| 随机访问 | +n、[]、比较 |
vector、deque、原生指针 |
| 连续 | 内存连续 | vector、array、string(C++20) |
算法对迭代器类别有要求:std::sort 需要随机访问,所以不能用于 list;std::find 只需要输入迭代器。
迭代器失效规则(背下来能省很多调试时间):
| 容器 | 插入 | 删除 |
|---|---|---|
vector/string |
扩容则全部失效;否则插入点之后的失效 | 删除点之后的失效 |
deque |
两端插入使全部迭代器失效(但引用不失效);中间插入使全部失效 | 中间删除使全部失效 |
list/forward_list |
不失效 | 只使被删除元素的迭代器失效 |
map/set |
不失效 | 只使被删除元素的迭代器失效 |
unordered_* |
rehash 则全部失效 | 只使被删除元素的失效 |
std::advance / next / prev:
auto it = v.begin();
std::advance(it, 5); // 随机访问迭代器 O(1),否则 O(n)
auto it2 = std::next(it, 2);
auto it3 = std::prev(it);
反向迭代器:
for (auto rit = v.rbegin(); rit != v.rend(); ++rit) { ... }
v.rbegin().base(); // 转回正向迭代器,指向 rit 的"下一个"
base() 的这个偏移关系经常让 erase 写错:
// 删除最后一个满足条件的元素
auto rit = std::find_if(v.rbegin(), v.rend(), pred);
if (rit != v.rend()) v.erase(std::next(rit).base());
4.6 算法库:比手写循环更好
<algorithm> 里的算法通常比手写循环更快(编译器对它们有特殊优化)且更不易出错。
查找:
std::find(v.begin(), v.end(), x);
std::find_if(v.begin(), v.end(), [](int x){ return x > 0; });
std::find_if_not(...);
std::binary_search(v.begin(), v.end(), x); // 前提:已排序
std::lower_bound(v.begin(), v.end(), x); // 第一个 >= x
std::upper_bound(v.begin(), v.end(), x); // 第一个 > x
std::equal_range(v.begin(), v.end(), x); // [lower, upper)
lower_bound / upper_bound 是二分查找的核心,值得记牢:
lower_bound返回第一个不小于 x 的位置(插入点,保持有序)upper_bound返回第一个大于 x 的位置- 两者之差就是 x 的个数
排序:
std::sort(v.begin(), v.end()); // 不稳定,O(n log n)
std::stable_sort(v.begin(), v.end()); // 稳定
std::partial_sort(v.begin(), v.begin() + k, v.end()); // 前 k 个
std::nth_element(v.begin(), v.begin() + k, v.end()); // 第 k 小就位,O(n) 平均
nth_element 是求中位数、Top-K 的利器,平均 O(n)。
变换与生成:
std::transform(in.begin(), in.end(), out.begin(), [](int x){ return x * 2; });
std::copy(in.begin(), in.end(), out.begin());
std::fill(v.begin(), v.end(), 0);
std::generate(v.begin(), v.end(), [n = 0]() mutable { return n++; });
std::iota(v.begin(), v.end(), 0); // 0,1,2,...
集合操作(要求输入已排序):
std::set_union / set_intersection / set_difference / set_symmetric_difference
删除惯用法(erase-remove):
v.erase(std::remove(v.begin(), v.end(), 0), v.end()); // 删除所有 0
v.erase(std::remove_if(v.begin(), v.end(), pred), v.end()); // 删除满足条件的
C++20 有更简洁的 std::erase(v, 0) 和 std::erase_if(v, pred)。
为什么 remove 之后还要 erase :std::remove 只是把要保留的元素前移,返回新的逻辑结尾,它不知道容器,无法真正缩短。这是 STL "算法与容器解耦"的必然结果。
std::for_each 与手写循环 :本质等价,但 for_each 支持并行版本(C++17 的 std::for_each(std::execution::par, ...),需要 TBB 或 libstdc++ 的并行后端)。
4.7 lambda:匿名函数对象
auto add = [](int a, int b) { return a + b; };
捕获列表:
int x = 1, y = 2;
[=](){}; // 按值捕获所有用到的变量
[&](){}; // 按引用捕获
[x, &y](){}; // 混合
[=, &y](){}; // 默认按值,y 按引用
[this](){}; // 捕获 this 指针(C++17 起可 *this 按值捕获整个对象)
注意事项:
- 按引用捕获的变量必须活过 lambda。把 lambda 存起来(如存进回调列表)再异步执行,很容易悬垂。
[&]捕获this是隐式的 ,但[=]在 C++17 前也隐式捕获this(按值捕获的是指针,不是对象)。C++20 起[=]不再隐式捕获this,必须显式写。- Lambda 的闭包大小取决于捕获的变量总大小。捕获一个大对象按值,lambda 就变大了。
mutable :按值捕获的变量默认是 const 的,要修改需加 mutable(修改的是副本)。
auto counter = [n = 0]() mutable { return ++n; };
counter(); counter(); // 1, 2
泛型 lambda(C++14):
auto print = [](const auto& x) { std::cout << x; };
本质是 operator() 是模板的仿函数。
lambda 的实现 :编译器生成一个匿名类,捕获的变量是成员,operator() 是成员函数。所以 lambda 的大小 = 捕获变量的大小之和(可能有填充),且捕获列表为空时大小为 1(保证唯一地址)。
4.8 std::function 与类型擦除
std::function<int(int)> f = [](int x) { return x * 2; };
f(3);
std::function 是类型擦除的容器,可以存任何签名兼容的可调用对象。代价:
- 可能堆分配(捕获较大的 lambda 时会分配)。
- 一次间接调用(通常无法内联)。
- 构造/析构有开销。
性能对比 (粗略):直接调用 < 函数指针 ≈ 模板参数 < std::function < std::map 查找。
选择建议:
- 需要存进容器、跨 API 边界、类型必须统一 →
std::function - 编译期就能确定 → 模板参数(零开销)
- 需要 C 接口兼容 → 函数指针 +
void*上下文
std::function 还有一个陷阱:它要求被存储的可调用对象可拷贝 。捕获 unique_ptr 的 lambda 不能直接放进去,需要用 shared_ptr 或 std::move_only_function(C++23)。
4.9 Ranges(C++20)
Ranges 让算法可以直接接受容器,不用写 begin()/end():
std::ranges::sort(v);
std::ranges::find(v, 3);
更大的价值是视图(views)------惰性求值、可组合:
namespace rv = std::ranges::views;
auto result = v
| rv::filter([](int x){ return x % 2 == 0; })
| rv::transform([](int x){ return x * x; })
| rv::take(5);
for (int x : result) std::cout << x << ' ';
视图不产生中间容器,所有操作在遍历时逐元素组合执行。这既省内存又省时间(虽然可能因为"每元素多次函数调用"而不一定最快)。
常用视图:
rv::filter / rv::transform / rv::take / rv::drop
rv::take_while / rv::drop_while / rv::reverse
rv::iota / rv::repeat / rv::join / rv::split
rv::elements / rv::keys / rv::values // 对 tuple/pair
注意视图的生命周期:视图持有底层容器的引用/迭代器,底层容器销毁后视图悬垂。
auto view = make_vector() | rv::take(3); // 危险:底层临时 vector 已销毁
这是 ranges 最常见的事故,和 string_view 是同一类问题。
4.10 本章练习
- 对比
vector、list、deque在 100 万元素下的遍历、随机插入、排序耗时,画出表格。 - 用
lower_bound实现"统计有序数组中等于 x 的元素个数"。 - 用
nth_element求 100 万个随机数的中位数,与sort版本对比耗时。 - 写一个自定义哈希函数,让
unordered_map<Point, int>在 1000 万个点上的查找比默认快 30%。 - 用 ranges 实现"读取一组整数,筛掉负数,平方,取前 10 个",全程不产生中间容器。
第五章 内存管理与对象生命周期
5.1 进程内存布局
一个典型 Linux 进程的地址空间(从低到高):
+---------------------+ 0x0000...
| 代码段 .text | 只读,放机器指令
+---------------------+
| 只读数据 .rodata | 字符串字面量、const 全局
+---------------------+
| 已初始化数据 .data | 非零初值的全局/静态变量
+---------------------+
| 未初始化 .bss | 零初值的全局/静态变量(不占文件空间)
+---------------------+
| 堆 heap | malloc/new,向高地址增长
+---------------------+
| ... |
+---------------------+
| 共享库映射 |
+---------------------+
| 栈 stack | 局部变量、返回地址,向低地址增长
+---------------------+
| 环境变量/命令行参数 |
+---------------------+ 0xffff...
栈的大小通常由 ulimit -s 决定(Linux 默认 8 MB)。这个限制决定了不要在栈上放巨大数组:
void f() {
int buf[1000000]; // 4 MB,接近栈上限,某些环境直接崩溃
// 应该用 std::vector 或 new
}
栈溢出(stack overflow)的典型表现是 Segmentation fault(Linux)或 Stack overflow(Windows)。调试时可以用 -fstack-protector 或 ASan 定位。
5.2 new / delete 的真实行为
Widget* p = new Widget(); // 三步:分配内存 -> 构造对象 -> 返回指针
delete p; // 两步:析构对象 -> 释放内存
new 实际上调用了 operator new(分配内存)和构造函数。operator new 默认就是 malloc 加上失败时的 bad_alloc 抛出。
void* mem = ::operator new(sizeof(Widget));
Widget* p = ::new (mem) Widget(); // placement new:只在已有内存上构造
p->~Widget(); // 显式析构
::operator delete(mem);
placement new 是很多容器和内存池的基础:先在预分配的内存上构造对象,避免每次 new 的系统调用和分配器开销。
new[] 与 delete[] :数组版本会在内存前多存一个元素个数(实现相关),delete[] 靠它来逐个析构。混用 new[] / delete 是 UB。
int* a = new int[10];
delete a; // UB!
delete[] a; // 正确
现代 C++ 里应该完全避免裸 new[],用 std::vector 或 std::unique_ptr<T[]>。
operator new 可重载:可以自定义内存分配策略,这是内存池和调试工具的基础。
void* operator new(size_t size) {
void* p = std::malloc(size);
if (!p) throw std::bad_alloc();
return p;
}
void operator delete(void* p) noexcept { std::free(p); }
重载可以放在全局,也可以放在类内(只影响该类)。配合 operator new(size_t, nothrow_t) 可以支持"不抛异常"版本。
5.3 智能指针
std::unique_ptr:独占所有权,零开销(大小等于裸指针,无控制块)。
std::unique_ptr<Widget> p = std::make_unique<Widget>(args...);
p->method();
*p;
p.get(); // 取裸指针,不转移所有权
p.release(); // 放弃所有权,返回裸指针
p.reset(); // 销毁对象
必须用 make_unique:
std::unique_ptr<Widget> p(new Widget()); // 不推荐
auto p = std::make_unique<Widget>(); // 推荐
理由:make_unique 保证异常安全。考虑 f(std::unique_ptr<A>(new A), g()):如果 g() 抛异常,new A 分配的内存会泄漏,因为 unique_ptr 还没来得及接管。make_unique 把分配和接管合成一个操作,不会出现中间态。
注意 make_unique 无法指定自定义删除器(C++14 的局限),这种情况下仍需 unique_ptr<T, D>(new T, deleter)。
自定义删除器:
auto deleter = [](FILE* f) { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(deleter)> fp(fopen("a.txt", "r"), deleter);
删除器是类型的一部分,所以不同删除器的 unique_ptr 是不同类型(这也让它可以零开销存储)。
std::shared_ptr:共享所有权,引用计数。
auto p = std::make_shared<Widget>();
auto q = p; // 引用计数 +1
p.use_count(); // 2(仅调试用,多线程下不可靠)
控制块(control block)里存着:强引用计数、弱引用计数、删除器、分配器。所以 shared_ptr 的大小是裸指针的 2 倍,且一定有一次堆分配。
make_shared 与 shared_ptr(new T) 的差异:
make_shared把对象和控制块分配在同一块内存,一次分配,缓存友好。shared_ptr(new T)是两次分配。- 但
make_shared在对象销毁后,整块内存要等弱引用也归零才能释放(因为控制块在同一块内存里)。对超大对象 + 长寿命weak_ptr的场景,这可能造成内存滞留。
循环引用:
struct Node {
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev; // 循环!引用计数永不归零
};
解决办法是把其中一个改成 std::weak_ptr:
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // 不增加引用计数
};
weak_ptr 的使用方式:
std::weak_ptr<Node> w = p;
if (auto locked = w.lock()) { // 尝试提升为 shared_ptr
locked->do_something();
} // 期间对象保证存活(多线程下这是唯一正确的用法)
shared_ptr 的线程安全边界(极其重要):
- 引用计数的增减是原子的(多线程拷贝同一个
shared_ptr对象安全)。 - 但
shared_ptr对象本身的读写不是原子的 。多个线程同时读写同一个shared_ptr变量(比如一个线程reset,另一个线程拷贝)是数据竞争,UB。 - 指向的对象也不是线程安全的。
多线程场景下要"原子地替换一个 shared_ptr",C++20 提供了 std::atomic<std::shared_ptr<T>>,C++17 之前用 std::atomic_load/atomic_store 的自由函数版本。
shared_ptr 的性能代价 :原子操作 + 间接访问 + 双指针大小。在热路径上大量使用会明显拖慢。经验法则是:能用 unique_ptr 就别用 shared_ptr,所有权清晰的地方不该共享。
5.4 分配器(allocator)
STL 容器的第二个模板参数就是分配器,默认 std::allocator。它的作用是抽象"内存从哪来"。
template <typename T, typename Alloc = std::allocator<T>>
class vector;
自定义分配器的典型用途:
- 内存池:减少系统调用和碎片。
- 统计:调试时统计各容器的内存占用。
- 共享内存/GPU 内存:把容器放在特殊内存区域。
C++17 引入了 std::pmr(polymorphic memory resource),让分配器可以运行时多态,避免"分配器类型不同导致容器类型不同"的传染问题:
#include <memory_resource>
std::pmr::monotonic_buffer_resource pool;
std::pmr::vector<int> v(&pool); // 所有类型都是 pmr::vector<int>,与分配策略解耦
monotonic_buffer_resource 是"只增不减"的分配器:从预分配的大块内存里切分,析构时一次性释放。非常适合"一批短生命周期对象"的场景(如解析一个请求时的临时对象)。
char buffer[1024];
std::pmr::monotonic_buffer_resource pool(buffer, sizeof(buffer));
{
std::pmr::vector<int> v(&pool); // 不触发堆分配(如果不超过 buffer)
v.assign(200, 1);
}
// 出了作用域,pool 自动回收(但不会调用 delete,只是重置指针)
注意 monotonic 分配器不会调用析构函数 ,所以不能用于持有资源的对象(std::string 的元素是 char 没问题,但 vector<shared_ptr> 就有问题)。
5.5 对象生命周期与平凡性
平凡可复制(trivially copyable) 是最有价值的类型属性之一:
static_assert(std::is_trivially_copyable_v<int>);
满足条件的类型可以安全地 memcpy,在网络上传输、写文件、DMA 传输。条件大致是:没有自定义拷贝/移动/析构、没有虚函数、所有成员也平凡。
struct Header { // 平凡
uint32_t magic;
uint16_t version;
uint16_t flags;
};
static_assert(std::is_trivially_copyable_v<Header>);
static_assert(sizeof(Header) == 8);
POD(Plain Old Data) 是更老的术语,C++11 后拆成了"trivial"和"standard layout"两个属性。标准布局(standard layout)保证成员顺序与声明一致、没有虚表、所有成员同访问级别------这是与 C 结构体互操作的前提。
生命周期开始的时机 :C++20 明确了"隐式对象创建"规则,malloc 得到的内存可以直接当作某些类型使用(在 C++20 前这是 UB)。实践上仍然建议用 std::launder 或者在 placement new 后再访问。
std::launder 用于在"对象已销毁但内存在复用"的场景下取得合法指针:
alignas(T) unsigned char buf[sizeof(T)];
T* p = new (buf) T();
p->~T();
new (buf) T(); // 新对象,地址相同
// 如果之前有指向旧对象的 const 引用,需要用 std::launder 刷新
这类场景主要出现在手写容器和联合体中,普通代码基本用不到。
5.6 内存对齐
为什么需要对齐 :CPU 访问未对齐的内存可能更慢(跨缓存行)甚至崩溃(某些 ARM 指令)。硬件要求 int 的地址是 4 的倍数,double 是 8 的倍数。
alignof(int); // 4
alignof(double); // 8
alignof(std::max_align_t); // 通常 16
结构体填充:
struct S1 { char a; int b; char c; }; // sizeof = 12
struct S2 { int b; char a; char c; }; // sizeof = 8
编译器会在成员之间插入填充,使每个成员的地址满足其对齐要求,同时结构体总大小是最大对齐值的倍数(这样数组里每个元素都对齐)。
调整顺序能省内存:
// 从大到小排列
struct Optimized {
double d; // 8
int i; // 4
short s; // 2
char c; // 1
}; // 16(8+4+2+1+1填充)
对百万级对象,从 24 字节降到 16 字节能省 8 MB,并显著提升缓存命中。
alignas / alignof(C++11):
struct alignas(64) CacheLineAligned { int x; }; // 强制 64 字节对齐(缓存行)
alignas(16) char buf[100]; // 便于 SIMD
std::aligned_storage 已废弃(C++23),改用 alignas + 字节数组或 std::byte。
5.7 缓存与内存性能
存储层次(数量级,x86-64):
| 层级 | 延迟 | 容量 |
|---|---|---|
| 寄存器 | 0 周期 | 几十个 |
| L1 | 4 周期 | 32--48 KB |
| L2 | 12 周期 | 256--512 KB |
| L3 | 40 周期 | 8--32 MB |
| 主存 | 200 周期 | GB 级 |
| SSD | 微秒级 | TB 级 |
缓存行(cache line) 是 64 字节。CPU 读一个 int 会把整行 64 字节都读进来。这解释了两件事:
- 顺序访问比随机访问快得多:顺序访问能充分利用预取和整行加载。
- 数据结构布局比算法常数更重要:一个 O(n) 的顺序遍历可能比 O(log n) 的树查找更快,因为后者每次都是 cache miss。
实测对比(100 万元素求和的三种方式):
// 1. vector<int>,连续内存:最快
for (int x : v) sum += x;
// 2. list<int>,节点分散:慢 5--10 倍
for (int x : l) sum += x;
// 3. vector<unique_ptr<int>>:再慢一倍
for (auto& p : vp) sum += *p;
差别的根源不是"指针解引用"本身,而是每次解引用都是一次潜在的 cache miss。
伪共享(false sharing):两个线程修改同一缓存行里的不同变量,会导致缓存行在核心间来回弹跳,性能损失可达 10 倍。
struct Bad {
std::atomic<int> a; // 同一缓存行
std::atomic<int> b;
};
struct Good {
alignas(64) std::atomic<int> a;
alignas(64) std::atomic<int> b;
};
alignas(64) 让每个变量独占一个缓存行。这是并发数据结构里的标准技巧。
std::hardware_destructive_interference_size(C++17)提供了平台相关的缓存行大小常量,比硬编码 64 更严谨(某些 ARM 是 128)。
5.8 内存问题检测工具
AddressSanitizer(ASan):
g++ -fsanitize=address -g main.cpp -o main
./main
能检测:堆/栈/全局越界、use-after-free、double-free、内存泄漏(配合 ASAN_OPTIONS=detect_leaks=1)。代价是 2--3 倍运行时间、大量内存占用。
UndefinedBehaviorSanitizer(UBSan):
g++ -fsanitize=undefined -g main.cpp
检测:有符号溢出、空指针解引用、越界移位、对齐错误、无效的枚举值等。
ThreadSanitizer(TSan):
g++ -fsanitize=thread -g main.cpp
检测数据竞争。代价较大(5--15 倍),但能发现极难复现的并发 bug。
Valgrind:
valgrind --tool=memcheck --leak-check=full ./main
无需重新编译,能检测未初始化读取、泄漏、越界。但慢 20--50 倍。
_GLIBCXX_DEBUG:开启 libstdc++ 的调试模式,检查迭代器越界、失效迭代器使用。
g++ -D_GLIBCXX_DEBUG main.cpp
代价是容器变慢,但能抓住"用了失效迭代器"这类问题。
工程建议:CI 里跑 ASan + UBSan 的全量测试,定期跑 TSan 的并发测试。发布版本不开 sanitizer(性能不可接受),但要保留 symbol 以便线上崩溃分析。
5.9 内存泄漏的常见来源与排查
- 裸 new 没有对应 delete,尤其在异常路径上。
shared_ptr循环引用。- 容器里存裸指针,容器清空但没 delete 元素。
- 单例/全局缓存只增不减。
- 自定义分配器忘了释放。
排查手段:
- ASan 的
detect_leaks给出分配栈。 valgrind --leak-check=full给出完整栈。- 生产环境可以重载
operator new/delete统计分配量,或集成jemalloc/tcmalloc的 heap profile。
统计分配的简易实现:
static std::atomic<size_t> g_allocated{0};
static std::atomic<size_t> g_count{0};
void* operator new(size_t size) {
void* p = std::malloc(size + sizeof(size_t));
if (!p) throw std::bad_alloc();
*static_cast<size_t*>(p) = size;
g_allocated += size;
++g_count;
return static_cast<char*>(p) + sizeof(size_t);
}
void operator delete(void* p) noexcept {
if (!p) return;
void* base = static_cast<char*>(p) - sizeof(size_t);
g_allocated -= *static_cast<size_t*>(base);
--g_count;
std::free(base);
}
这个技巧("在分配前存大小")在调试构建里非常有用,但会破坏对齐,正式实现需要额外处理。
5.10 本章练习
- 用
sizeof对比成员顺序不同的三个结构体,并画出内存布局图。 - 实现一个固定大小的内存池(预分配 N 个槽位,
allocate/deallocateO(1)),用operator new重载接进去,测 100 万次分配的时间。 - 构造一个
shared_ptr循环引用,用 ASan 的 leak 检测确认泄漏,再用weak_ptr修复。 - 写一个多线程程序,两个线程各写一个变量,把它们放在同一缓存行和不同缓存行,对比耗时(引出第七章)。
- 用
valgrind --tool=callgrind分析一个程序,找出最耗时的三个函数。
第六章 现代 C++:C++11 到 C++23
6.1 现代 C++ 的四个核心思想
C++11 被称为"现代 C++ 的起点",它的意义不在于加了多少特性,而在于用四条主线重写了语言的默认用法:
- 值语义与移动语义:让"传递对象"不再意味着"拷贝"。
- 类型推导与泛型 :
auto、decltype、lambda、可变参数模板。 - 编译期计算 :
constexpr、static_assert、noexcept。 - 所有权与资源管理:智能指针、RAII 成为默认。
后续的 14/17/20/23 基本是在这四条主线上继续推进:让编译期计算更强(consteval)、让泛型更可读(concepts)、让抽象更零成本(ranges、coroutines)、让代码更少写(structured bindings、if 初始化)。
6.2 右值引用与移动语义
这是现代 C++ 里最容易被"背下来但不理解"的部分。先回到一个基本问题:为什么需要移动?
std::vector<std::string> make() {
std::vector<std::string> v;
v.push_back("a very long string ...");
return v;
}
在 C++03 里,return v 要拷贝整个 vector(包括每个 string),然后是 NRVO(返回值优化)能省掉一部分。但 v.push_back(str)里,str 是个临时对象,拷贝完就销毁,这次拷贝纯属浪费。
左值与右值:粗略地说,能取地址、有名字的是左值;临时的、即将销毁的是右值。
int a = 1; // a 是左值
int b = a + 1; // a+1 是右值(临时的)
std::string s = "x"; // s 左值
std::string("y") // 右值
右值引用 T&& 的作用是"绑定到即将销毁的对象",从而可以安全地"偷走"它的资源。
class Buffer {
int* data_;
size_t size_;
public:
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) { // 直接接管指针
other.data_ = nullptr;
other.size_ = 0;
}
};
这里"偷"就是移动:把指针拿过来,把源对象置空。代价是三次赋值,与数据量无关。而拷贝的代价是 O(n)。
std::move 不是"移动",是"类型转换":
void f(std::string s);
std::string a = "hello";
f(std::move(a)); // 把 a 转成 string&&,从而选中移动构造
// 之后 a 处于"有效但未指定"状态,通常为空,但不要依赖
std::move 的等价实现就是 static_cast<T&&>(x)。它什么也不做 ,只是让重载决议选中接受 T&& 的版本。如果没有接受 T&& 的版本,std::move 之后仍然会拷贝。
const std::string s = "abc";
f(std::move(s)); // 转成 const string&&,无法绑定到 string&&(会丢 const),仍走拷贝
这是"对 const 对象 move 无效"的原因。
移动后的对象状态:标准只保证"有效但未指定"(valid but unspecified)。可以析构、可以赋值,但读取它的值是未指定的。实践中标准库容器 move 后为空,但不要依赖这一点。
完美转发:
template <typename T>
void wrapper(T&& arg) { // 万能引用(forwarding reference)
inner(std::forward<T>(arg)); // 保持左值/右值属性
}
T&& 在模板推导时是"万能引用":传入左值时 T 推导为 T&,引用折叠后 T&& 变成 T&;传入右值时 T 推导为 T,T&& 就是右值引用。
std::forward<T>(arg) 等价于 static_cast<T&&>(arg),它根据 T 还原出原始的左右值属性。
引用折叠规则(只有四种):
T& & -> T&
T& && -> T&
T&& & -> T&
T&& && -> T&&
只有"右值引用 + 右值引用"才是右值引用,其余都是左值引用。这就是万能引用能工作的底层机制。
移动语义的工程规则:
-
自定义移动操作要标
noexcept,否则容器扩容会退化成拷贝。 -
移动构造后要把源对象置为可析构的安全状态。
-
不要对
const对象std::move。 -
不要
return std::move(local)------这会阻止 NRVO,反而多一次移动。std::vector
f() {
std::vectorv;
// ...
return v; // 正确:NRVO
// return std::move(v); // 错误:阻止 NRVO
}
6.3 constexpr 与编译期计算
constexpr 变量:编译期就能确定的值。
constexpr int N = 10;
constexpr double PI = 3.1415926;
constexpr 函数:在编译期或运行期都可以执行;当所有参数是编译期常量时,结果在编译期算出。
constexpr int fib(int n) {
if (n < 2) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
int t = a + b;
a = b;
b = t;
}
return b;
}
static_assert(fib(10) == 55);
注意 C++11 的 constexpr 函数限制极严(只能一条 return 语句),C++14 才放开循环和局部变量。
consteval(C++20):必须在编译期求值。
consteval int square(int x) { return x * x; }
int a = square(5); // 编译期算出 25
// int b = 5; int c = square(b); // 编译错误:b 不是常量表达式
constinit(C++20):保证变量在编译期初始化,但仍可在运行期修改。
constinit int counter = 0; // 静态初始化,避免"静态初始化顺序问题"
编译期字符串 :constexpr 函数可以返回 std::array<char, N>,这是编译期字符串处理的基础。
template <size_t N>
constexpr auto make_upper(const char (&s)[N]) {
std::array<char, N> out{};
for (size_t i = 0; i < N; ++i)
out[i] = (s[i] >= 'a' && s[i] <= 'z') ? s[i] - 32 : s[i];
return out;
}
constexpr auto HELLO = make_upper("hello");
C++20 之后 std::string 也可以在 constexpr 中使用(有动态分配的话会有限制),这让"编译期生成配置"成为可能。
编译期计算的边界 :标准推荐至少支持 512 层递归、1024 次循环;实际 GCC 的限制由 -fconstexpr-depth 和 -fconstexpr-ops-limit 控制。写太重的编译期计算会导致编译时间暴涨,这是"编译期换运行期"的代价。
6.4 结构化绑定、if 初始化、\[attributes]
结构化绑定(C++17):
auto [a, b] = std::make_pair(1, 2.0);
for (const auto& [key, value] : map) { ... }
它本质是"给聚合类型的成员起别名",用 auto& 可以绑定引用:
auto& [x, y] = point; // x、y 是 point 成员的引用,可修改
注意:结构化绑定不能捕获进 lambda (C++20 之前),也不能用于 std::vector<bool> 的代理对象。
if / switch 初始化(C++17):
if (auto it = m.find(key); it != m.end()) {
use(it->second);
}
变量的作用域被限制在 if 语句内,既避免了污染外层,也避免了"先声明后赋值"。switch 同理。
属性:
[[nodiscard]] int compute(); // 丢弃返回值时警告
[[maybe_unused]] int x = 0; // 抑制未使用警告
[[deprecated("use new_api instead")]] void old_api();
[[noreturn]] void fatal();
[[likely]] / [[unlikely]] // 分支预测提示(C++20)
[[nodiscard]] 是现代 C++ 最实用的属性之一,用在所有"返回错误码/新对象"的函数上:
[[nodiscard]] bool try_connect(const std::string& host);
// 调用方忽略返回值会得到警告,避免"检查了连接却没处理失败"
6.5 std::optional、variant、any
std::optional<T>:表示"可能没有值"。
std::optional<int> parse_int(const std::string& s) {
try { return std::stoi(s); }
catch (...) { return std::nullopt; }
}
if (auto v = parse_int("42")) {
use(*v);
}
它比"返回 -1 表示失败"更安全(-1 可能是合法值),比"返回布尔 + 输出参数"更简洁。代价是多 1 字节(对齐后可能更多)。
注意:optional<int> 的 sizeof 通常是 8(4 字节值 + 1 字节标志 + 3 字节填充),不是 5。对海量数据存储时,这个开销值得考虑。
std::variant<A, B, C>:类型安全的联合。
using Value = std::variant<int, double, std::string>;
Value v = 42;
v = "hello";
std::visit([](auto&& x) {
std::cout << x;
}, v);
std::visit 要求所有分支的返回类型一致(或都能转换成同一类型),这也是它比虚函数"更静态"的体现。访问非活跃类型会抛 std::bad_variant_access。
variant 的大小是最大成员大小 + 判别式 + 对齐填充。需要"封闭集合的多态"、避免堆分配时,variant 比虚函数更优。
std::any:任意类型的容器,运行时类型擦除。
std::any a = 42;
a = std::string("x");
if (auto p = std::any_cast<int>(&a)) { ... }
代价:可能堆分配、每次访问要做类型检查、没有编译期保障。适合"配置项/插件参数"这类场景,不适合热路径。
三者的选择:
- 只有"有/无" →
optional - 有限个类型之一 →
variant - 完全未知的类型 →
any
6.6 string_view、span、filesystem、chrono
std::string_view :不拥有数据的字符串视图,substr 是 O(1)。
void log(std::string_view msg); // 可以接受 const char*、std::string、字面量
关键风险:不拥有数据。规则:只作函数参数,不存成员、不作返回(除非生命周期明确)。
std::span<T>(C++20):连续内存的视图,替代"指针 + 长度"的组合。
void process(std::span<const int> data);
int arr[10];
process(arr); // 自动推导长度
std::vector<int> v;
process(v); // 自动转换
process(v.data() + 2, 5); // 子范围
span 不拥有数据,同样有生命周期风险。它的价值是让接口既能接受数组、容器,也能接受裸指针区间,同时保留长度信息。
std::filesystem(C++17):
namespace fs = std::filesystem;
for (const auto& entry : fs::directory_iterator(".")) {
if (entry.path().extension() == ".cpp")
std::cout << entry.path() << '\n';
}
fs::create_directories("out/a/b");
fs::copy("a.txt", "b.txt", fs::copy_options::overwrite_existing);
fs::remove("x.tmp");
fs::exists(p); fs::is_regular_file(p); fs::file_size(p);
注意:fs::path 的 string() 在 Windows 上可能丢失非 ASCII 字符,应该用 u8string()。错误处理用 std::error_code 版本避免异常。
std::chrono:
using namespace std::chrono;
auto start = steady_clock::now(); // 用时序时钟测间隔,不要用 system_clock
// ...
auto elapsed = steady_clock::now() - start;
std::cout << duration_cast<microseconds>(elapsed).count() << " us\n";
system_clock 会被 NTP 调整,测耗时必须用 steady_clock(或 high_resolution_clock,但它可能是 system_clock 的别名)。
C++20 给 chrono 加了日历(year_month_day)、时区(zoned_time)、格式化的支持,让日期处理不再依赖 C 的 localtime。
6.7 std::format 与 std::print(C++20/23)
#include <format>
std::string s = std::format("name={}, age={}", "Tom", 20);
std::format("{:>10.2f}", 3.14159); // 宽度、精度
std::format("{0} {1} {0}", "a", "b"); // 位置参数
std::format 比 printf 类型安全(编译期检查格式串),比 ostream 快(不需要虚函数和 locale 开销),是 C++20 最受欢迎的特性之一。
C++23 的 std::print 直接输出到 stdout,不需要先格式化再输出:
std::print("hello {}\n", 42);
格式化的性能 :std::format 通常比 std::stringstream 快 2--5 倍,比 printf 略慢(因为要做类型检查和内存分配),但安全性高得多。
6.8 模块(Modules,C++20)
// math.cppm
export module math;
export int add(int a, int b) { return a + b; }
// main.cpp
import math;
int main() { return add(1, 2); }
模块的目标是彻底替代 #include:
- 没有文本替换,编译快得多。
- 没有头文件保护宏、没有 ODR 问题。
- 模块的接口和实现可以分离(
export module+module :private)。 - 宏不会"泄漏"到导入方。
现实:截至 2025 年,GCC/Clang/MSVC 对模块的支持仍不完整(尤其是与标准库模块、构建系统的集成)。CMake 需要 3.28+ 且配置复杂。新项目可以尝试,但大型存量项目短期内难以迁移。
6.9 协程(Coroutines,C++20)
协程让函数可以"暂停"和"恢复",是实现异步 I/O、生成器的基础。
generator<int> range(int n) {
for (int i = 0; i < n; ++i)
co_yield i;
}
for (int x : range(5)) std::cout << x;
三个关键字:
co_await:等待一个可等待对象co_yield:产出一个值并暂停co_return:返回并结束
C++20 只提供了协程的框架 (promise_type、coroutine_handle),没有提供现成的 generator、task 类型。std::generator在 C++23 才进入标准。
一个最小的 generator 需要定义 promise_type:
template <typename T>
struct Generator {
struct promise_type {
T current;
Generator get_return_object() { return Generator{handle_type::from_promise(*this)}; }
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T v) { current = v; return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
using handle_type = std::coroutine_handle<promise_type>;
handle_type h_;
// 迭代器、begin/end、析构...
};
协程的工程价值在于:用同步的写法实现异步的逻辑 ,避免回调地狱和状态机。但它的学习曲线陡峭(要理解 promise、awaiter、调度器),且调试体验差(栈不连续)。实践中多在网络库(如 asio 的 co_spawn)和生成器场景使用。
6.10 本章练习
- 实现一个
MyString,用移动语义让MyString a = make();不产生任何拷贝(打印构造/析构日志验证)。 - 写一个
constexpr的编译期素数筛,static_assert验证前 100 个素数。 - 用
std::variant+std::visit实现一个 JSON 值类型(null/bool/number/string/array/object)。 - 用
std::optional重写一个原本返回-1表示失败的函数,并说明 API 的变化。 - 用
std::format实现一个日志格式化函数,支持{}占位符和宽度/精度,对比stringstream版本的性能。
第七章 并发与多线程
7.1 并发不是"多开几个线程"
先明确三个概念的区别:
- 并发(concurrency):多个任务在时间上重叠地推进,不一定是同时。
- 并行(parallelism):多个任务在物理上同时执行,需要多核。
- 异步(asynchrony):发起操作后不等待结果,稍后通过回调/事件获取。
C++ 的 std::thread 给你的是"并行"的原语,但真正的并发设计还要考虑:任务如何划分、数据如何共享、错误如何传播、如何取消、如何观测。
什么时候不该用多线程:
- 任务本身是 I/O 密集且可以用异步 I/O 解决(线程会阻塞在 I/O 上,浪费栈和调度)。
- 数据共享极其频繁(锁的争用会让并行变成串行)。
- 任务太小(创建/调度开销超过计算本身)。
7.2 std::thread 基础
#include <thread>
void worker(int id) { std::cout << "thread " << id << '\n'; }
int main() {
std::thread t1(worker, 1);
std::thread t2(worker, 2);
t1.join(); // 等待结束
t2.join();
}
必须 join 或 detach :std::thread 析构时如果仍然 joinable,会调用 std::terminate。这是刻意的设计------避免"线程还在跑但对象已销毁"的悬垂。
std::thread t(f);
// 如果没有 t.join() 或 t.detach(),程序会 abort
detach 让线程在后台运行,但失去了同步能力,且线程结束时无法通知主线程。工程上很少用 detach,更常见的是用线程池管理生命周期。
传参的陷阱:
void f(const std::string& s);
std::thread t(f, "hello"); // 危险!
std::thread 的构造函数会按值拷贝 参数,然后在新线程里以右值传给函数。"hello" 是 const char*,拷贝的是指针,新线程启动时字面量还在(静态存储期),所以这里其实没问题。但如果是:
void f(const std::string& s);
char buf[100] = "hello";
std::thread t(f, buf); // 危险:buf 是局部数组,线程启动时可能已销毁
解决办法是显式构造 std::string:
std::thread t(f, std::string(buf));
硬件并发数:
unsigned n = std::thread::hardware_concurrency(); // 可能是 0,需要兜底
7.3 互斥量与锁
std::mutex:
std::mutex mtx;
{
std::lock_guard<std::mutex> lk(mtx); // 构造加锁,析构解锁
// 临界区
}
永远用 RAII 包装,不要手动 lock() / unlock()------异常路径会漏掉 unlock。
std::unique_lock :比 lock_guard 灵活,可以延迟加锁、提前解锁、转移所有权(condition_variable 需要它)。
std::unique_lock<std::mutex> lk(mtx, std::defer_lock);
// ...
lk.lock();
std::scoped_lock(C++17) :同时锁多个互斥量,避免死锁。
std::scoped_lock lk(mtx1, mtx2); // 内部用死锁避免算法(try-and-back-off)
这是多锁场景的标准做法。手写 lock(m1); lock(m2); 在两个线程里以不同顺序加锁就会死锁。
死锁的四个必要条件 :互斥、持有并等待、不可剥夺、循环等待。破坏任意一个即可。工程上最常用的是"统一加锁顺序"和 scoped_lock。
std::recursive_mutex:同一线程可重复加锁。它通常是设计有问题的信号------如果一个函数需要递归地持有锁,说明临界区划分不清。而且它的性能比普通 mutex 差,且掩盖了问题。
std::shared_mutex(C++17):读写锁,多读并发、写独占。
std::shared_mutex rw;
{
std::shared_lock lk(rw); // 读锁,可并发
read_data();
}
{
std::unique_lock lk(rw); // 写锁,独占
write_data();
}
读写锁在"读多写少"时有效。但读锁本身也有原子操作开销,如果临界区极短(比如只读一个 int),读写锁可能比普通 mutex 更慢。测量后再决定。
7.4 条件变量
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
bool done = false;
// 生产者
{
std::lock_guard<std::mutex> lk(mtx);
q.push(1);
}
cv.notify_one();
// 消费者
std::unique_lock<std::mutex> lk(mtx);
cv.wait(lk, []{ return !q.empty() || done; }); // 带谓词的 wait,防虚假唤醒
if (!q.empty()) {
int x = q.front(); q.pop();
}
三个必须记住的点:
-
wait必须带谓词,或在循环里 wait。条件变量存在"虚假唤醒"(spurious wakeup),可能在没有任何 notify 的情况下返回。 -
wait释放锁并原子地进入等待 ,醒来后重新加锁。所以它必须接受unique_lock。 -
修改共享状态和 notify 的顺序:通常在持锁时修改,释放锁后 notify(也可以持锁 notify,但会导致"刚 notify 就被抢锁"的惊群)。
{
std::lock_guardstd::mutex lk(mtx);
q.push(x);
} // 先释放锁
cv.notify_one(); // 再通知
notify_one vs notify_all:
notify_one唤醒一个等待者,适合"一个任务只需要一个消费者"。notify_all唤醒全部,适合"状态变化影响所有等待者"(如 shutdown)。
std::condition_variable_any 可以配合任意锁类型(包括 shared_lock),但性能略差。
7.5 原子操作与内存序
std::atomic<T> 提供无数据竞争的基本类型操作。
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);
counter++; // 等价于 seq_cst 的读改写
counter.load(); counter.store(5);
counter.compare_exchange_strong(expected, desired);
六个内存序:
| 内存序 | 语义 | 开销 |
|---|---|---|
memory_order_relaxed |
只保证原子性,不保证顺序 | 最低 |
memory_order_acquire |
读操作,之后的读写不能重排到它之前 | 中 |
memory_order_release |
写操作,之前的读写不能重排到它之后 | 中 |
memory_order_acq_rel |
读改写,兼具两者 | 中 |
memory_order_seq_cst |
全局一致顺序 | 最高 |
memory_order_consume |
依赖序(实践中很少用,多数实现等同于 acquire) | --- |
acquire/release 的经典用法(发布-订阅模式):
std::atomic<Data*> ptr{nullptr};
Data* data = nullptr;
// 线程 A:初始化数据并发布
data = new Data(...); // 1. 写数据
ptr.store(data, std::memory_order_release); // 2. 发布
// 线程 B:读取指针并使用数据
Data* p = ptr.load(std::memory_order_acquire); // 3. 获取
if (p) use(p->field); // 4. 保证能看到 1 的写入
release 保证"之前的写"对 acquire 之后的读可见。这就是"无锁发布"的基础。
seq_cst 是默认值 ,也是最安全的。先写对,再优化 :绝大多数场景用默认的 seq_cst 就够了,只有在 profile 证明原子操作是瓶颈时,才考虑降级到 acquire/release。
std::atomic 的限制:
-
不是所有类型都支持(需要 lock-free 或者编译器插入锁)。
-
std::atomic<double>的fetch_add在 C++20 前不支持(只有整型和指针)。 -
std::atomic<T>::is_always_lock_free可用于编译期检查。static_assert(std::atomic
::is_always_lock_free);
ABA 问题:无锁栈/队列中,指针从 A 变成 B 又变回 A,CAS 会误判"没变过"。解决办法是给指针加版本号(用 128 位 CAS,或把指针和计数打包进一个 64 位整数)。
7.6 future / promise / async
#include <future>
// async:启动一个异步任务
auto fut = std::async(std::launch::async, []{ return heavy_compute(); });
int result = fut.get(); // 阻塞等待
// promise/future:手动设置结果
std::promise<int> prom;
std::future<int> f = prom.get_future();
std::thread t([&]{ prom.set_value(42); });
std::cout << f.get();
std::async 的启动策略:
std::launch::async:一定在新线程执行。std::launch::deferred:延迟到get()时在调用线程执行。std::launch::async | std::launch::deferred(默认):由实现决定。这可能导致"看起来异步实际同步" ,所以工程上建议显式指定async。
异常传播 :future::get() 会重新抛出任务里的异常,这是 std::async 相对裸线程的一大优势。
auto f = std::async(std::launch::async, []{ throw std::runtime_error("x"); });
try { f.get(); }
catch (const std::exception& e) { /* 捕获 */ }
future 的局限 :没有"等待多个 future"(没有 when_all),没有取消机制,future 的析构会阻塞(如果任务还在跑且是 async 策略)。所以 C++ 的 future 在实践中不如其他语言的 Promise 好用,很多项目改用协程或第三方库。
7.7 线程池
线程池是并发编程的"必备基础设施":预创建 N 个线程,从任务队列里取任务执行,避免频繁创建/销毁线程。
class ThreadPool {
public:
explicit ThreadPool(size_t n) {
for (size_t i = 0; i < n; ++i) {
workers_.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lk(mtx_);
cv_.wait(lk, [this]{ return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task();
}
});
}
}
template <typename F>
void enqueue(F&& f) {
{
std::lock_guard<std::mutex> lk(mtx_);
tasks_.emplace(std::forward<F>(f));
}
cv_.notify_one();
}
~ThreadPool() {
{
std::lock_guard<std::mutex> lk(mtx_);
stop_ = true;
}
cv_.notify_all();
for (auto& t : workers_) if (t.joinable()) t.join();
}
private:
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex mtx_;
std::condition_variable cv_;
bool stop_ = false;
};
这个实现有三个值得注意的设计:
- 析构时先置 stop 再 notify_all,让所有线程都能看到退出信号。
- 任务用
std::function<void()>,代价是类型擦除(见 4.8 节)。如果要支持返回值和future,需要std::packaged_task。 enqueue接受任意可调用对象,模板 + 完美转发避免多余拷贝。
线程池的常见改进:
- 支持优先级队列(
priority_queue替代queue)。 - 支持任务返回值(
std::future)。 - 支持动态扩容/缩容。
- 支持"工作窃取"(每个线程有自己的队列,空闲时从别人队列偷任务),减少锁争用。
- 支持优雅关闭(等待队列清空 vs 立即丢弃)。
7.8 无锁队列与生产者-消费者
一个基于环形缓冲区的单生产者单消费者(SPSC)队列:
template <typename T, size_t N>
class SpscQueue {
static_assert((N & (N - 1)) == 0, "N must be power of 2");
public:
bool push(const T& v) {
size_t head = head_.load(std::memory_order_relaxed);
size_t next = (head + 1) & (N - 1);
if (next == tail_.load(std::memory_order_acquire)) return false; // 满
buf_[head] = v;
head_.store(next, std::memory_order_release);
return true;
}
bool pop(T& v) {
size_t tail = tail_.load(std::memory_order_relaxed);
if (tail == head_.load(std::memory_order_acquire)) return false; // 空
v = buf_[tail];
tail_.store((tail + 1) & (N - 1), std::memory_order_release);
return true;
}
private:
std::atomic<size_t> head_{0}, tail_{0};
T buf_[N];
};
关键点:
- 容量用 2 的幂,用位与代替取模,快得多。
- head 只被生产者改,tail 只被消费者改 ,所以各自
relaxed读写自己的,acquire/release读对方的。 acquire/release建立 happens-before 关系,保证数据写入先于 head 更新被看到。
这个队列只支持 SPSC。多生产者需要 CAS 循环,复杂度和性能都高一个量级。工程上更常见的是"每线程一个 SPSC 队列 + 消费者轮询"。
7.9 并发的工程实践
原则一:优先用"不可变数据"和"消息传递",而不是共享内存 + 锁。
// 差:大量共享状态
struct State { std::mutex m; std::map<...> data; };
// 好:每个线程有自己的数据,最后合并
std::vector<Result> results(n);
std::for_each(std::execution::par, ...); // 每个任务写自己的 results[i]
原则二:锁的粒度要"足够小但不过小"。太小意味着频繁加解锁,太大意味着串行。
原则三:避免在持锁时做 I/O、调用回调、分配内存。这些操作可能耗时很久或重入。
原则四:用工具验证,而不是靠推理。TSan 能发现你肉眼看不到的竞争。
原则五:优先使用标准库的并行算法 (C++17 的 std::execution::par),它们经过充分测试。
原则六:关注"可取消性"。很多并发 bug 出现在"程序要退出时",线程还在跑、资源已释放。
7.10 本章练习
- 用
std::thread实现一个"多线程求和":把数组分成 N 段,每段一个线程,最后合并,与单线程对比加速比。 - 实现一个有界阻塞队列(容量满时 push 阻塞,空时 pop 阻塞),用条件变量。
- 用
std::atomic实现自旋锁,并与std::mutex对比在低争用和高争用下的性能。 - 用 TSan 跑一个"两个线程各自 ++ 同一个非原子 int"的程序,观察报告。
- 实现一个简单的线程池,支持提交任务并返回
std::future<int>。
第八章 性能优化:从缓存到编译器
8.1 优化的第一原则:先测量
不要猜,去测。程序里 90% 的时间通常花在 10% 的代码上。
测量工具:
| 工具 | 用途 | 命令 |
|---|---|---|
perf |
CPU 采样、火焰图 | perf record -g ./app |
valgrind --tool=callgrind |
指令级计数 | callgrind_annotate |
gprof |
函数级耗时 | 需要 -pg |
time / chrono |
粗粒度计时 | --- |
Google Benchmark |
微基准测试 | 库 |
quick-bench.com |
在线对比 | 网站 |
火焰图 的生成流程:
perf record -F 99 -g ./app
perf script > out.perf
git clone https://github.com/brendangregg/FlameGraph
./FlameGraph/stackcollapse-perf.pl out.perf > out.folded
./FlameGraph/flamegraph.pl out.folded > flame.svg
火焰图的读法:横轴是采样占比(越宽越耗时),纵轴是调用栈深度。找"宽而扁"的块------那是热点函数本身;"窄而深"的往往是调用链开销。
微基准测试的陷阱:
- 编译器可能把整个计算优化掉(结果没被使用)。用
benchmark::DoNotOptimize或asm volatile阻止。 - 循环太小,测的是启动开销。
- 缓存预热不充分,第一次跑是冷的。
- CPU 频率变化(降频/睿频)导致结果不稳定。
Google Benchmark 的用法:
#include <benchmark/benchmark.h>
static void BM_VectorPush(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> v;
v.reserve(state.range(0));
for (int i = 0; i < state.range(0); ++i) v.push_back(i);
}
}
BENCHMARK(BM_VectorPush)->Arg(1000)->Arg(10000);
BENCHMARK_MAIN();
8.2 编译器优化
-O0 → -O2 的差距通常是 5--20 倍。这不是"优化选项",而是"能不能跑得动"的分界线。
主要优化手段:
- 内联:把小函数展开到调用点,消除调用开销,让后续优化看到上下文。
- 常量传播:把编译期已知的值直接替换。
- 死代码消除:删掉不可达代码和无用计算。
- 循环不变量外提:把循环里不变的计算移到循环外。
- 向量化(SIMD):把标量运算合成 SSE/AVX 指令,一次处理 4/8 个 float。
- 循环展开:减少分支和循环变量开销。
查看优化结果:
g++ -O2 -S -fverbose-asm main.cpp -o main.s
# 或者用 godbolt.org 在线对比
-O3 与 -Ofast :-O3 更激进地向量化和展开,-Ofast 还会违反 IEEE 754(如开启 -ffast-math,允许重排浮点运算、把 x/x 优化为 1)。浮点计算对精度敏感时绝不能用 -ffast-math。
LTO(链接时优化):
g++ -flto -O2 a.cpp b.cpp -o app
让编译器看到所有翻译单元,可以跨文件内联。对大项目能带来 5--15% 提升,代价是链接时间变长、内存占用高。
PGO(Profile-Guided Optimization):
# 1. 带插桩编译
g++ -fprofile-generate -O2 *.cpp -o app_prof
# 2. 跑代表性负载
./app_prof < typical_input
# 3. 用 profile 重新编译
g++ -fprofile-use -O2 *.cpp -o app
PGO 让编译器知道"哪个分支更可能走"、"哪个函数更热",从而做更合理的内联和布局。典型收益 5--20%,尤其对分支密集的代码。
BOLT(LLVM 的后链接优化)可以进一步优化代码布局,收益 5--10%,但工具链复杂。
8.3 数据结构与缓存
"数据布局比算法更重要" 这句话在现代硬件上近乎真理。
案例:把 AoS 改成 SoA
// AoS(Array of Structs)
struct Particle { float x, y, z, vx, vy, vz; };
std::vector<Particle> particles;
for (auto& p : particles) { p.x += p.vx; } // 每次读 24 字节,只用 8 字节
// SoA(Struct of Arrays)
struct Particles {
std::vector<float> x, y, z, vx, vy, vz;
};
for (size_t i = 0; i < n; ++i) x[i] += vx[i]; // 连续访问,缓存利用率 100%
SoA 让"只更新位置"的循环的缓存利用率从 1/3 提升到 100%,并且可以向量化。在粒子系统、物理引擎里这是标准做法。
案例:用"索引"代替"指针"
struct Node { int value; Node* left; Node* right; }; // 每个节点 24 字节,分散分配
struct Tree { std::vector<int> value, left, right; }; // 数组存储,索引代替指针
指针版本的每次 left 访问都是一次潜在的 cache miss;数组版本是连续内存,预取友好,而且省掉了 8 字节指针。
案例:小类型打包
struct Flags { bool a, b, c, d, e, f, g, h; }; // 8 字节
struct Packed { uint8_t bits; }; // 1 字节,用位操作访问
对百万级对象,这能省 7 MB 并将缓存命中率提升数倍。
分支预测:现代 CPU 有 15--20 级流水线,一次分支预测失败要清空流水线,代价约 15--20 周期。
// 分支不可预测时,用"无分支"写法
// 差
if (x < 0) y = -x; else y = x;
// 好
y = std::abs(x); // 编译器会生成 cmov 或位运算
// 更复杂的情况:用查表或多路选择
C++20 的 [[likely]] / [[unlikely]] 给编译器提示:
if (error) [[unlikely]] { handle_error(); }
但注意:现代编译器对分支的预测已经很好,手动提示的收益通常很小,除非你在写极端热路径。
8.4 减少内存分配
分配是昂贵的 :一次 malloc 在大对象上可能是微秒级(系统调用 + 页表操作),即使命中 tcache 也要 20--50 ns。
手段一:预留容量 (reserve)。
手段二:对象池 / 内存池。对"频繁创建销毁同类型对象"的场景,用池化避免反复分配。
手段三:小对象优化(SBO)。把常见的小数据存在对象内部,避免堆分配。
class SmallVector {
static constexpr size_t INLINE_CAP = 4;
int inline_buf_[INLINE_CAP];
int* data_ = inline_buf_;
size_t size_ = 0, cap_ = INLINE_CAP;
// size <= 4 时零分配
};
std::string 的 SSO、std::function 的 SBO 都是这个思路。
手段四:std::pmr 的 monotonic 分配器(见 5.4 节),适合"请求级"的批量分配。
手段五:避免不必要的临时对象。
// 差:每次循环构造临时 string
for (const auto& item : items) {
log(std::string("item: ") + item.name);
}
// 好:复用缓冲区,或用 string_view 拼接
std::string buf;
buf.reserve(64);
for (const auto& item : items) {
buf.clear();
buf.append("item: ").append(item.name);
log(buf);
}
8.5 字符串处理的性能要点
字符串是 C++ 程序里最常见的性能瓶颈之一。
- 避免
substr循环 :每次substr都是 O(n) 拷贝。用string_view。 - 避免
std::string作为 map 的键:哈希要遍历整个字符串,比较也是。可以考虑"字符串驻留"(interning)或前缀哈希。 - 避免
+=拼接大量字符串 :每次可能扩容。用reserve或std::format。 std::to_string不便宜 :它内部用snprintf或ostringstream风格。热路径上可以考虑std::format或第三方库(fmt、itoa实现)。strlen在循环条件里 :for (i = 0; i < strlen(s); ++i)是 O(n²),先存长度。
小技巧:用 std::char_traits<char>::length 或直接缓存长度。
8.6 编译期计算换运行期
constexpr 查表:
constexpr std::array<int, 256> make_table() {
std::array<int, 256> t{};
for (int i = 0; i < 256; ++i) t[i] = i * i % 251;
return t;
}
constexpr auto TABLE = make_table();
int f(int x) { return TABLE[x & 0xFF]; } // 运行时零计算
if constexpr 消除分支:模板参数决定的分支可以在编译期裁掉,避免运行时的判断。
模板元编程的"预计算":把能在编译期算的都算掉,运行时只做查表。
8.7 性能优化的反模式
- 过早优化:在没有 profile 的情况下"我觉得这里慢",改了之后可能更慢。
- 用
-O3掩盖算法问题 :O(n²) 的循环不会因为-O3变成 O(n)。 - 过度内联 :
inline所有东西会让代码膨胀、指令缓存失效。让编译器决定。 - 迷信
move:对小类型(int、指针、pair<int,int>)来说,移动和拷贝一样快,加std::move反而增加代码噪音。 - 滥用
shared_ptr:原子引用计数是热路径的杀手。 - 用
map做小范围整数查找 :直接用数组/vector做直接寻址表快一个数量级。 - 在循环里
new:把分配提到循环外。
8.8 一个完整的优化案例
需求:统计 1 亿行日志里各 URL 的访问次数。
版本 1:unordered_map<string, int> 直接统计
std::unordered_map<std::string, int> counts;
for (auto& line : lines) {
std::string url = extract_url(line); // 每次分配
++counts[url]; // 哈希 + 可能的 rehash
}
问题:每行一次 std::string 分配、一次哈希、一次可能的 rehash。
版本 2:先解析出 string_view,用自定义哈希
struct SvHash {
size_t operator()(std::string_view s) const {
size_t h = 1469598103934665603ull;
for (char c : s) h = (h ^ c) * 1099511628211ull;
return h;
}
};
std::unordered_map<std::string_view, int, SvHash> counts;
counts.reserve(1 << 20);
for (auto& line : lines) {
++counts[extract_url_view(line)]; // 零分配
}
提升主要来自"零分配"和"预分配桶"。
版本 3:批量 + 本地聚合
每个线程维护自己的小表,处理完一批再合并到全局表,减少锁争用和全局表的 rehash。
版本 4:如果 URL 集合很小,用"直接映射"
先构建 URL → id 的映射,之后只用 vector<int> 计数。
每一版都应该用实际数据测量,而不是凭直觉。经验上的收益排序通常是:减少分配 > 改进数据结构 > 并行化 > 编译器优化。
8.9 本章练习
- 用
perf找出一个程序的热点函数,并用火焰图可视化。 - 对比
vector<int>、list<int>、vector<unique_ptr<int>>的遍历耗时,解释差异。 - 把一个 AoS 结构改成 SoA,测量粒子更新循环的加速比。
- 用 Google Benchmark 对比
std::string拼接(+=vsreserve++=vsformat)的耗时。 - 用 PGO 编译一个分支密集的程序,对比
-O2与-O2 + PGO的性能。
第九章 工程化实践
9.1 项目结构与目录约定
一个好的 C++ 项目结构应该让"新人能在 10 分钟内找到他想改的东西"。
project/
├── CMakeLists.txt
├── cmake/ # 自定义 cmake 模块
├── include/project/ # 对外公开的头文件
│ ├── api.h
│ └── types.h
├── src/ # 实现文件 + 私有头文件
│ ├── internal/
│ └── api.cpp
├── tests/ # 单元测试
├── benchmark/ # 性能基准
├── examples/ # 使用示例
├── third_party/ # 第三方依赖
└── docs/
关键约定:
include/下的头文件是"公开 API" ,src/下的头文件是"内部实现"。这样外部使用者可以直接#include <project/api.h>。- 头文件路径带项目名前缀 (
project/api.h),避免多个库的api.h冲突。 - 测试与被测代码分离 ,但测试文件可以访问
src/internal/(通过 CMake 的 include 路径)。
9.2 CMake 基础到进阶
最小可用:
cmake_minimum_required(VERSION 3.20)
project(myapp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) # 禁用 gnu++20,用严格的 c++20
add_library(mylib src/api.cpp)
target_include_directories(mylib PUBLIC include)
target_compile_options(mylib PRIVATE -Wall -Wextra)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE mylib)
PUBLIC / PRIVATE / INTERFACE 的区别:
-
PRIVATE:只用于本目标的编译。 -
PUBLIC:本目标用,且链接本目标的人也会继承。 -
INTERFACE:本目标不用,但使用者要用(如 header-only 库)。target_include_directories(mylib
PUBLIC include # 使用者需要
PRIVATE src/internal) # 只有自己需要
现代 CMake 的核心思想:不要用全局变量(include_directories、CMAKE_CXX_FLAGS),而要用 target 属性。 这样依赖关系是显式的,多目标之间不会互相污染。
查找依赖:
find_package(Threads REQUIRED)
target_link_libraries(app PRIVATE Threads::Threads)
find_package(fmt CONFIG REQUIRED)
target_link_libraries(app PRIVATE fmt::fmt)
fmt::fmt 这样的"命名空间目标"(imported target)会带上正确的 include 路径和编译选项,比自己拼 -I / -L 可靠得多。
FetchContent(从网络拉取依赖):
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0
)
FetchContent_MakeAvailable(googletest)
这比 git submodule 简单(不用手动初始化),但每次配置都要联网。企业内通常用私有镜像或 vendor 目录。
安装与导出:
include(GNUInstallDirs)
install(TARGETS mylib
EXPORT mylibTargets
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)
install(EXPORT mylibTargets
FILE mylibTargets.cmake
NAMESPACE mylib::
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib
)
这样别人 find_package(mylib) 就能拿到 mylib::mylib 目标。
常用命令:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j 8
ctest --test-dir build --output-on-failure
用 -S / -B 分离源码与构建目录,是 CMake 3.13 之后的标准用法。
9.3 头文件设计:PImpl 与前置声明
编译时间的敌人是"头文件传染" :a.h 包含 b.h,b.h 包含 c.h...... 最后改一个底层头文件,整个项目重编。
PImpl(Pointer to Implementation) 是切断依赖的标准手法:
// widget.h ------ 对外只暴露必要的东西
class Widget {
public:
Widget();
~Widget(); // 必须声明,并在 .cpp 中定义
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
void draw();
private:
struct Impl;
std::unique_ptr<Impl> impl_; // 只依赖 unique_ptr,不依赖 Impl 的定义
};
// widget.cpp
struct Widget::Impl {
std::vector<Detail> details; // 所有实现细节都在这里
};
Widget::Widget() : impl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // 必须在能看到 Impl 完整定义的地方定义
代价:一次间接访问(impl_ 解引用),以及可能的堆分配。收益:编译时间大幅下降、ABI 稳定(改实现不需要重新编译使用者)。
前置声明 vs 包含:
// 能用前置声明时
class Widget; // 前置声明
void use(Widget* w); // 参数是指针/引用,够用
// 必须包含时
#include "widget.h"
class Panel { Widget w_; }; // 按值成员,需要完整定义
判断规则:只需要"名字"时用前置声明,需要"大小/成员/继承/内联调用"时用包含。
export 宏(跨 DLL 边界时必须):
#ifdef _WIN32
#ifdef MYLIB_EXPORTS
#define MYLIB_API __declspec(dllexport)
#else
#define MYLIB_API __declspec(dllimport)
#endif
#else
#define MYLIB_API __attribute__((visibility("default")))
#endif
class MYLIB_API Widget { ... };
Linux 上默认所有符号都导出,加 -fvisibility=hidden 可以只导出标记过的符号,减小动态符号表、加快加载、避免符号冲突。
9.4 单元测试:GoogleTest
#include <gtest/gtest.h>
#include "calculator.h"
TEST(CalculatorTest, AddPositive) {
EXPECT_EQ(add(1, 2), 3);
}
TEST(CalculatorTest, AddNegative) {
ASSERT_EQ(add(-1, -2), -3);
}
TEST(CalculatorTest, DivideByZeroThrows) {
EXPECT_THROW(divide(1, 0), std::invalid_argument);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
EXPECT_* vs ASSERT_* :EXPECT 失败继续执行,ASSERT 失败立即返回。对"后续代码依赖这个前提"的检查用 ASSERT。
测试夹具(Fixture):
class DatabaseTest : public ::testing::Test {
protected:
void SetUp() override { db_ = std::make_unique<Database>(":memory:"); }
void TearDown() override { db_.reset(); }
std::unique_ptr<Database> db_;
};
TEST_F(DatabaseTest, InsertWorks) {
db_->insert("key", "value");
EXPECT_EQ(db_->get("key"), "value");
}
测试的原则:
- 一个测试只验证一件事,失败时能立刻定位。
- 测试要独立,不依赖执行顺序、不共享全局状态。
- 测试名要说清楚意图 (
AddNegative_ReturnsNegativeSum比Test2好)。 - 覆盖边界:空输入、单元素、最大值、溢出、异常路径。
- 不要测试实现细节:测"行为"而不是"内部调用了几次"。
测试覆盖率 :gcov / llvm-cov 可以统计行覆盖、分支覆盖。但覆盖率不等于质量------100% 覆盖也可能全是无效断言。
9.5 静态分析与动态检查
编译器警告 :-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion。-Wconversion 会暴露大量隐式窄化,新项目建议开,老项目可能一开就是几千条。
clang-tidy:
clang-tidy src/*.cpp -checks='-*,modernize-*,bugprone-*,performance-*' -- -std=c++17 -Iinclude
常用检查组:
modernize-*:把 C++03 写法改成现代写法(NULL→nullptr、typedef→using)。bugprone-*:容易出错的模式(悬垂引用、错误的move)。performance-*:性能问题(不必要的拷贝、std::move阻止优化)。readability-*:可读性。
cppcheck:轻量级静态分析,不需要编译数据库,适合 CI 快速检查。
Sanitizer(见 5.8 节):ASan、UBSan、TSan、MSan(内存未初始化检测,需要完整编译整个程序)。
fuzzing:对"解析不可信输入"的代码(JSON、协议、文件格式),用 libFuzzer 做模糊测试,能发现 ASan 检测不到的边界问题。
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
parse_json(data, size); // 崩溃/UB 会被捕获并记录
return 0;
}
9.6 错误处理:异常 vs 错误码 vs Expected
异常:
try {
auto data = load_config("config.json");
} catch (const FileNotFound& e) {
// 处理
} catch (const std::exception& e) {
// 兜底
}
优点:不污染正常路径的返回值、自动传播、与 RAII 天然配合。缺点:运行时开销(异常表)、二进制体积、跨 ABI 问题、部分嵌入式/游戏环境禁用异常。
错误码:
enum class Error { Ok, NotFound, PermissionDenied, IOError };
Error load_config(const std::string& path, Config& out);
优点:零开销、强制调用方处理(配合 [[nodiscard]])。缺点:容易被忽略、无法携带上下文、错误传播要逐层写 if。
std::expected<T, E>(C++23):
std::expected<Config, Error> load_config(const std::string& path);
auto result = load_config("config.json");
if (!result) {
log(result.error());
return;
}
Config cfg = *result;
它把"值或错误"放进一个类型,既强制检查,又保留了异常式的表达力。C++23 之前可以用 tl::expected 或 boost::outcome。
工程选择:
- 通用服务/桌面应用:异常 + RAII(最省心)。
- 实时系统/游戏/嵌入式:错误码或
expected(避免异常的不确定性)。 - 库作者:不要用异常穿过 C 接口,在边界处转成错误码。
9.7 日志与可观测性
日志的三要素:级别、时间、上下文。
enum class Level { Debug, Info, Warn, Error };
class Logger {
public:
static Logger& instance();
template <typename... Args>
void log(Level lv, std::string_view fmt, Args&&... args) {
if (lv < level_) return;
std::string msg = std::vformat(fmt, std::make_format_args(args...));
write(lv, msg);
}
private:
Level level_ = Level::Info;
};
工程要点:
- 热路径上的日志要先判断级别再格式化 。
log(Debug, "value={}", heavy_to_string(x))即使不输出也会构造参数。 - 日志要能带上下文(请求 id、线程 id、用户 id),否则线上排查无从下手。
- 异步日志:写日志到队列,后台线程落盘,避免阻塞业务线程。
- 日志轮转:按大小或时间切分,避免单文件无限增长。
- 不要记录敏感信息(密码、token、身份证号)。
性能 :std::format 每次都要分配字符串。追求极致时用"二进制日志 + 离线解析"(如 protobuf 日志、spdlog 的 binary sink)。
9.8 常见设计模式的 C++ 实现
单例(线程安全,C++11 起):
class Singleton {
public:
static Singleton& instance() {
static Singleton inst; // 局部静态变量的初始化是线程安全的(C++11 magic statics)
return inst;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() = default;
};
这比双重检查锁定(DCLP)简洁得多,且没有内存序问题。但单例本身是"全局状态",测试时难以隔离,能不用就不用。
工厂:
class ShapeFactory {
public:
static std::unique_ptr<Shape> create(ShapeType t) {
switch (t) {
case ShapeType::Circle: return std::make_unique<Circle>();
case ShapeType::Square: return std::make_unique<Square>();
}
return nullptr;
}
};
现代替代:注册表模式(用 std::function 或函数指针表注册创建函数),避免 switch 的维护成本。
观察者:
class Subject {
std::vector<std::function<void(const Event&)>> observers_;
public:
void subscribe(std::function<void(const Event&)> cb) { observers_.push_back(std::move(cb)); }
void notify(const Event& e) { for (auto& cb : observers_) cb(e); }
};
注意生命周期 :观察者如果捕获了 this,在销毁前必须取消订阅,否则回调会访问已销毁的对象。这是 C++ 里最常见的设计模式事故。
策略:
class Sorter {
public:
using Compare = std::function<bool(int, int)>;
explicit Sorter(Compare cmp) : cmp_(std::move(cmp)) {}
void sort(std::vector<int>& v) { std::sort(v.begin(), v.end(), cmp_); }
private:
Compare cmp_;
};
策略模式的 C++ 优化:用模板参数代替虚函数,编译期绑定,零开销。
RAII 守卫 (见 2.7 节)是最"C++"的模式,很多其他语言需要 try/finally 的场景,C++ 一个对象就解决了。
9.9 代码规范与评审
命名:
- 类型:
PascalCase(HttpClient) - 函数/变量:
snake_case或camelCase(团队统一即可) - 成员变量:
trailing_underscore_(Google 风格)或m_前缀 - 常量:
kConstantName或UPPER_SNAKE - 宏:
UPPER_SNAKE(且尽量不用)
其他约定:
- 函数不超过 40 行,参数不超过 5 个。
- 单个文件不超过 500 行。
- 不写"注释掉的代码",用版本控制。
- 注释解释"为什么",而不是"是什么"。
const能加就加。- 优先用
auto只在类型明显时(auto it = m.find(k)可以,auto x = compute()不行)。
评审关注点(按优先级):
- 正确性:边界、异常、资源泄漏、并发。
- 可维护性:命名、结构、重复代码。
- 性能:是否有明显的不必要拷贝/分配。
- 可测试性:是否容易写测试。
- 风格:最后再看。
9.10 本章练习
- 用 CMake 搭一个"库 + 可执行 + 测试"的三目标项目,导出
find_package支持。 - 把一个"头文件包含头文件包含头文件"的类改成 PImpl,测量全量编译时间的变化。
- 用 GoogleTest 为一个"字符串分割函数"写 8 个测试,覆盖空串、单字符、连续分隔符、首尾分隔符。
- 用 clang-tidy 跑一遍你的项目,处理所有
performance-*警告。 - 实现一个线程安全的单例,并解释为什么"局部静态变量"在 C++11 后是线程安全的。
第十章 实战项目
理论只有落到代码里才算数。这一章给出五个递进式的小项目,每个都对应前面某一组知识,并且都可以在一天内完成。
10.1 项目一:手写一个简化版 unique_ptr
目标:理解所有权、移动语义、noexcept、删除器。
template <typename T>
class UniquePtr {
public:
constexpr UniquePtr() noexcept = default;
constexpr UniquePtr(std::nullptr_t) noexcept {}
explicit UniquePtr(T* p) noexcept : ptr_(p) {}
// 禁止拷贝
UniquePtr(const UniquePtr&) = delete;
UniquePtr& operator=(const UniquePtr&) = delete;
// 移动
UniquePtr(UniquePtr&& o) noexcept : ptr_(std::exchange(o.ptr_, nullptr)) {}
UniquePtr& operator=(UniquePtr&& o) noexcept {
if (this != &o) {
reset(o.release());
}
return *this;
}
~UniquePtr() { delete ptr_; }
T& operator*() const { return *ptr_; }
T* operator->() const noexcept { return ptr_; }
T* get() const noexcept { return ptr_; }
explicit operator bool() const noexcept { return ptr_ != nullptr; }
T* release() noexcept { return std::exchange(ptr_, nullptr); }
void reset(T* p = nullptr) noexcept {
T* old = std::exchange(ptr_, p);
delete old;
}
void swap(UniquePtr& o) noexcept { std::swap(ptr_, o.ptr_); }
private:
T* ptr_ = nullptr;
};
template <typename T, typename... Args>
UniquePtr<T> makeUnique(Args&&... args) {
return UniquePtr<T>(new T(std::forward<Args>(args)...));
}
练习点:
- 为什么移动构造要标
noexcept?去掉后std::vector<UniquePtr<T>>扩容会怎样? std::exchange的作用是什么?手写会有什么问题?- 实现数组特化
UniquePtr<T[]>,注意要用delete[]。
进阶:加入自定义删除器,用空基类优化(EBO)让"无状态删除器"不占空间。
template <typename T, typename Deleter = std::default_delete<T>>
class UniquePtr : private Deleter { // 私有继承实现 EBO
T* ptr_;
};
10.2 项目二:写一个简易 vector
目标:理解内存管理、异常安全、迭代器、emplace_back。
template <typename T>
class Vector {
public:
Vector() = default;
explicit Vector(size_t n) : data_(alloc(n)), size_(n), cap_(n) {
std::uninitialized_value_construct_n(data_, n);
}
Vector(const Vector& o) : data_(alloc(o.size_)), size_(o.size_), cap_(o.size_) {
std::uninitialized_copy(o.data_, o.data_ + o.size_, data_);
}
Vector(Vector&& o) noexcept
: data_(std::exchange(o.data_, nullptr)),
size_(std::exchange(o.size_, 0)),
cap_(std::exchange(o.cap_, 0)) {}
~Vector() {
std::destroy_n(data_, size_);
::operator delete(data_);
}
void push_back(const T& v) { emplace_back(v); }
void push_back(T&& v) { emplace_back(std::move(v)); }
template <typename... Args>
T& emplace_back(Args&&... args) {
if (size_ == cap_) grow();
::new (static_cast<void*>(data_ + size_)) T(std::forward<Args>(args)...);
return data_[size_++];
}
void pop_back() {
std::destroy_at(data_ + --size_);
}
T& operator[](size_t i) { return data_[i]; }
const T& operator[](size_t i) const { return data_[i]; }
size_t size() const noexcept { return size_; }
T* begin() noexcept { return data_; }
T* end() noexcept { return data_ + size_; }
private:
static T* alloc(size_t n) {
return static_cast<T*>(::operator new(n * sizeof(T), std::align_val_t{alignof(T)}));
}
void grow() {
size_t new_cap = cap_ ? cap_ * 2 : 1;
T* new_data = alloc(new_cap);
// 关键:用 noexcept 的移动构造,否则异常时无法回滚
if constexpr (std::is_nothrow_move_constructible_v<T>) {
std::uninitialized_move(data_, data_ + size_, new_data);
} else {
std::uninitialized_copy(data_, data_ + size_, new_data);
}
std::destroy_n(data_, size_);
::operator delete(data_);
data_ = new_data;
cap_ = new_cap;
}
T* data_ = nullptr;
size_t size_ = 0;
size_t cap_ = 0;
};
核心难点:
- 必须用
operator new而不是new T[]:new T[n]会默认构造 n 个对象,而 vector 需要"容量有但元素未构造"的状态。 std::uninitialized_*系列是"在未初始化内存上构造"的标准工具。if constexpr选择移动还是拷贝 :这正是noexcept影响性能的实证。
验证 :用 T = std::string(可移动)和 T = 一个拷贝会抛异常的类型 各跑一遍,确保异常时不泄漏。
10.3 项目三:JSON 解析器
目标:递归下降解析、std::variant、错误处理、字符串处理。
struct JsonValue;
using JsonObject = std::map<std::string, JsonValue>;
using JsonArray = std::vector<JsonValue>;
struct JsonValue {
std::variant<std::nullptr_t, bool, double, std::string, JsonArray, JsonObject> data;
bool is_number() const { return std::holds_alternative<double>(data); }
double as_number() const { return std::get<double>(data); }
const JsonObject& as_object() const { return std::get<JsonObject>(data); }
};
解析器主体:
class Parser {
public:
explicit Parser(std::string_view s) : s_(s) {}
std::expected<JsonValue, std::string> parse() {
auto v = parse_value();
skip_ws();
if (pos_ != s_.size()) return std::unexpected("trailing characters");
return v;
}
private:
void skip_ws() {
while (pos_ < s_.size() &&
(s_[pos_] == ' ' || s_[pos_] == '\n' || s_[pos_] == '\r' || s_[pos_] == '\t'))
++pos_;
}
std::expected<JsonValue, std::string> parse_value() {
skip_ws();
if (pos_ >= s_.size()) return std::unexpected("unexpected end");
switch (s_[pos_]) {
case '{': return parse_object();
case '[': return parse_array();
case '"': return parse_string();
case 't': return expect("true") ? JsonValue{true} : std::unexpected("bad literal");
case 'f': return expect("false") ? JsonValue{false} : std::unexpected("bad literal");
case 'n': return expect("null") ? JsonValue{nullptr} : std::unexpected("bad literal");
default: return parse_number();
}
}
std::expected<JsonValue, std::string> parse_object() {
++pos_; // '{'
JsonObject obj;
skip_ws();
if (pos_ < s_.size() && s_[pos_] == '}') { ++pos_; return JsonValue{obj}; }
while (true) {
skip_ws();
auto key = parse_string();
if (!key) return std::unexpected(key.error());
skip_ws();
if (pos_ >= s_.size() || s_[pos_] != ':') return std::unexpected("expected ':'");
++pos_;
auto val = parse_value();
if (!val) return std::unexpected(val.error());
obj.emplace(std::get<std::string>(key->data), std::move(*val));
skip_ws();
if (pos_ < s_.size() && s_[pos_] == ',') { ++pos_; continue; }
if (pos_ < s_.size() && s_[pos_] == '}') { ++pos_; break; }
return std::unexpected("expected ',' or '}'");
}
return JsonValue{std::move(obj)};
}
std::expected<JsonValue, std::string> parse_string() {
if (pos_ >= s_.size() || s_[pos_] != '"') return std::unexpected("expected '\"'");
++pos_;
std::string out;
while (pos_ < s_.size() && s_[pos_] != '"') {
char c = s_[pos_++];
if (c == '\\') {
if (pos_ >= s_.size()) return std::unexpected("bad escape");
char e = s_[pos_++];
switch (e) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'n': out += '\n'; break;
case 't': out += '\t'; break;
case 'r': out += '\r'; break;
default: return std::unexpected("unsupported escape");
}
} else {
out += c;
}
}
if (pos_ >= s_.size()) return std::unexpected("unterminated string");
++pos_; // 收尾的 '"'
return JsonValue{std::move(out)};
}
std::expected<JsonValue, std::string> parse_number() {
size_t start = pos_;
if (s_[pos_] == '-') ++pos_;
while (pos_ < s_.size() && std::isdigit(static_cast<unsigned char>(s_[pos_]))) ++pos_;
if (pos_ < s_.size() && s_[pos_] == '.') {
++pos_;
while (pos_ < s_.size() && std::isdigit(static_cast<unsigned char>(s_[pos_]))) ++pos_;
}
if (pos_ < s_.size() && (s_[pos_] == 'e' || s_[pos_] == 'E')) {
++pos_;
if (pos_ < s_.size() && (s_[pos_] == '+' || s_[pos_] == '-')) ++pos_;
while (pos_ < s_.size() && std::isdigit(static_cast<unsigned char>(s_[pos_]))) ++pos_;
}
if (pos_ == start) return std::unexpected("invalid number");
std::string_view num = s_.substr(start, pos_ - start);
double d = 0;
auto [ptr, ec] = std::from_chars(num.data(), num.data() + num.size(), d);
if (ec != std::errc{}) return std::unexpected("number out of range");
return JsonValue{d};
}
bool expect(std::string_view lit) {
if (s_.substr(pos_, lit.size()) != lit) return false;
pos_ += lit.size();
return true;
}
std::string_view s_;
size_t pos_ = 0;
};
这个项目覆盖的知识点:
std::variant做"封闭类型的联合"std::expected做错误传播std::string_view做零拷贝的词法扫描std::from_chars做无 locale 依赖的数值解析(比stod快且不抛异常)- 递归下降的解析思路
下一步挑战:
- 支持
\uXXXX转义(要考虑代理对)。 - 递归深度限制,避免恶意输入导致栈溢出。
- 改成"一次扫描 + 索引"的零拷贝版本(
string_view直接指向原缓冲区)。 - 用 libFuzzer 做模糊测试。
10.4 项目四:带 future 的线程池
在 7.7 节的基础上,让 enqueue 返回 std::future:
class ThreadPool {
public:
explicit ThreadPool(size_t n) { /* 同 7.7 */ }
template <typename F, typename... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>>
{
using R = std::invoke_result_t<F, Args...>;
auto task = std::make_shared<std::packaged_task<R()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...));
std::future<R> fut = task->get_future();
{
std::lock_guard<std::mutex> lk(mtx_);
if (stop_) throw std::runtime_error("enqueue on stopped pool");
tasks_.emplace([task]{ (*task)(); });
}
cv_.notify_one();
return fut;
}
};
为什么用 packaged_task + lambda :packaged_task 提供了 get_future(),但它本身不可拷贝(只能移动)。std::function 要求可拷贝,所以用 shared_ptr 包一层,lambda 捕获这个 shared_ptr(拷贝的是指针)。
C++23 的 std::move_only_function 可以直接存 packaged_task,不需要 shared_ptr。
注意 std::bind 的问题:它做的是"值拷贝",且对重载函数、模板函数支持不好。更现代的写法是用 lambda:
auto task = std::make_shared<std::packaged_task<R()>>(
[f = std::forward<F>(f), ...args = std::forward<Args>(args)]() mutable {
return f(args...);
});
进阶 :加入"任务优先级"(priority_queue + 自定义比较)和"优雅关闭"(等待队列为空再退出)。
10.5 项目五:epoll 回声服务器
目标:理解 I/O 多路复用、非阻塞 I/O、连接管理。这是"高性能网络编程"的入门。
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
int yes = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(8080);
bind(listen_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
listen(listen_fd, SOMAXCONN);
int epfd = epoll_create1(0);
epoll_event ev{};
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
epoll_event events[64];
char buf[4096];
while (true) {
int n = epoll_wait(epfd, events, 64, -1);
if (n < 0) continue;
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
// 接受所有待处理连接(循环直到 EAGAIN)
while (true) {
int client = accept4(listen_fd, nullptr, nullptr, SOCK_NONBLOCK);
if (client < 0) break;
epoll_event cev{};
cev.events = EPOLLIN | EPOLLET; // 边缘触发
cev.data.fd = client;
epoll_ctl(epfd, EPOLL_CTL_ADD, client, &cev);
}
} else {
// 边缘触发:必须一直读到 EAGAIN
while (true) {
ssize_t r = read(fd, buf, sizeof(buf));
if (r > 0) {
write(fd, buf, r); // 简化:未处理写不全
} else if (r == 0) {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
close(fd);
break;
}
}
}
}
}
}
关键概念:
- 水平触发(LT)vs 边缘触发(ET) :LT 只要缓冲区有数据就反复通知;ET 只在状态变化时通知一次,必须一次读到
EAGAIN,否则会丢数据。 - 非阻塞 I/O 是 ET 的前提:阻塞 read 会卡住整个事件循环。
write可能写不全:真实服务器需要"输出缓冲区 + EPOLLOUT 事件",这里为了简洁省略了。accept4(SOCK_NONBLOCK)避免"accept 后再设非阻塞"的竞态。
下一步:加入一个简单的"每连接读缓冲区 + 写缓冲区",处理粘包/半包,这就接近一个真正的网络服务器框架了(类似 muduo 的最简版)。
10.6 学习路线建议
如果要从零系统学 C++,一个可行的时间表:
第 1--2 周:语法与工具链
- 把第一章的代码全部手写一遍
- 学会用
g++、gdb、-E/-S/-c观察每个阶段 - 能解释"一个
.cpp到可执行文件经历了什么"
第 3--4 周:面向对象与 RAII
- 实现一个带完整五大特殊成员函数的类
- 理解虚表、对象切片、RAII
- 用
sizeof验证各种类的布局
第 5--6 周:STL 与算法
- 把常用容器的时间复杂度背下来
- 用算法库重写自己写过的手写循环
- 用
perf对比vector和list
第 7--8 周:模板与现代 C++
- 写一个
constexpr编译期计算 - 用 concepts 约束一个泛型函数
- 理解移动语义和完美转发
第 9--10 周:并发
- 实现线程池
- 用 TSan 抓数据竞争
- 理解内存序(先会用默认的
seq_cst)
第 11--12 周:工程化
- 用 CMake 搭一个多目标项目
- 接入 GoogleTest、clang-tidy、ASan
- 做一次完整的 code review
长期 :读经典书、读优秀开源项目的代码(fmt、spdlog、abseil、muduo)、写自己的小项目。
10.7 常见坑速查表
| 坑 | 症状 | 修复 |
|---|---|---|
| 有符号溢出 | 优化后行为异常 | 用无符号或检查范围 |
| 悬垂引用 | 随机崩溃 | 检查生命周期,用值语义 |
vector 扩容后指针失效 |
随机错误数据 | 存索引而非指针,或先 reserve |
shared_ptr 循环引用 |
内存泄漏 | 一环改 weak_ptr |
| 手写析构导致无移动 | 性能骤降 | 显式 = default 移动操作 |
移动构造非 noexcept |
容器扩容走拷贝 | 加 noexcept |
operator[] 插入默认值 |
map 大小莫名增长 | 只读用 find/contains |
substr 在循环里 |
性能极差 | 用 string_view |
string_view 悬垂 |
随机乱码 | 只作参数,不存 |
未 join 的 thread |
程序 abort | RAII 包装线程 |
| 条件变量无谓词 | 虚假唤醒 | wait(lk, pred) |
| 伪共享 | 多线程扩展性差 | alignas(64) |
| 静态初始化顺序 | 启动时崩溃 | 用函数内局部静态 |
| ODR 违规 | 链接错误或诡异行为 | 头文件里加 inline |
混用 new[]/delete |
崩溃 | 用容器或 unique_ptr<T[]> |
10.8 推荐资源
书:
- 《C++ Primer》(第 5 版)------系统入门,但很厚,适合查阅。
- 《Effective C++》/《Effective Modern C++》------条款式,讲"为什么"。
- 《C++ Concurrency in Action》------并发圣经。
- 《Data-Oriented Programming》/《Optimizing C++》------性能与数据布局。
- 《The C++ Programming Language》------语言之父的权威著作。
在线:
- cppreference.com------最权威的参考手册。
- C++ Core Guidelines------工程规范的事实标准。
- Compiler Explorer(godbolt.org)------看汇编的神器。
- C++ Insights------把模板/lambda 展开成"编译器眼中的样子"。
代码:
fmt:现代格式化的实现范本。spdlog:日志库的工程实践。abseil:Google 的 C++ 基础设施。muduo:陈硕的网络库,代码干净。nlohmann/json:header-only 库的设计范例。
结语
C++ 的学习曲线陡峭,但它的回报也很直接:你能写出既快又可控的程序,你能读懂那些"别人不敢碰"的底层代码,你能在性能问题出现时知道该往哪里看。
这份笔记覆盖的内容已经足够支撑一个"能干活"的 C++ 开发者,但语言本身还在演进(C++26 已经在路上,反射、std::execution、模式匹配都在推进)。真正重要的不是记住某个特性,而是建立三个习惯:
- 遇到不确定的行为,去看标准/参考,而不是猜。
- 写完代码先跑 sanitizer,再看性能。
- 每引入一个抽象,都想清楚它的代价。
愿你的程序既正确,也够快。