C++ 系统学习手记:从语法细节到工程实践

鸡汤

一份写给真正想把 C++ 用起来的开发者的长期笔记。覆盖语言基础、面向对象、模板与泛型、STL、内存模型、现代 C++、并发、性能优化、工程化与实战项目,共十章。


目录

  • 第一章 语言基础:从编译到语法
  • 第二章 面向对象:类、生命周期与多态
  • 第三章 模板与泛型编程
  • 第四章 STL:容器、迭代器与算法
  • 第五章 内存管理与对象生命周期
  • 第六章 现代 C++:C++11 到 C++23
  • 第七章 并发与多线程
  • 第八章 性能优化:从缓存到编译器
  • 第九章 工程化实践
  • 第十章 实战项目

写在前面

C++ 是一门很"诚实"的语言。它不会替你隐藏代价:一次拷贝、一次虚函数调用、一次堆分配、一次缓存未命中,几乎都能在代码里找到出处。也正因为如此,它既容易写出极快的程序,也容易写出极脆弱的程序。

这份笔记不追求"覆盖所有语法点",而是按一条真实的学习路径来组织:先搞清楚一行代码在编译器和机器上究竟变成了什么,再谈抽象、谈泛型、谈并发、谈优化。每一章都尽量给出可以直接编译运行的例子,并且在讲"怎么做"的同时讲"为什么这么做、代价是什么"。

阅读建议:

  1. 不要跳过第一章。很多"玄学问题"(未定义行为、对象切片、悬垂引用)根子都在最基础的语义上。
  2. 每段代码都亲手敲一遍。C++ 的坑绝大多数不是"看不懂",而是"想不到"。
  3. 遇到不确定的行为,去查 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 的内容。这带来了两个后果:

  1. 头文件里放的必须是"能被重复定义而不出错"的东西(声明、inline 函数、模板、类定义),否则就会重复定义。
  2. 模板必须在使用者可见的地方定义,所以模板代码通常全写在头文件里。

理解这一点,后面所有的头文件设计、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

默认参数是在调用点展开的,所以它是静态绑定的。如果通过基类指针调用虚函数且基类声明了默认参数,实际用的是基类的默认值,而不是派生类重写后的值------这是个隐蔽的坑。

函数重载与重载解析:编译器按以下顺序挑选:

  1. 精确匹配(不做任何转换)
  2. 提升转换(char → int,float → double)
  3. 标准转换(int → double,派生类指针 → 基类指针)
  4. 用户定义转换(构造函数、转换运算符)
  5. 可变参数 ...

只要某个阶段找到唯一最佳匹配就停止。如果同一阶段有多个同等好的候选,就是二义性错误。

复制代码
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 本章练习

  1. 用 g++ -E 观察一个包含 <iostream> 的文件预处理后有多少行,体会头文件的开销。
  2. 写一个 sizeof 实验,输出各基本类型、指针、空类、带虚函数的类、带成员函数的类的大小,解释差异。
  3. 构造一个 i = i++ + 1 的例子,用 UBSan 观察它是否被检出。
  4. 实现一个接受 const char* 的 my_strlen,再实现一个"带长度参数"的版本,比较两者在编译期的优化结果。
  5. 用 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 构造与析构:对象的一生

构造函数不是"分配内存",而是"在已分配的内存上初始化"。这决定了几个反直觉的事实:

  1. 构造函数里抛出异常,对象不会被构造出来,但已构造的成员会被正确析构(成员按声明的逆序析构)。

  2. 构造函数不能是虚函数(虚表还没建好)。

  3. 在构造函数/析构函数里调用虚函数,不会派发到派生类。

    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。析构同理,派生类先析构,之后对象的动态类型退回基类。

构造顺序:

  1. 虚基类(按深度优先、从左到右)
  2. 直接基类(按声明顺序)
  3. 非静态数据成员(按声明顺序)
  4. 构造函数体

析构顺序完全相反。理解这一点,才能解释"为什么成员析构顺序和构造顺序相反",以及"为什么基类析构函数必须是虚的"。

虚析构函数:

复制代码
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;
};

纯虚函数可以有定义(在类外),这允许"纯虚 + 有默认实现"的混合语义,但很少用。

虚函数的性能代价与替代方案:

  1. 去虚化(devirtualization) :当对象是栈上具体类型、或编译器能证明动态类型时,可以静态绑定。final 也能帮助编译器去虚化。
  2. CRTP(静态多态):见第三章,编译期绑定,零开销,但会增大代码体积。
  3. 函数指针表:手写 vtable,适合需要 ABI 稳定或跨语言交互的场景。
  4. 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 本章练习

  1. 实现一个 String 类,带拷贝构造、移动构造、拷贝赋值、移动赋值、析构,并用 operator= 按值传参的写法,验证自赋值安全。
  2. 写一个类,故意在构造函数里调用虚函数,观察输出,解释原因。
  3. 用 sizeof 对比"带虚函数的类"和"不带虚函数的类",并尝试用 final 观察编译器能否去虚化(看汇编)。
  4. 实现一个 FileGuard:构造时打开文件,析构时关闭并在未提交时回滚。
  5. 用 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 的差异。

缓解手段:

  1. 把与类型无关的逻辑抽到非模板基类("thin template"技巧)。

  2. 用 extern template 显式实例化,避免每个翻译单元都生成一份。

  3. 用类型擦除(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 本章练习

  1. 实现一个编译期的 Fibonacci<N>,用 static_assert 验证 Fibonacci<10> == 55。
  2. 实现 TypeList:支持 push_front、push_back、contains 的编译期类型列表。
  3. 用 concepts 写一个 Sortable 约束,要求类型支持 < 且可拷贝,并提供一个 sort 函数。
  4. 实现一个 tuple 的 for_each,对每个元素调用一次函数,用折叠表达式实现。
  5. 用 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 按值捕获整个对象)

注意事项:

  1. 按引用捕获的变量必须活过 lambda。把 lambda 存起来(如存进回调列表)再异步执行,很容易悬垂。
  2. [&] 捕获 this 是隐式的 ,但 [=] 在 C++17 前也隐式捕获 this(按值捕获的是指针,不是对象)。C++20 起 [=] 不再隐式捕获 this,必须显式写。
  3. 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 是类型擦除的容器,可以存任何签名兼容的可调用对象。代价:

  1. 可能堆分配(捕获较大的 lambda 时会分配)。
  2. 一次间接调用(通常无法内联)。
  3. 构造/析构有开销。

性能对比 (粗略):直接调用 < 函数指针 ≈ 模板参数 < 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 本章练习

  1. 对比 vector、list、deque 在 100 万元素下的遍历、随机插入、排序耗时,画出表格。
  2. 用 lower_bound 实现"统计有序数组中等于 x 的元素个数"。
  3. 用 nth_element 求 100 万个随机数的中位数,与 sort 版本对比耗时。
  4. 写一个自定义哈希函数,让 unordered_map<Point, int> 在 1000 万个点上的查找比默认快 30%。
  5. 用 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;

自定义分配器的典型用途:

  1. 内存池:减少系统调用和碎片。
  2. 统计:调试时统计各容器的内存占用。
  3. 共享内存/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 字节都读进来。这解释了两件事:

  1. 顺序访问比随机访问快得多:顺序访问能充分利用预取和整行加载。
  2. 数据结构布局比算法常数更重要:一个 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 内存泄漏的常见来源与排查

  1. 裸 new 没有对应 delete,尤其在异常路径上。
  2. shared_ptr 循环引用。
  3. 容器里存裸指针,容器清空但没 delete 元素。
  4. 单例/全局缓存只增不减。
  5. 自定义分配器忘了释放。

排查手段:

  • 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 本章练习

  1. 用 sizeof 对比成员顺序不同的三个结构体,并画出内存布局图。
  2. 实现一个固定大小的内存池(预分配 N 个槽位,allocate/deallocate O(1)),用 operator new 重载接进去,测 100 万次分配的时间。
  3. 构造一个 shared_ptr 循环引用,用 ASan 的 leak 检测确认泄漏,再用 weak_ptr 修复。
  4. 写一个多线程程序,两个线程各写一个变量,把它们放在同一缓存行和不同缓存行,对比耗时(引出第七章)。
  5. 用 valgrind --tool=callgrind 分析一个程序,找出最耗时的三个函数。

第六章 现代 C++:C++11 到 C++23

6.1 现代 C++ 的四个核心思想

C++11 被称为"现代 C++ 的起点",它的意义不在于加了多少特性,而在于用四条主线重写了语言的默认用法:

  1. 值语义与移动语义:让"传递对象"不再意味着"拷贝"。
  2. 类型推导与泛型 :auto、decltype、lambda、可变参数模板。
  3. 编译期计算 :constexpr、static_assert、noexcept。
  4. 所有权与资源管理:智能指针、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&&

只有"右值引用 + 右值引用"才是右值引用,其余都是左值引用。这就是万能引用能工作的底层机制。

移动语义的工程规则:

  1. 自定义移动操作要标 noexcept,否则容器扩容会退化成拷贝。

  2. 移动构造后要把源对象置为可析构的安全状态。

  3. 不要对 const 对象 std::move。

  4. 不要 return std::move(local)------这会阻止 NRVO,反而多一次移动。

    std::vector f() {
    std::vector v;
    // ...
    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 本章练习

  1. 实现一个 MyString,用移动语义让 MyString a = make(); 不产生任何拷贝(打印构造/析构日志验证)。
  2. 写一个 constexpr 的编译期素数筛,static_assert 验证前 100 个素数。
  3. 用 std::variant + std::visit 实现一个 JSON 值类型(null/bool/number/string/array/object)。
  4. 用 std::optional 重写一个原本返回 -1 表示失败的函数,并说明 API 的变化。
  5. 用 std::format 实现一个日志格式化函数,支持 {} 占位符和宽度/精度,对比 stringstream 版本的性能。

第七章 并发与多线程

7.1 并发不是"多开几个线程"

先明确三个概念的区别:

  • 并发(concurrency):多个任务在时间上重叠地推进,不一定是同时。
  • 并行(parallelism):多个任务在物理上同时执行,需要多核。
  • 异步(asynchrony):发起操作后不等待结果,稍后通过回调/事件获取。

C++ 的 std::thread 给你的是"并行"的原语,但真正的并发设计还要考虑:任务如何划分、数据如何共享、错误如何传播、如何取消、如何观测。

什么时候不该用多线程:

  1. 任务本身是 I/O 密集且可以用异步 I/O 解决(线程会阻塞在 I/O 上,浪费栈和调度)。
  2. 数据共享极其频繁(锁的争用会让并行变成串行)。
  3. 任务太小(创建/调度开销超过计算本身)。

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();
}

三个必须记住的点:

  1. wait 必须带谓词,或在循环里 wait。条件变量存在"虚假唤醒"(spurious wakeup),可能在没有任何 notify 的情况下返回。

  2. wait 释放锁并原子地进入等待 ,醒来后重新加锁。所以它必须接受 unique_lock。

  3. 修改共享状态和 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;
};

这个实现有三个值得注意的设计:

  1. 析构时先置 stop 再 notify_all,让所有线程都能看到退出信号。
  2. 任务用 std::function<void()> ,代价是类型擦除(见 4.8 节)。如果要支持返回值和 future,需要 std::packaged_task。
  3. 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];
};

关键点:

  1. 容量用 2 的幂,用位与代替取模,快得多。
  2. head 只被生产者改,tail 只被消费者改 ,所以各自 relaxed 读写自己的,acquire/release 读对方的。
  3. 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 本章练习

  1. 用 std::thread 实现一个"多线程求和":把数组分成 N 段,每段一个线程,最后合并,与单线程对比加速比。
  2. 实现一个有界阻塞队列(容量满时 push 阻塞,空时 pop 阻塞),用条件变量。
  3. 用 std::atomic 实现自旋锁,并与 std::mutex 对比在低争用和高争用下的性能。
  4. 用 TSan 跑一个"两个线程各自 ++ 同一个非原子 int"的程序,观察报告。
  5. 实现一个简单的线程池,支持提交任务并返回 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

火焰图的读法:横轴是采样占比(越宽越耗时),纵轴是调用栈深度。找"宽而扁"的块------那是热点函数本身;"窄而深"的往往是调用链开销。

微基准测试的陷阱:

  1. 编译器可能把整个计算优化掉(结果没被使用)。用 benchmark::DoNotOptimize 或 asm volatile 阻止。
  2. 循环太小,测的是启动开销。
  3. 缓存预热不充分,第一次跑是冷的。
  4. 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++ 程序里最常见的性能瓶颈之一。

  1. 避免 substr 循环 :每次 substr 都是 O(n) 拷贝。用 string_view。
  2. 避免 std::string 作为 map 的键:哈希要遍历整个字符串,比较也是。可以考虑"字符串驻留"(interning)或前缀哈希。
  3. 避免 += 拼接大量字符串 :每次可能扩容。用 reserve 或 std::format。
  4. std::to_string 不便宜 :它内部用 snprintf 或 ostringstream 风格。热路径上可以考虑 std::format 或第三方库(fmt、itoa 实现)。
  5. 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 性能优化的反模式

  1. 过早优化:在没有 profile 的情况下"我觉得这里慢",改了之后可能更慢。
  2. 用 -O3 掩盖算法问题 :O(n²) 的循环不会因为 -O3 变成 O(n)。
  3. 过度内联 :inline 所有东西会让代码膨胀、指令缓存失效。让编译器决定。
  4. 迷信 move :对小类型(int、指针、pair<int,int>)来说,移动和拷贝一样快,加 std::move 反而增加代码噪音。
  5. 滥用 shared_ptr:原子引用计数是热路径的杀手。
  6. 用 map 做小范围整数查找 :直接用数组/vector 做直接寻址表快一个数量级。
  7. 在循环里 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 本章练习

  1. 用 perf 找出一个程序的热点函数,并用火焰图可视化。
  2. 对比 vector<int>、list<int>、vector<unique_ptr<int>> 的遍历耗时,解释差异。
  3. 把一个 AoS 结构改成 SoA,测量粒子更新循环的加速比。
  4. 用 Google Benchmark 对比 std::string 拼接(+= vs reserve + += vs format)的耗时。
  5. 用 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/

关键约定:

  1. include/ 下的头文件是"公开 API" ,src/ 下的头文件是"内部实现"。这样外部使用者可以直接 #include <project/api.h>。
  2. 头文件路径带项目名前缀 (project/api.h),避免多个库的 api.h 冲突。
  3. 测试与被测代码分离 ,但测试文件可以访问 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");
}

测试的原则:

  1. 一个测试只验证一件事,失败时能立刻定位。
  2. 测试要独立,不依赖执行顺序、不共享全局状态。
  3. 测试名要说清楚意图 (AddNegative_ReturnsNegativeSum 比 Test2 好)。
  4. 覆盖边界:空输入、单元素、最大值、溢出、异常路径。
  5. 不要测试实现细节:测"行为"而不是"内部调用了几次"。

测试覆盖率 :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;
};

工程要点:

  1. 热路径上的日志要先判断级别再格式化 。log(Debug, "value={}", heavy_to_string(x)) 即使不输出也会构造参数。
  2. 日志要能带上下文(请求 id、线程 id、用户 id),否则线上排查无从下手。
  3. 异步日志:写日志到队列,后台线程落盘,避免阻塞业务线程。
  4. 日志轮转:按大小或时间切分,避免单文件无限增长。
  5. 不要记录敏感信息(密码、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(且尽量不用)

其他约定:

  1. 函数不超过 40 行,参数不超过 5 个。
  2. 单个文件不超过 500 行。
  3. 不写"注释掉的代码",用版本控制。
  4. 注释解释"为什么",而不是"是什么"。
  5. const 能加就加。
  6. 优先用 auto 只在类型明显时(auto it = m.find(k) 可以,auto x = compute() 不行)。

评审关注点(按优先级):

  1. 正确性:边界、异常、资源泄漏、并发。
  2. 可维护性:命名、结构、重复代码。
  3. 性能:是否有明显的不必要拷贝/分配。
  4. 可测试性:是否容易写测试。
  5. 风格:最后再看。

9.10 本章练习

  1. 用 CMake 搭一个"库 + 可执行 + 测试"的三目标项目,导出 find_package 支持。
  2. 把一个"头文件包含头文件包含头文件"的类改成 PImpl,测量全量编译时间的变化。
  3. 用 GoogleTest 为一个"字符串分割函数"写 8 个测试,覆盖空串、单字符、连续分隔符、首尾分隔符。
  4. 用 clang-tidy 跑一遍你的项目,处理所有 performance-* 警告。
  5. 实现一个线程安全的单例,并解释为什么"局部静态变量"在 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)...));
}

练习点:

  1. 为什么移动构造要标 noexcept?去掉后 std::vector<UniquePtr<T>> 扩容会怎样?
  2. std::exchange 的作用是什么?手写会有什么问题?
  3. 实现数组特化 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;
};

核心难点:

  1. 必须用 operator new 而不是 new T[] :new T[n] 会默认构造 n 个对象,而 vector 需要"容量有但元素未构造"的状态。
  2. std::uninitialized_* 系列是"在未初始化内存上构造"的标准工具。
  3. 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 快且不抛异常)
  • 递归下降的解析思路

下一步挑战:

  1. 支持 \uXXXX 转义(要考虑代理对)。
  2. 递归深度限制,避免恶意输入导致栈溢出。
  3. 改成"一次扫描 + 索引"的零拷贝版本(string_view 直接指向原缓冲区)。
  4. 用 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;
                    }
                }
            }
        }
    }
}

关键概念:

  1. 水平触发(LT)vs 边缘触发(ET) :LT 只要缓冲区有数据就反复通知;ET 只在状态变化时通知一次,必须一次读到 EAGAIN,否则会丢数据。
  2. 非阻塞 I/O 是 ET 的前提:阻塞 read 会卡住整个事件循环。
  3. write 可能写不全:真实服务器需要"输出缓冲区 + EPOLLOUT 事件",这里为了简洁省略了。
  4. 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、模式匹配都在推进)。真正重要的不是记住某个特性,而是建立三个习惯:

  1. 遇到不确定的行为,去看标准/参考,而不是猜。
  2. 写完代码先跑 sanitizer,再看性能。
  3. 每引入一个抽象,都想清楚它的代价。

愿你的程序既正确,也够快。

相关推荐
灿飞屋1 小时前
Kotlin与Java虚拟机三:控制流
android·java·开发语言·后端·python·kotlin·安卓
石小石Orz1 小时前
AI 正在蚕食我们的编码能力
前端·后端·算法
xiaoye-duck1 小时前
《C++ 项目:手写大模型接入 SDK》Ollama接口设计(上):初始化、全量响应实现和测试
开发语言·网络·c++·语言模型
zaizai10072 小时前
【Effective Modern C++】学习第一章
c++
Fruiticecake2 小时前
C 程序员速通现代 C++(一):lambda、std::function、移动语义与智能指针
c语言·c++
总要学点什么2 小时前
第98篇:三维特效-材质线效果——给一条线换十种材质,虚线的图案只填了一半
开发语言·前端·javascript·ecmascript·gis·webgl·材质
田甲3 小时前
做一款 Type-C 母座读卡器:GL3224 双卡方案实测
c语言·开发语言
民乐团扒谱机3 小时前
【微实验】NSGA-II(非支配排序遗传算法)——解决多目标优化问题的“自然选择模拟器”
linux·人工智能·算法
青少儿编程课堂3 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会Python/C++考级:中缀表达式求值解析
c++·python·算法·bfs·信息学竞赛