现代 C++ 完整知识体系交互讲解

全景:C++ 的定位与现状

钻进语法之前先回答三个问题:C++ 解决什么问题、今天它用在哪里、和邻近语言怎么选。后面每一章都是这张地图的放大。初学者提示:这一章为了把定位讲清楚,会提前点到 RAII(资源随对象生命周期自动释放)、模板、ranges 等后面才展开的名词——扫一眼有个印象即可,不必现在弄懂,直接进 01 章把环境跑通更要紧。02–04 章的卡片分「先读」与「进阶」两段,标着「进阶」的第一遍可以整段跳过。

C++ 的立身之本是零开销抽象:既要 C 级别的性能与硬件控制力,又要高级抽象(类、泛型、RAII)——你不用的特性不付代价,你用的特性也没法手写得更快

今天它统治哪些领域

  • 游戏引擎(Unreal)、浏览器(Chromium / WebKit)、数据库(MySQL / ClickHouse / RocksDB);
  • 高频交易、音视频、图形渲染、嵌入式——一切「延迟敏感 + 资源受限」的场景;
  • AI 基础设施的底座(PyTorch / TensorFlow 的算子层都是 C++)。

和邻居怎么选

  • vs C:需要抽象与类型安全选 C++,极简 ABI 与内核模块 C 仍占优;
  • vs Rust:新项目要内存安全保证可考虑 Rust,C++ 胜在生态存量与渐进采用;
  • vs Go/Java:能接受 GC 停顿的服务端业务,它们的开发效率更高。
// 一眼看出"现代 C++"和老 C++ 的差别:
// 无裸 new/delete、无手写循环、类型自动推导
#include <algorithm>
#include <memory>
#include <vector>

auto widget = std::make_unique<Widget>();   // RAII 管生命周期

std::vector<int> v{3, 1, 4, 1, 5};
std::ranges::sort(v);                        // C++20 ranges

auto even = std::ranges::count_if(v,
    [](int x) { return x % 2 == 0; });     // lambda
入门资料的年代比难度更容易误导:网上大量教程仍停在 C++98/03,通篇 char*、裸 new/deleteNULL,照着学等于先练一遍本页后面要逐个否定的危险写法。挑书先翻两页——看不到 auto 和智能指针就换一本。
C++11 是分水岭(auto、lambda、移动语义、智能指针),C++17 是当下工程主流基线,C++20 带来 concepts / ranges / coroutines。主线就是「用现代特性替换危险的老写法」:裸指针 → 智能指针、手写循环 → 算法/ranges、new/delete → RAII。

跑通第一个程序

在讲任何语法之前,先让机器把你写的东西跑起来。这一章只做三件事:装好工具链、敲出并运行第一个完整程序、看懂它报的第一条错。后面所有章节的代码片段都默认你已经有一个能编译运行的环境——没有它,读再多语法也无从验证。

C++ 没有「装好语言就能跑」这回事——你需要一个编译器把源码翻译成可执行文件。三大平台各有默认选择,任选其一,本页示例在三者上通用。

按平台装,然后两步跑通

  • WindowsVisual Studio Community(勾「使用 C++ 的桌面开发」)最省心;想用命令行就装 MSYS2pacman -S mingw-w64-ucrt-x86_64-gccmacOSxcode-select --installLinuxsudo apt install g++
  • 装完 g++ --version 能打印版本号即可。编辑器不必纠结,VS Code + C/C++ 扩展是最常见的起步组合;
  • 编译 g++ -std=c++20 -Wall -Wextra hello.cpp -o hello运行 ./hello(Windows 是 hello.exe)。用 Visual Studio 直接按 F5 即可,但本页命令行一律以 g++ / clang++ 为准
// hello.cpp —— 第一个完整、可直接编译运行的程序
#include <iostream>

int main() {
    std::cout << "Hello, C++!" << "\n";
    return 0;
}

// ---- 在终端里执行这两条 ----
// 1) 编译:产出可执行文件 hello
//    g++ -std=c++20 -Wall -Wextra hello.cpp -o hello
// 2) 运行
//    ./hello          (Windows: hello.exe)
// 输出:Hello, C++!

// ---- 若用 Visual Studio 的 cl 编译器,等价命令是 ----
//    cl /EHsc /std:c++17 /W4 hello.cpp
// 本页后续一律用 g++ 的写法。
编译命令里漏掉 -o hello 时,g++ 会默认生成 a.out(Windows 上 a.exe),然后你执行 ./hello 报「找不到文件」——不是代码错了,是产物不叫这个名字
从第一天起就带上 -std=c++20 -Wall -Wextra-std 不写就吃编译器自己的默认档(还随版本漂移),-Wall -Wextra 让大量 bug 在警告里就现形。

上一卡那个程序只有四行有效代码,但每一行都对应 C++ 的一条基本规则。

#include <iostream> 把要用的东西搬进来

  • C++ 默认什么库都没有:想用输出功能,就得先包含提供它的头文件
  • # 开头的行由预处理器在真正编译前处理掉——它做的就是文本替换。

int main() 入口,以及那个分号

  • 每个可执行程序有且仅有一个 main,操作系统从这里开始执行;
  • 每条语句以分号结尾——漏分号是初学阶段最高频的编译错误;
  • return 0; 是进程退出码,0 表示成功main 里不写它编译器也会替你补上,但别的函数没这个待遇。
#include <iostream>   // ① 引入输出功能所在的头文件

int main() {            // ② 程序入口,花括号包住函数体
    std::cout << "Hello, C++!" << "\n";   // ③ 一条语句,以分号结尾
    return 0;         // ④ 返回 0 = 正常退出
}

// 缩进和换行都不影响语义——下面这行与上面那个 main 完全等价
// (只是给人读的可读性没了,别真这么写;两者不能同时存在于一个程序)
// int main(){std::cout<<"Hello, C++!"<<"\n";return 0;}
漏分号的报错几乎总是指向下一行int a = 1 后面漏了分号,编译器会在下一行报 expected ',' or ';' before ...(g++ 15 报错原文)。所以看到报错先检查上一行行尾。同理,花括号不配对时报错位置往往在文件末尾——用编辑器的括号高亮从头核对,别盯着报错行硬看。
本页后面的代码大多是片段,省略了 #includemain。想动手跑就塞回骨架:#include <iostream> + int main() { ... }

C++ 的报错以又长又难读闻名,但按阶段分类之后,八成都有固定套路

三期各自的特征

  • 编译期:带 文件:行:列: error:,代码本身不合法。漏分号时报错常指向下一行真正的问题在报错行或它上一行
  • 链接期:特征极好认,undefined reference to,而且不指向源码某一行——函数只声明没定义、少链接一个库、漏了某个 .cpp
  • 运行期:编译链接全过、跑起来才炸,Segmentation fault 既没行号也没解释——靠 -fsanitize=address 把行号找回来。

还有一类根本不报错:未定义行为(UB)

  • 标准把一部分写法列为 UB(未定义行为):标准对接下来发生什么不作任何规定——可能崩溃,也可能看起来完全正常直到你加上 -O2
  • 更反直觉的是编译器假设 UB 永不发生并据此优化:「先算再检查溢出」这类写法,检查会被直接删掉——这就是「Debug 好好的、Release 一开就错」的由来;
  • 入门最常撞的四种:读未初始化的变量、数组越界、解引用空指针或已释放的指针、有符号整数溢出;自保靠声明即初始化、开发期带 -fsanitize=address,undefined、用 std::vector 与智能指针。
// ① 编译期:漏了分号
int x = 1
int y = 2;
// error: expected ',' or ';' before 'int'   ← 报在第 2 行,错在第 1 行行尾

// ① 编译期:忘了 #include <vector> 就用 std::vector
std::vector<int> v;
// error: 'vector' is not a member of 'std'
// 提示:漏 include 不一定报错——有些头文件会顺带引入别的头,
// 所以"能编过"不代表你 include 全了,用到什么就显式 include 什么

// ② 链接期:main 拼错了
int mian() { return 0; }
// undefined reference to 'main'   ← 没有行号 = 链接期

// ③ 运行期:编译通过,运行才崩
#include <vector>
int main() {
    std::vector<int> v = {1, 2, 3};
    return v[10];          // 越界:编译零报错,运行时未定义行为
}
// g++ -g -fsanitize=address 编译后运行会指出越界位置。
// 想让 vector 的越界检查更可靠,可再加 -D_GLIBCXX_ASSERTIONS;
// 或者干脆用 v.at(10),它会抛异常而不是默默越界。
模板报错动辄几十行:跳过所有 required from here 中间帧,直接找第一条 error:
把报错原文粘进搜索引擎完全正当,但先删掉自己的文件名、变量名和路径再搜,命中率高得多。

基础语法

环境跑通之后,正式进入语法。C++ 代码里满屏都是 ::、流运算符、std:: 前缀——第一张卡先把这些符号和词汇一次性解码,再进入类型系统、控制流、函数与输入输出等程序的基本构成要素。本章卡片分「先读」与「进阶」两段:先读段是写出可用代码的最小集合,进阶段是同一主题下的精确规则与陷阱,第一遍可以整段跳过、回头再看。

先读 · 写出能跑的代码

还没碰业务逻辑,满屏已经是 std::cout << x << std::endl; 这样的符号。这一层不是概念难,纯粹是「同一个符号在 C++ 里身兼数职」——进正文前先逐个解码,后面每一章都省力。

:: 作用域解析运算符

  • 作用是「到某个命名空间 / 类里去取名字」std::cout = 标准库 std 里的 coutColor::Red = 枚举 Color 里的 Red(枚举本身见本章进阶段的 enum class 卡,现在只需认得这个 ::);
  • Class::func 在类外定义成员函数、Class::static_member 访问静态成员(见 04 章);
  • 开头什么都不加的 ::name全局作用域的 name——被局部同名遮蔽时用来点名全局那个。

std 到底是什么

  • std 就是标准库的命名空间(standard 的缩写):vector、string、cout、sort…整个标准库的名字都装在里面,所以到处得写 std:: 前缀;
  • 嫌烦可以 using std::cout; 只引一个名字,或 using namespace std; 引入全部——但头文件里别用后者(会污染所有包含它的文件,见 pitfall),「命名空间」这套机制本身见本章进阶段的 namespace 卡——现在把 std 当成「标准库的姓氏」理解就够了;
  • 常用 std:: 名字横跨全书,完整清单见 06 章「标准库速查」:先用「任务反查」按你要干的事找名字,再按名字所在的头文件翻后面七张卡看具体操作

<< / >> 同一符号两副面孔

  • 左边是时是流插入 / 提取运算符cout << x 输出、cin >> x 输入;它俩每次返回流本身,所以能 cout << a << b 链着写(流的细节见本章 iostream 卡);
  • 左边是整数时,同一个 << / >>按位左移 / 右移1 << 4 得 16。到底哪副面孔,看左操作数是流还是整数
  • 「一个运算符对不同类型给出不同行为」这件事叫运算符重载——是 C++ 的核心机制,+[]== 都能重载(见 04 章「运算符重载」卡)。
#include <iostream>   // cout / cin 来自这里
#include <string>

// 把第一行 Hello World 逐块解码:
std::cout << "Hi" << std::endl;
//   ↑ std 里的 cout   ↑ 流插入运算符   ↑ std 里的 endl
// = 「取标准库的 cout,插入 "Hi",再插入换行并刷新」

// :: 的四种用法
std::string s;              // 命名空间 :: 名字
Color c = Color::Red;       // 枚举 :: 成员
int n = app::version;       // 自定义命名空间 :: 名字
::malloc(8);                // :: 名字 → 全局作用域

// 同一个 << —— 看左边是谁,决定它是哪副面孔
std::cout << 16;             // 左边是流 → 输出 16
int x = 1 << 4;            // 左边是整数 → 位移,得 16

// 少写 std:: 的两种方式
using std::cout;            // 只引入一个名字(推荐)
using namespace std;        // 引入全部(.cpp 里可,头文件中忌)
<< 沿用的是移位运算符的优先级,比 <==&?: 都高,于是 std::cout << a & 1 会被解析成 (std::cout << a) & 1 而报错——输出 ==&、三目等表达式务必加括号std::cout << (x == y)。另外 using namespace std; 别写进头文件:它把整个 std 灌进每个包含该头的文件,引发难查的名字冲突;头文件里一律写全 std::
看到不认识的 std::xxx,去 06 章以头文件命名的七张速查卡里找;反过来「知道要干什么、不知道叫什么」,用该章开头的「任务反查」表;符号背后的运算符重载机制见 04 章,cin/cout 的流状态细节见本章 iostream 卡。记住一句话:C++ 里符号先看上下文再定含义——这正是它比 Python/Go 更「费眼」的根源。

C++ 是静态强类型语言:每个变量在编译期就定死类型。这一卡管两件事:内置类型的心智地图,以及 auto / const / constexpr 三个修饰词的准确语义——它们比看上去精确得多。

内置类型速览

  • 整数 int / long / long long(宽度随平台变,要定宽见本章进阶段「整数的真相」)、浮点 float(32 位)/ double(64 位,字面量 3.14 默认是 double)、字符 char、布尔 bool
  • char 一身两职:既是字符也是最窄的整数类型——'A' + 1 得 66(原因见进阶段「整数的真相」);
  • 未初始化的局部内置类型变量是垃圾值而非 0,读它是未定义行为——「声明即初始化」是铁律(用 {},见下一卡)。

auto:让编译器照抄右边的类型

  • auto 的含义是「这个变量的类型,就是右边那个表达式算出来的类型」——编译期定死,不是动态类型;
  • 一条要记住的规则:auto 只抄「值」的类型,会把 const 和引用抹掉const int& r = x; auto a = r;a 是普通 int,而且是一份拷贝——改 a 不会影响 x
  • 想保留就自己写回来:auto&(可改的引用)、const auto&(只读引用,还省掉一次拷贝)——遍历容器时几乎总该用后者;
  • 该用的场景:类型名冗长(迭代器)、类型根本写不出来(lambda)、右侧已经写明了类型;不该用的场景:你想指定一个不同于右侧的类型时——auto 只会如实复读,不会替你转换。

const 与 constexpr:只读 ≠ 编译期

  • const 的本义是「初始化后不可再改」,初始值完全可以是运行期才算出来的;
  • constexpr保证编译期就能算出,因此能用作数组长度这类必须在编译期确定的地方;
  • 经验法则:常量能 constexpr 就 constexpr,不能则退而用 const。
#include <string>

int x = 42;
double pi = 3.14159;   // 浮点字面量默认 double,不是 float
bool flag = true;
int oops;               // ⚠️ 未初始化:值是垃圾,读它是 UB

// auto 推导(C++11)——照抄右边的类型,但丢掉 const 和引用
auto i = 10;                // int
auto s = std::string("hi"); // std::string
const int& r = x;
auto a = r;                 // int!const 和 & 都被丢了 → 是份拷贝
const auto& b = r;         // const int&:要保留就自己写回来

// auto + {} 的怪癖
auto one{1};         // int(C++17 起)
auto list = {1, 2};  // initializer_list<int>,多半不是你想要的

// const 与 constexpr
const int doubled = x * 2; // 只读,但值运行期才定
constexpr int SIZE = 256;  // 保证编译期算出(可作数组长度)
auto 最常见的事故是意外拷贝:遍历一个装着大对象的容器时写 for (auto item : items),每轮都会完整复制一份 item——数据量一大就是白白的性能损失,而且改 item 改不到容器里的原对象。默认写 for (const auto& item : items)(只读)或 for (auto& item : items)(要改),确实需要副本时才写 auto
浮点默认用 doublefloat 只在内存带宽敏感(超大数组、GPU 交换)时才值得;比较浮点数用误差范围而非 ==。整数的宽度、有无符号与溢出规则水更深,本章进阶段的「整数的真相」专门拆。另有一个少见但会咬人的角落:std::vector<bool> 被特殊实现过,auto flag = v[0]; 拿到的不是 bool 而是一个内部代理对象——遇到它时写明类型 bool flag = v[0]; 即可,细节见 05 章容器卡。

「给变量赋初值」在 C++ 里有三种写法,差别不只是风格——选错会静默截断数据,甚至把一句定义变成一句函数声明。这是最典型的「基础语法却暗藏陷阱」层。

三种写法

  • T x = v; 拷贝初始化——最眼熟,但概念上是「先造 v 再拷进 x」;
  • T x(v); 直接初始化——直接调构造函数;
  • T x{v}; 列表初始化 / 统一初始化(C++11)——花括号一套语法通吃:内置类型、类、聚合、容器都用它。

为什么优先 {}:它禁止窄化

  • int a{3.14}; 编译报错(不允许 double→int 丢精度);而 int a = 3.14; 静默截断成 3——{} 把一类隐蔽 bug 变成编译期错误;
  • 还能直接初始化聚合与容器:Point p{1, 2};std::vector<int> v{1, 2, 3};

两个必须知道的坑

  • most vexing parseWidget w(); 看着像「默认构造一个 w」,编译器却把它解析成「声明一个返回 Widget 的函数 w」——想默认构造要写 Widget w;Widget w{};
  • (){} 对容器意义不同std::vector<int> a(10, 2); 是「10 个 2」,std::vector<int> a{10, 2}; 是「两个元素 10 和 2」——因为 {} 会优先匹配 initializer_list 构造函数。
// 三种写法
int a = 42;      // 拷贝初始化
int b(42);       // 直接初始化
int c{42};       // 列表初始化(推荐)

// {} 禁止窄化,把隐蔽 bug 变编译错误
int x = 3.14;    // 静默截断成 3(危险)
// int y{3.14};  // ❌ 编译报错:不允许 double→int 窄化

// most vexing parse:这不是构造对象,是声明函数!
Widget w1();       // ⚠️ 声明「返回 Widget 的函数 w1」
Widget w2;         // ✅ 默认构造
Widget w3{};       // ✅ 默认构造(更明确)

// () 与 {} 对容器含义不同
std::vector<int> v1(10, 2);  // 10 个 2
std::vector<int> v2{10, 2};  // 两个元素:10, 2
{} 在有 initializer_list 构造函数的类型上会优先选它std::vector<int> v{10, 2} 得到的是 [10, 2] 而非「10 个 2」。要按「大小 / 值」构造这类容器,必须用圆括号 v(10, 2)。这是「一律用 {}」唯一需要让路的场景。
经验法则:默认用 {}——防窄化、语法统一、还顺手躲开 most vexing parse(T x{}; 永远是构造对象)。唯一例外是上面的容器「大小 + 值」构造,改用 ()

控制流骨架与 C 一致,这张卡真正的新东西是 C++11/17 加的三件套——范围 for、if 初始化语句、结构化绑定。

C++ 的控制流语句和 C 基本一致,但现代 C++ 增加了一些改进:范围 for(C++11)让遍历容器更简洁,if 初始化语句(C++17)让条件判断更紧凑,结构化绑定(C++17)让解构更方便。基础的 switch(整数 / 枚举多分支,注意 break)与三目 ?:(能返回值的条件表达式)也一并演示在代码里。
#include <iostream>
#include <map>
#include <string>
#include <vector>

// 范围 for 循环(C++11)—— 遍历容器的首选方式
std::vector<int> nums = {1, 2, 3, 4, 5};
for (const auto& n : nums) {
    std::cout << n << " ";
}

// switch:整数 / 枚举分支;注意 break,多标签可共用,穿透用 [[fallthrough]]
char grade = 'B';
switch (grade) {
    case 'A': std::cout << "优秀"; break;
    case 'B':
    case 'C': std::cout << "及格"; break;   // B、C 共用一段
    default:  std::cout << "其他";
}

// 三目 ?: 是表达式,有返回值——可直接用于初始化
const char* tag = (grade == 'A') ? "top" : "normal";

// if 带初始化语句(C++17)—— map 等容器见 05 章
std::map<std::string, int> m = {{"key", 1}};
if (auto it = m.find("key"); it != m.end()) {
    std::cout << it->second;  // it 仅在 if 块内可见
}

// 结构化绑定(C++17)
std::map<std::string, int> scores = {{"Alice", 95}};
for (const auto& [name, score] : scores) {
    std::cout << name << ": " << score;
}
范围 for 只是迭代器循环的语法糖:循环体里对同一容器 push_back / erase,扩容或挪动会让迭代器失效,继续循环就是 UB——for (const auto& n : v) if (n == 1) v.push_back(4); g++ 15 编译零警告,加 -fsanitize=address 运行报 heap-use-after-free;不开 sanitizer 时还可能装作正常跑完。要边遍历边增删,改用普通迭代器循环并接住 erase 的返回值(见 05 章)。
遍历容器时用 const auto& 可以避免不必要的拷贝。只有在需要修改元素时才用 auto&

C++ 允许同名函数不同参数共存,调哪个由编译器做重载决议挑选。这套选拔规则,加上默认参数与 inline 的现代语义,是读接口、排「ambiguous」错误的基本功。

重载决议怎么选

  • 只看参数列表(个数与类型),返回类型不参与——只改返回类型不构成重载;
  • 匹配优先级:精确匹配 > 提升charintfloatdouble > 标准转换intdouble、派生指针→基类指针) > 用户定义转换;同一级冒出多个候选 → ambiguous 编译错误;
  • 经典案例:f(int)f(char*) 并存时,f(NULL) 永远拿不到指针版——g++ 15 / clang 21 直接报 call of overloaded 'f(NULL)' is ambiguous(它们的 NULL 是内建的 __null,转 int 与转指针两条路打平),NULL 定义为整数 0 的实现则选中 int 版——这正是 nullptr 存在的理由(见 03 章)。

默认参数

  • 只能从右往左连续给(void f(int a = 1, int b) 非法),且只在声明处写一次(通常在头文件),定义处不能重复;
  • 默认值是在调用点由编译器填进去的,不是运行时才决定的——这条规则等学到 04 章的虚函数后还会再咬一次(那里有专门的警告)。

inline 的现代含义:ODR 豁免,不是「快」

  • 内不内联早已由编译器自主决定,inline 关键字的实际作用是允许同一定义出现在多个翻译单元——这就是「头文件里定义函数必须标 inline」的原因——ODR(单一定义规则)是指「整个程序里每个函数只能有一份定义」,把定义写进头文件、又被多个 .cpp 包含就会撞车,详见本章进阶段的编译流程卡;
  • 类内直接定义的成员函数、constexpr 函数隐式自带 inline;C++17 的 inline 变量让头文件里定义全局变量也合法了。
// 重载:同名不同参,编译器按实参挑
int    add(int a, int b)       { return a + b; }
double add(double a, double b) { return a + b; }
add(1, 2);        // 精确匹配 int 版
add(1.0, 2.0);    // 精确匹配 double 版
// add(1, 2.0);   // ❌ ambiguous:int→double 与 double→int 打平

// NULL 的教训:整数 0 优先匹配 int 版
void f(int);
void f(char*);
f(NULL);           // g++/clang 报 ambiguous,别家可能选 f(int)——都拿不到指针版;请用 f(nullptr)

// 默认参数:从右往左,声明处写一次
void greet(std::string name = "World");
greet();           // Hello, World!
greet("C++");      // Hello, C++!

// inline:允许定义进头文件而不违反 ODR
inline int square(int x) { return x * x; }  // 放 .h 多处包含不冲突
重载和默认参数别同时提供同一种「省参数」的便利f(int)f(int, int = 0) 并存时,f(1) 两个都能匹配上,直接 ambiguous 编译失败。另一个高频错误是把默认值在声明和定义处各写一遍——头文件里写了 void greet(std::string name = "World");,.cpp 的定义处就不能再写 = "World",否则报重复默认参数。
参数超过三四个、且多为可选时,别再堆默认参数——把它们收进一个小结构体,用 C++20 指定初始化器 Options{.retries = 3} 调用,调用点自文档化。重载的进阶形态是模板与完美转发,见 07 章。

string 拥有并管理自己的字符,string_view 只是借看别人的一段字符——借来的东西有归还期限。

std::string 是 C++ 标准库提供的字符串类,支持动态增长、拼接、查找等操作,远比 C 风格字符串 char[] 安全易用。C++17 引入 std::string_view,是字符串的只读非拥有视图,避免不必要的内存分配和拷贝。
#include <string>
#include <string_view>

std::string s = "Hello";
s += ", C++!";           // 拼接
s.size();                // 长度:11
s.find("C++");           // 查找:7
s.substr(0, 5);         // 子串:"Hello"

// string_view:零拷贝的只读视图(C++17)
void print(std::string_view sv) {
    std::cout << sv;  // 不会拷贝字符串内容
}
print("literal");   // 字面量直接传入,零开销
print(s);            // string 隐式转换
string_view 不给源字符串续命:std::string_view sv = s + "!"; 右边的临时 string 在整条语句结束就析构,sv 当场悬垂——g++ 15 开 -Wall -Wextra 编译零警告,之后读 sv 是 UB,ASan 报 heap-use-after-free(字符串短到落进 SSO 栈内缓冲时连 ASan 都未必抓得到)。string_view 当函数参数用最安全;要存进成员、容器或返回值,就拷一份 std::string。另外它不保证以 \0 结尾,别把 .data() 直接喂给要 C 字符串的 API。
函数参数如果只读字符串且不需要所有权,优先用 std::string_view 而非 const std::string&,因为 string_view 可以接受字符串字面量而无需构造临时 string 对象。顺带记两个字面量后缀:"..."s 得到 std::string(免去 char* 类型歧义,如 auto x = "hi"s;)、"..."sv 得到 string_view,分别需 using namespace std::string_literals / std::string_view_literals

C++ 的标准输入输出建立在流(stream)抽象上:cincoutcerr 是三个全局流对象,用 >> / << 运算符搬运数据。把流理解成「带状态的字符管道」——读取可能失败,失败会记录在流的状态位里,后续操作全部受影响,这是用好 iostream 的核心心智模型。

输出:cout 与 cerr

  • std::cout 是标准输出,std::cerr 是标准错误(默认不缓冲,专用于报错);<< 返回流自身,因此可以链式书写;
  • std::endl = 输出换行 + 强制刷新缓冲区;循环里大量输出时用 "\n" 明显更快。

输入:cin >> 的规则

  • cin >> x空白分词:跳过前导空格 / Tab / 换行,读到下一个空白为止——所以它天然读不了带空格的整行;
  • 遇到非法输入(比如给 int 喂了字母),流进入失败状态,之后所有读取直接返回,且非法字符仍留在流里;恢复三步曲:cin.clear() 清状态位 → cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n') 丢弃整行脏数据 → 重新读取;
  • 流对象可以转成 bool(成功为 true),所以每次读取都应检查if (cin >> x) / while (cin >> x) 是惯用法;
  • std::getline(cin, s) 读一整行(不含行尾换行符),空格不再是分隔符。

性能开关:竞赛两件套

  • std::ios::sync_with_stdio(false) 解除 iostream 与 C stdio 的同步,std::cin.tie(nullptr) 解除「cin 读取前自动刷新 cout」的绑定,两者合用能让 cin/cout 快数倍,是竞赛标配;
  • 副作用:之后不能再混用 printf/scanf,交互式程序要自己保证提示语在读输入前已刷新输出。

现代输出:std::println(C++23)与 std::format(C++20)

  • std::println("{} 是 {} 岁", name, age) 一行搞定:类型安全(格式串与参数在编译期核对,填错类型直接编译报错,不像 printf 运行期才炸)、自带换行,取代 cout << a << b << "\n" 那串 <<
  • 只要字符串不打印用 std::format(返回 std::string);打印到标准错误用 std::print(stderr, ...);对齐 / 补零 / 小数位等格式见 10 章 format 卡;
  • 本指南从 05 章标准库起默认用 println 打印(03/04 基础章仍用 cout,不强求新工具链);此后 cout 只在需要流本身的特性(手动刷新、流状态、把流对象当参数传)时才出现。工具链要求 GCC 14+ / Clang 18+ / MSVC 19.40+,更旧的环境回退到 cout 或第三方 {fmt} 库(std::format 的前身,接口几乎一致)。
#include <iostream>
#include <print>       // C++23:std::print / println
#include <limits>
#include <string>

// 惯用法:读取失败就清理流、重新提示,直到读到合法值
int age;
std::cout << "请输入年龄: ";
while (!(std::cin >> age)) {           // 读失败 → 流转为 false
    std::cin.clear();                    // 1. 清除失败状态位
    std::cin.ignore(                     // 2. 丢弃这一整行脏输入
        std::numeric_limits<std::streamsize>::max(), '\n');
    std::cout << "无效输入,请输入数字: ";
}

// >> 之后接 getline:先清掉流里残留的换行符
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
std::string name;
std::getline(std::cin, name);          // 读一整行,可含空格

// 输出两种写法:经典 cout vs 现代 println(本指南 05 章起默认后者)
std::cout << name << " 是 " << age << " 岁\n";
std::println("{} 是 {} 岁", name, age);  // 等价、更短、类型安全

// 竞赛加速两件套(之后不可再混用 printf/scanf)
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
不检查 cin >> x 是否成功是高发 bug:一旦输入非法,流进入失败状态,后续所有读取立即返回、变量保持旧值,非法字符还留在流里——放进循环就是死循环 + 脏数据。正确姿势是把读取本身当条件(while (cin >> x)),而不是 while (!cin.eof())。另一个经典坑是 cin >> n 之后直接 getline>> 会把行尾换行符留在流里,getline 读到它立刻返回空串;混用前先用 cin.ignore(...) 清掉残留换行。
调试打印用 std::cerr 而不是 cout:cerr 不缓冲,程序崩溃前的每一条都能落到终端;cout 的最后几行可能还躺在缓冲区里就随崩溃消失,害你误判「没执行到这里」。重定向时两者也分流——./app > out.txt 只带走 cout,报错仍留在屏幕上。

文件流与字符串流复用了 iostream 的同一套接口:会用 cin/cout,就会用 ifstream / ostringstream,区别只在数据源——一个连着文件,一个连着内存中的字符串。它们都是 RAII 对象:构造即打开、析构自动关闭,不需要手动 close()——这是 RAII 的教科书案例(原理详见 08 章)。

文件流 fstream

  • std::ifstream fin("data.txt") 构造即打开;打开可能失败(文件不存在、无权限),用前必须判断if (!fin)
  • 逐行读的标准姿势是 while (std::getline(fin, line))——读取与判断合二为一,文件读完循环自然结束;逐词读用 while (fin >> word)
  • ofstream 默认截断(清空原内容)再写;要追加写需传 std::ios::app
  • 二进制文件加 std::ios::binary 打开,配合成员函数 read() / write() 按字节搬运。

字符串流 sstream

  • ostringstream 在内存里拼接内容,最后用 str() 取结果,适合分多步构造复杂文本;
  • istringstream 把一个字符串当输入流来解析——「getline 读整行 + istringstream 解析字段」是 C++ 健壮读入的标准组合(呼应上一张卡):行的边界由 getline 保证,某一行解析失败也只影响局部,不会污染 cin 或整个文件流。
#include <fstream>
#include <sstream>
#include <string>

std::ifstream fin("scores.txt");  // 构造即打开
if (!fin) {                             // 必须先判断是否打开成功
    std::cerr << "无法打开文件\n";
    return 1;
}

// 逐行读 + istringstream 解析:健壮读入的标准姿势
std::string line;
while (std::getline(fin, line)) {
    std::istringstream iss(line);       // 把这一行当成输入流
    std::string name;
    int score;
    if (iss >> name >> score) {    // 解析失败只影响这一行
        std::cout << name << ": " << score << "\n";
    }
}                                        // fin 离开作用域自动关闭(RAII)

// 写文件:默认截断原内容,ios::app 表示追加
std::ofstream fout("app.log", std::ios::app);
fout << "new entry\n";

// ostringstream:在内存里拼接
std::ostringstream oss;
oss << "(" << 3 << ", " << 4 << ")";
std::string point = oss.str();      // "(3, 4)"

// 二进制:加 ios::binary,用 write()/read() 按字节搬运
int vals[] = {1, 2, 3};
std::ofstream bout("data.bin", std::ios::binary);
bout.write(reinterpret_cast<const char*>(vals), sizeof(vals));
ifstream 打开失败不抛异常、不报错,后续所有读取静默失败,程序会拿着空数据继续跑——务必先 if (!fin) 检查。另外不要写 while (!fin.eof()) 再在循环体里读:eof 标志只有在读过头之后才置位,这种写法会把最后一次读到的数据处理两遍;把读取放进循环条件 while (std::getline(fin, line)) 才正确。
不需要手动调用 fin.close():文件流是 RAII 对象,离开作用域时析构函数自动关闭文件。真正需要显式 close 的场景只有一种——想在对象销毁前提前释放文件句柄(例如接下来要重命名或删除该文件)。
进阶 · 精确规则与陷阱(第一遍可跳过)

std::string 存的是一串字节,不是一串字符。UTF-8 下一个字符占 1~4 个字节,而 string 的下标、size()substr 全按字节算——中文 / emoji 的各种乱码都源于此。先把「一个字符到底几个字节」看具体。

UTF-8 是变长编码:一个字符 1~4 字节

  • 1 字节——ASCII(英文、数字、半角标点):'A' = 0x41
  • 2 字节——带重音拉丁、希腊 / 西里尔:'é''π'
  • 3 字节——中日韩、假名、常见符号:'你' = E4 BD A0(三个字节)、'€'
  • 4 字节——emoji、罕用字:'🎉' = F0 9F 8E 89
  • 怎么判断长度:只看头字节——0xxxxxxx=1 字节、110xxxxx=2、1110xxxx=3、11110xxx=4;10xxxxxx续接字节,不是任何字符的开头。

于是这些操作全会出错

  • "你好".size()6 不是 2;想「取前 2 个字」写 substr(0, 2),只截到「你」的头 2 个字节,末尾冒出半个乱码字符;
  • s[0] 取到的是 0xE4(「你」的第一字节),单独打印是 ? 或方块;
  • std::reverse(s.begin(), s.end()) 会把每个多字节字符内部的字节也倒过来,整串彻底乱码——按字符反转必须先解码。

四种字符类型:只有 char32_t「一个 = 一个字符」

  • char:1 字节,UTF-8 的载体,现代 C++ 字符串默认走它;
  • char8_t(C++20):UTF-8 码元,配 u8"..." / std::u8string不与 char 隐式互通
  • char16_t(UTF-16)/ char32_t(UTF-32):char32_t 定长 4 字节、一个就装一个码点,要「按字符」处理就把串转成 std::u32string
  • wchar_t:宽度不可移植——Windows 16 位(UTF-16)、Linux / macOS 32 位(UTF-32),别拿它跨平台存储。

类型之间怎么转:同编码一步搞定,跨编码没有标准解

  • char ⇄ char8_t(stringu8string:两者装的都是 UTF-8 字节,只是类型标签不同——reinterpret_cast 逐字节搬即可,不重新编码(见代码);
  • UTF-8 ⇄ UTF-16 ⇄ UTF-32(跨编码):要真正重算字节,而标准库没有好用的现成设施。老接口 <codecvt> / std::wstring_convertC++17 就被弃用(仍能编译但告警,且至今没给替代品)——别在新代码里用;
  • 实务转码:跨平台用 ICU / utf8cpp;Windows 上直接 MultiByteToWideChar / WideCharToMultiByte(见代码);POSIX 上还有 iconv
  • 别混淆:字面量前缀 u8"" / u"" / U"" / L""编译期就把编码定死,跟运行期把一个已有字符串转码是两码事——难点永远在后者。

Windows 特有的坑

  • Win32 的 ...W 版 API(CreateFileW 等)只吃 wchar_t*(UTF-16);控制台默认代码页常是 GBK 而非 UTF-8,直接 cout 中文可能出方块;
  • 解法:内部统一 UTF-8 的 std::string,只在系统调用边界用 MultiByteToWideChar / WideCharToMultiByte 转 UTF-16(见代码),并给 MSVC 加 /utf-8

标准库不做 Unicode 语义,别自己造

  • 大小写折叠、规范化(é 既可是 1 个码点、也可是 e + 组合重音共 2 个码点)、按字素簇切分(一个「👨‍👩‍👧」由好几个码点拼成)、按语言排序——标准库一概不管<cctype> 只认 ASCII;
  • 正经处理上 ICU(最全)、utf8cpp(只做 UTF-8 遍历 / 校验,轻量)或 Boost.Locale
#include <string>
#include <algorithm>

// UTF-8 变长:一个「字符」占 1~4 字节
std::string s = "A你🎉";         // A=1 字节  你=3 字节  🎉=4 字节
s.size();                       // 8,不是 3!返回的是字节数

// 下标 / substr 都按「字节」走,不按「字符」
(unsigned char)s[0];            // 0x41 = 'A'(正好一个完整字符)
(unsigned char)s[1];            // 0xE4 = '你'的第 1 字节,单独看是乱码
s.substr(0, 2);                 // "A" + 半个'你' → 末尾一个乱码字节

// 正确地数「字符数」:跳过续接字节(10xxxxxx)
std::size_t n = 0;
for (unsigned char c : s)
    if ((c & 0xC0) != 0x80) ++n;  // n = 3

// 要按字符随机访问,转成定长码点的 u32string
std::u32string u32 = U"A你🎉";       // u32.size()==3,u32[1]==U'你'

// 四种字符类型:只有 char32_t「一个 = 一个码点」
char     a  = 'A';              // 1 字节
char8_t  c8 = u8'A';            // C++20,UTF-8 码元,不与 char 互通
char16_t h  = u'你';             // UTF-16 码元
char32_t p  = U'你';             // 一个码点,定长 4 字节
wchar_t  w  = L'A';             // 平台相关:Win 16 位 / Linux 32 位

// string ⇄ u8string:同是 UTF-8 字节,reinterpret_cast 逐字节搬
std::u8string u8s = u8"你好";
std::string s2(reinterpret_cast<const char*>(u8s.data()), u8s.size());

// 跨编码(UTF-8⇄16⇄32):<codecvt> 已于 C++17 弃用,改用 ICU/utf8cpp;
// Windows 上用 MultiByteToWideChar / WideCharToMultiByte:
// —— UTF-8(string) → UTF-16(wstring),喂给 ...W 版 API ——
// 需 <windows.h>;反向转换用 WideCharToMultiByte
std::wstring to_utf16(const std::string& u8) {
    int len = MultiByteToWideChar(CP_UTF8, 0, u8.data(), (int)u8.size(), nullptr, 0);
    std::wstring w(len, L'\0');
    MultiByteToWideChar(CP_UTF8, 0, u8.data(), (int)u8.size(), w.data(), len);
    return w;
}
char 的符号性是隐形雷char 是否有符号由实现决定(x86 上通常有符号),于是非 ASCII 字节(值 > 127)当成 char 就是负数——直接喂给 <cctype>isdigit / tolower 是 UB(它们只接受 unsigned char 范围或 EOF),在中文串上可能崩或返错。凡按字节处理先转 unsigned char(见速查页文本食谱)。另一个 C++20 新坑u8"..." 现在类型是 const char8_t*不能直接赋给 std::string——要么 reinterpret_cast,要么改用 std::u8string
一条能躲开绝大多数坑的工程约定:内部一律用 UTF-8 存 std::string,只在跟操作系统 API(尤其 Windows 宽字符)打交道的边界上做显式转换。给 MSVC 加 /utf-8(同时管源码字符集和执行字符集),源文件存成无 BOM 的 UTF-8。真要按字符做切分 / 计数 / 排版,别自己造,直接上 ICU 或 utf8cpp。

C++ 是编译型语言:源码经过预处理 → 编译 → 汇编 → 链接四个阶段才变成可执行文件。这张流程图不是背景知识——每个阶段报的错长相不同,看懂阶段就等于拿到排错地图。

四个阶段各干什么

  • 预处理:纯文本操作——#include 把头文件原样粘贴进来、#define 展开、#ifdef 裁剪,产物仍是 C++ 源码(g++ -E 可看);
  • 编译 + 汇编:把每个 .cpp(连同粘贴进来的头文件,合称一个翻译单元各自独立翻译成目标文件 .o——语法错误、类型不匹配、未声明的名字都在这里报;
  • 链接:把所有 .o 与库合并,把「这里调用了 f」的引用对上「f 的定义在这」——找不到定义(undefined reference)或定义撞车(duplicate symbol)在这里报。

头文件为什么存在:声明与定义分离

  • 头文件(.h/.hpp)放声明(函数签名、类定义),源文件(.cpp)放实现——每个 .cpp 独立编译时只需看到声明,定义留到链接期去找;
  • ODR(单一定义规则):整个程序里每个函数 / 变量只能有一份定义——把非 inline 的函数定义写进头文件、被两个 .cpp 包含,链接期就是 duplicate symbol(inline 的现代含义正是放宽这条,见本章函数卡);
  • 头文件开头写 #pragma once(或 include guard),防止在同一翻译单元里被重复粘贴。

多文件项目里的链接错误

  • 报错分诊的基本功(编译期 / 链接期 / 运行期怎么区分)见 01 章「看懂第一条报错」,这里只补多文件场景特有的那一种:
  • undefined reference to ... 在单文件时多半是 main 拼错,在多文件项目里则几乎总是「定义所在的 .cpp 没参与链接」——忘了把它加进构建命令或 CMake 的源文件列表,或者忘了用 -l 链上第三方库。
// 多文件项目的最小形态:声明进头文件,实现进 .cpp
// ---- math.h ----
#pragma once
int add(int a, int b);        // 声明:告诉编译器"有这么个函数"

// ---- math.cpp ----
#include "math.h"
int add(int a, int b) { return a + b; }   // 定义:全程序仅此一份

// ---- main.cpp ----
#include <iostream>
#include "math.h"
int main() {
    std::cout << add(1, 2) << "\n";
}

// 分步编译,看清两个阶段:
// g++ -std=c++20 -Wall -Wextra -c math.cpp   → math.o(只编译)
// g++ -std=c++20 -Wall -Wextra -c main.cpp   → main.o
// g++ math.o main.o -o app                   → 链接成可执行文件
// 若漏掉 math.o:undefined reference to 'add(int, int)' ← 链接期错误
undefined reference 的病根从来不是「忘了 #include」——头文件只提供声明,include 一百次也不会产生定义;真正原因是定义所在的 .cpp / 库没参与链接。反过来 duplicate symbol 多半是把非 inline 的函数定义写进了头文件、被多个 .cpp 包含。记住:include 管编译期的「声明可见」,链接参数管「定义从哪来」,两条线别混。
手敲 g++ 命令只适合单文件练习:一旦文件多起来,「哪些 .cpp 要编、按什么顺序链、依赖哪些库」就该交给构建系统去生成,见 11 章 Make 与 CMake 两卡。#pragma once 虽非标准但三大编译器都支持,比手写 include guard 更省事,现代项目基本都用它。

「int 就是整数」在精通级远远不够:类型多宽不可移植、混用有无符号会静默出错、有符号溢出是 UB 而非回绕。这一层是无数隐蔽 bug 的根。

定宽类型:别赌 int 的宽度

  • int / long 的宽度由平台决定(long 在 Win64 是 32 位、Linux64 是 64 位)——要确定宽度用 <cstdint>int32_t / uint64_t
  • 存大小 / 索引用 size_t(无符号)、指针差用 ptrdiff_t、指针整数值用 intptr_t

整型提升与算术转换

  • int 窄的类型参与运算前先提升到 intchar + char 结果是 int);
  • 有符号与无符号混算,有符号一方被转成无符号——负数瞬间变成巨大正数。

溢出:有符号是 UB,无符号是回绕

  • 有符号溢出 = 未定义行为:编译器假设它永不发生,据此删掉你的溢出检查;
  • 无符号溢出 = 定义良好的模 2ⁿ 回绕——既是特性也是陷阱(见 pitfall)。
#include <cstdint>

int32_t a = 2000000000;
int32_t b = a + a;        // 有符号溢出 = UB!不是回绕

// 有无符号混用:-1 变成巨大正数
int s = -1;
unsigned u = 1;
bool bad = (s < u);          // false!s 被转成 4294967295

// 遍历用 size_t,别拿 int 和 .size() 比
std::vector<int> v(10);
for (std::size_t i = 0; i < v.size(); ++i) { }  // 类型一致
经典死循环:for (unsigned i = n - 1; i >= 0; --i) 永不终止——无符号数恒 >= 0,减到 0 再自减会回绕成 SIZE_MAX。倒序遍历要么用有符号下标,要么写 for (size_t i = n; i-- > 0; )。另一个高发坑是 int i; … i < v.size()size() 返回无符号,i 被转无符号,一旦掺入负逻辑就出错——开 -Wsign-compare / -Wconversion 能提前抓到。
需要确定宽度、做位运算、或与协议 / 文件格式打交道时,一律用 <cstdint> 的定宽类型;size_t 专用于大小与索引。有符号溢出的 UB 可用 -fsanitize=undefined(见 11 章 Sanitizer)在运行期抓现行。

解码卡说过 <<「左边是整数时是位移」——这里就是它的另一副面孔。位运算逐比特处理整数,是标志位、掩码、底层协议、性能优化的基本功。

六个运算符

  • & 按位与、| 按位或、^ 按位异或、~ 按位取反;
  • << / >> 左移 / 右移:x << n 相当于 x * 2ⁿx >> n 相当于 x / 2ⁿ(对无符号数)。

和逻辑运算符别混

  • & / |逐位运算、两边都求值&& / ||逻辑运算、有短路——a && b 里 a 为假就不算 b。用错一个就少一个字符,行为却天差地别。

典型用途

  • 标志位:多个开关塞进一个整数,| 置位、& 查位、& ~flag 清位;
  • 掩码取字段(rgb >> 16) & 0xFF 取出 R 分量;
  • 奇偶 / 乘除 2 的幂x & 1 判奇偶、x << 1 乘 2。
unsigned a = 0b1100, b = 0b1010;
a & b;   // 1000 —— 都为 1 才留
a | b;   // 1110 —— 有一个 1 就留
a ^ b;   // 0110 —— 不同才为 1
~a;      // 按位取反(逐比特翻转)

// 标志位:一个整数装多个开关
constexpr unsigned READ = 1, WRITE = 2, EXEC = 4;
unsigned perm = READ | WRITE;      // 置位:可读可写
bool canWrite = perm & WRITE;       // 查位:非 0 即有
perm &= ~WRITE;                     // 清位:去掉写权限

// 掩码取字段 & 移位
unsigned rgb = 0x3399FF;
unsigned g = (rgb >> 8) & 0xFF;    // 取绿色分量 = 0x99
int n = 7;
bool odd = n & 1;                  // 判奇偶:最低位是 1 即为奇数
优先级极易出错&|^ 的优先级低于 ==< 等比较运算符,所以 x & 1 == 0 实际被解析成 x & (1 == 0)x & 0——永远是 0!判奇偶要写 (x & 1) == 0。此外负数 / 有符号数的右移与溢出行为是实现定义或 UB,位运算尽量用 无符号类型unsigned / uint32_t)。
一堆布尔状态与其用多个 bool,不如定义一组 2 的幂常量(或 enum)用标志位打包,省内存又能一次性 | 组合、& 检测——这是系统 API(如文件打开模式 O_RDWR | O_CREAT)的通用惯用法。

三件收拾 C 遗产的设施:名字要分组防冲突,枚举要强类型,转换要显式说出意图。

三个绕不开的基础设施:命名空间namespace)把名字分组以避免冲突,using 引入名字;enum class(C++11 强类型枚举)不隐式转 int、名字被作用域隔离,比 C 风格 enum 安全;C++ 用四种命名转换取代含糊的 C 风格强转,让转换意图在代码里一目了然。
// 命名空间:给名字分组,避免冲突
namespace app {
    int version = 1;
}
using app::version;          // 引入单个名字
using namespace std;        // 引入整个命名空间(头文件中慎用)

// enum class:强类型枚举(不隐式转 int、作用域隔离)
enum class Color { Red, Green, Blue };
Color c = Color::Red;         // 必须带作用域前缀
int n = static_cast<int>(c);   // 需显式转换,不会隐式退化

// 四种命名转换,各司其职
double d = static_cast<double>(n);         // 相关类型间的安全转换
Derived* dp = dynamic_cast<Derived*>(basePtr);  // 多态向下转型:失败得 nullptr
int& nc = const_cast<int&>(constRef);      // 去除 const(仅在确无副作用时)
auto bits = reinterpret_cast<std::uintptr_t>(dp); // 底层位模式重解释(危险)
const_cast 只能去掉「访问路径上的 const」,写本来就声明为 const 的对象照样是 UB:const int x = 1; const_cast<int&>(x) = 2; 之后打印 x,g++ 15 在 -O0 和 -O2 下都输出 1——x 早被当常量折叠,写入无声丢失。另外 dynamic_cast 要求源类型是多态的(至少有一个虚函数),否则直接编译错误:cannot 'dynamic_cast' ... (source type is not polymorphic)(g++ 15 报错节选)。
优先用 enum class 而非裸 enum;类型转换优先 static_cast,绝不用 C 风格 (T)x 强转——它会在编译期静默选择包括 reinterpret_cast 在内最危险的一种。

指针

上一章的语法地基里,指针是最硬核也最危险的一块,值得单独拎出来讲透。这一章从「指针 vs 引用」的取舍讲起,依次拆开 const 正确性、指针与数组的退化、以及函数指针三件硬骨头——它们是读懂任何 C++ 代码的底层素养。掌握了裸指针的机制与陷阱,才谈得上第 08 章用智能指针与 RAII 去驯服它:本页「裸指针 → 智能指针」这条主线,正是从这里起步。本章同样分「先读」与「进阶」两段,标着「进阶」的卡第一遍可以跳过。

先读 · 写出能跑的代码

指针和引用都是「间接访问」,但一个灵活到危险、一个安全到受限——什么时候用哪个,是写 C++ 每天都要做的选择。

指针是一个存着地址的变量——它自身占内存、有自己的地址,可以为空(nullptr)、可以改指向别处、可以做指针运算:灵活,也正因如此危险。引用是变量的别名int& r = x 之后,r 就是 x 的另一个名字,必须声明时绑定、终生不能改绑、不能为空。底层上引用大多用指针实现,但语义上它没有独立身份——取不到「引用本身」的地址,也无法让别名改指。所以经验法则是默认用引用,需要「可空 / 可改指 / 做指针运算」时才用指针(见本卡 tip)。C++11 还用 nullptr 取代了 C 的 NULL:后者本质是整数 0,会与整型重载相混,nullptr 有独立类型(std::nullptr_t),更安全。
int x = 42;

// 指针
int* p = &x;       // p 指向 x
*p = 100;          // 通过指针修改 x 的值
p = nullptr;       // 空指针(C++11)

// 引用
int& r = x;        // r 是 x 的别名
r = 200;           // 等价于 x = 200

// 函数参数传递:引用避免拷贝
void process(const std::string& s) {
    // s 是原字符串的只读引用,零拷贝
}
永远不要返回局部变量的引用或指针!函数返回后局部变量被销毁,引用/指针会变成悬垂引用(dangling reference),访问它是未定义行为。
选择原则:能用引用就别用裸指针——引用不会为空、不会悬空重指,语义更强。真正需要指针的三种场景:可能为空(用 nullptr 表示「无」)、需要重新指向、需要做指针运算遍历内存。而管理动态内存的所有权,现代 C++ 一律交给智能指针(见 08 章「智能指针三件套」),裸 new/delete 几乎不再手写。后面几张卡把裸指针剩下的硬核知识——常量正确性、指针运算、数组视图 span、二级指针、函数指针——一次讲透。

同一个 const 挪一格位置,锁住的东西就完全不同——读懂它是读懂一切 C++ 接口签名的前提。

裸指针叠加 const 有两个独立的维度,区分它们的口诀是「从右往左读,const 修饰它左边最近的东西」const int* p(等价 int const* p)是指向常量的指针——不能通过 p 改数据,但 p 本身能重新指向别处;int* const p常量指针——能改数据,但 p 绑定后不能再指向别处;const int* const p 则两者都锁死。把接口参数尽量标成 const,就是 const 正确性:向调用者承诺「我只读不改」,也让 const 对象能被传进来。
int a = 1, b = 2;

// ① 指向常量的指针:数据只读,指针可重指
const int* p1 = &a;
// *p1 = 9;   ❌ 不能改数据
p1 = &b;         // ✅ 可以重新指向

// ② 常量指针:数据可改,指针锁定
int* const p2 = &a;
*p2 = 9;         // ✅ 可以改数据(a 变 9)
// p2 = &b;   ❌ 不能重指

// ③ 两者都锁死
const int* const p3 = &a;

// const 正确性:只读接口标 const,才能接收 const 数据
size_t strLen(const char* s) {   // 承诺不改 s 指向的内容
    size_t n = 0;
    while (s[n] != '\0') ++n;
    return n;
}
const 的位置差一格语义就反过来,这是新手最常读错的语法之一。养成从右往左读的习惯:看到 * const 就知道锁的是「指针本身」,看到 const T* 就知道锁的是「所指数据」。顶层 const(指针本身只读)在按值传参时会被忽略,真正影响接口契约的是底层 const(数据只读)。
接口参数的 const 从第一天就写上:事后再补会沿调用链一路传染(每一层都得跟着改签名),成本远高于当初写对。还有一条省心判断:形参里的顶层 const(如 T* const p)对调用方毫无影响、几乎不必写;真正构成接口契约、必须认真对待的是底层 constconst T* / const T&)。

数组名一传进函数就悄悄变成指针、长度随之蒸发——这个叫「退化」的机制是无数越界 bug 的共同源头。

数组和指针在 C++ 里关系暧昧但并不等价。数组名在大多数表达式里会退化(decay)成「指向首元素的指针」,因此 a[i] 本质就是 *(a + i)——下标只是指针运算的语法糖。指针加减以元素为单位p + 1 实际地址增加 sizeof(*p) 字节,而非 1 字节,这也是为什么指针必须带类型。退化的代价是丢失长度信息:一旦数组传进函数变成裸指针,sizeof 就只能量出指针大小,长度必须额外传。
int a[5] = {10, 20, 30, 40, 50};

int* p = a;          // 数组名退化为 &a[0]
*(p + 2);            // == a[2] == 30,地址加了 2*sizeof(int)
p[3];                // == *(p + 3) == 40,下标就是指针运算
++p;                 // 现在 p 指向 a[1]

// 用指针遍历(half-open 区间 [begin, end))
for (int* it = a; it != a + 5; ++it)
    std::cout << *it << ' ';

sizeof(a);           // 20:整个数组(5 * 4 字节)

void sum(int* arr, size_t n) {
    sizeof(arr);      // ⚠️ 只有 8(x86-64 Linux):arr 已退化成指针
}
退化后 sizeof 量的是指针而非数组,靠 sizeof(arr)/sizeof(arr[0]) 求长度在函数内部必然失效——长度务必单独传参。此外指针运算越界即未定义行为:允许算出「尾后指针」(a + 5)用于比较,但解引用它或再往后走都是 UB。
现代 C++ 里几乎不该手写这类裸指针 + 长度的接口:用 std::vector 自带长度、自动扩容,或用 C++20 的 std::span(非拥有的「指针 + 长度」视图,只读文本则用 std::string_view,见 02 章 string 与 string_view)。它们把「指针 + 长度」打包成一个带边界的对象,既保留零拷贝,又不丢长度信息。
进阶 · 精确规则与陷阱(第一遍可跳过)

函数想收「一段连续元素」,过去要么锁死容器类型、要么裸指针加长度分开两个参数传——span 用一个对象终结了这个两难。

std::span<T>(C++20)把「指针 + 长度」打包成一个对象,是一层非拥有视图:它不申请、不拷贝、不释放内存,只「借看」一段连续元素——底层是 C 数组、std::arraystd::vector、还是裸 ptr + n,都能一视同仁地接。它正是上一卡「退化丢长度」难题的现代答案:长度随身携带,于是能直接 range-for、.size().subspan() 切片。可写用 span<int>,只读用 span<const int>(只读字符串则用更专门的 string_view)。
#include <span>

// 一个函数吃遍所有连续容器,且不丢长度
int sum(std::span<const int> xs) {
    int s = 0;
    for (int v : xs) s += v;   // 自带 begin/end 与 size
    return s;
}

int carr[] = {1, 2, 3};
std::vector<int> vec = {4, 5, 6};
sum(carr);              // C 数组 → span,长度自动推出 3
sum(vec);               // vector 也行,零拷贝

// 可写视图 + 切片
std::span<int> sp = vec;
sp[0] = 99;             // 直接改到 vec
auto tail = sp.subspan(1);  // 从第 1 个到末尾的子视图
span 不拥有数据,生命周期完全依赖底层容器:容器一旦销毁或重新分配(如 vector::push_back 触发扩容),手里的 span 立刻变悬垂视图,再访问就是 UB。别把 span 存下来跨越它所指容器的生命周期,更别返回指向局部数组的 span。
接口设计的现代默认:只读一段连续元素用 std::span<const T> 取代 (const T* p, size_t n) 双参数,甚至取代 const std::vector<T>&——因为 span 还能接 C 数组与 std::array,通用性更强,又不逼调用方必须用 vector。容器本身(vector/array)见 05 章。

指针本身也是变量、也有自己的地址——把这句话想通,T** 的三个真实用途就全都顺理成章。

指针本身也是变量、也有地址,于是可以有指向指针的指针 T**。三个真实用途:① 输出参数——函数想改写调用方的指针(例如替它分配内存再让它指过去),就得拿到「指针的地址」,即传 T**;② 指针数组——一排指针连续排列,char* argv[] 本质就是 char**,指向一组字符串;③ 动态锯齿二维数组(每行单独分配)。关键是别把 T** 和真正的二维数组 T[M][N] 混为一谈:后者是一整块连续内存、退化成 T(*)[N](行指针),二者内存布局根本不同。
// ① 输出参数:函数替调用方分配并回填指针
void makeBuf(int** out, int n) {
    *out = new int[n]{};   // 写进 *out,调用方的指针被改
}
int* buf = nullptr;
makeBuf(&buf, 8);        // 传 buf 的地址(int**)

// ② 指针数组 = char**:命令行参数就是这个形状
int main(int argc, char** argv) {
    for (int i = 0; i < argc; ++i)
        std::cout << argv[i];  // argv[i] 是 char*,一个字符串
}
int**int[3][4]!真正的二维数组是连续内存,形参要写 int (*)[4](即 int arr[][4]),用 int** 去接会因布局不符而错乱。判据:指针数组(每个元素是独立指针)才是 T**连续二维数组T(*)[N]
现代 C++ 里 T** 的两大场景都有更安全的替代:输出参数改用返回值(或 std::optional / 引用参数);一组对象改用 std::vector<std::string>std::vector<std::unique_ptr<T>>(见 08 章)。真正绕不开裸 T** 的,主要是对接 C API(argv、部分系统调用)时。

数据有地址,代码也有地址——把「行为」存进变量、当参数传出去,回调机制的底层就这么简单。

函数名同样会退化成函数指针——指向代码而非数据的地址,因此可以把「行为」当参数传递,这就是回调的底层机制(C 标准库的 qsort、GUI 事件处理都靠它)。声明语法 返回类型 (*名字)(参数表) 那对括号不能省,否则会被解析成「返回指针的函数」。裸函数指针的短板是只能指向无状态的普通函数,捕获了变量的 lambda 装不进去。
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }

// 声明一个函数指针并赋值(& 可省)
int (*op)(int, int) = add;
op(3, 4);          // 7,等价于 (*op)(3, 4)
op = mul;              // 换一个行为

// 把行为当参数:回调
int apply(int x, int y, int (*f)(int, int)) {
    return f(x, y);
}
apply(6, 7, mul);  // 42

// 现代写法:std::function 能装下带捕获的 lambda
int base = 100;
std::function<int(int)> g = [base](int x) { return x + base; };
g(5);              // 105(裸函数指针做不到)
只有无捕获的 lambda 能隐式转成函数指针;一旦带捕获,赋给函数指针直接编译错——g++ 15 报 error: cannot convert 'main()::<lambda(int)>' to 'int (*)(int)'。这不是语法刁难:捕获的变量得有地方存,而裸函数指针只是一个代码地址,装不下状态——这种时候改用 std::function 或模板参数。
优先级:模板参数(如 STL(标准模板库)算法接收的可调用对象,零开销、可内联)> std::function(能统一存放函数 / lambda / 函数对象,但有类型擦除开销)> 裸函数指针(仅剩 C 接口互操作时用)。Lambda 见 05 章「算法库与 Lambda」,std::function 的类型擦除原理见 11 章「设计模式与惯用法」。

面向对象编程

语法地基打牢,进入 C++ 的第一大范式。类与对象是核心:掌握封装、继承、多态三大特性,以及现代 C++ 对拷贝/移动语义的精确控制,是写出健壮代码的关键。本章分「先读」与「进阶」两段——先读段足以让你写出自己的类并用上多态;进阶段的 vtable 底层与移动语义是精通向内容,第一遍可以跳过。

先读 · 写出能跑的代码

类把「数据长什么样」和「谁有权碰它」绑在一起——访问控制防的不是黑客,是未来乱改状态的自己。

将数据(成员变量)和操作(成员函数)封装在一起。访问控制有三级:public(外部可访问)、private(仅类内部)、protected(类内部 + 派生类)。class 默认成员访问与继承方式均为 private,struct 默认均为 public,除这两处默认值外两者没有区别。
class Vec2 {
public:
    double x, y;

    // 构造函数 + 初始化列表
    Vec2(double x, double y) : x(x), y(y) {}

    // 成员函数
    double length() const {
        return std::sqrt(x*x + y*y);
    }

    // 运算符重载(机制详见本章「运算符重载」卡)
    Vec2 operator+(const Vec2& other) const {
        return {x + other.x, y + other.y};
    }
};

Vec2 a(3, 4);
a.length();  // 5.0
class 不写访问说明符时成员全是 private——外部一碰就是编译错,g++ 15 报 error: 'double Account::balance_' is private within this context。另一个隐蔽坑在构造函数:成员初始化列表的执行顺序由成员声明顺序决定,与你在列表里书写的顺序无关——若用成员 b 去初始化 a、而 a 声明在前,读到的是未初始化的 b;g++ 开 -Wall 会给 -Wreorder 警告(「will be initialized after」),别忽略它。
不修改对象状态的成员函数务必标记 const。这不仅是好习惯,还能让 const 对象和 const 引用调用该函数。

成员函数凭什么「知道」自己属于哪个对象?因为每次调用都被编译器偷偷塞了一个指针参数——它叫 this。

每个非静态成员函数都藏着一个隐式参数 this——指向「调用它的那个对象」的指针。你写 length(),编译器实际按 length(&对象) 传入,函数体里的 x 其实是 this->x。所以 this 的类型是 ClassName*decltype(this) 可;它是不可赋值的纯右值,教材因此常把它「等效地」写成 ClassName* const);在 const 成员函数里变成 const ClassName*,这正是 const 成员函数「改不了成员」的底层原因(呼应上一卡)。它有三个日常用途:消歧(参数名和成员同名时用 this-> 点明)、返回 *this 实现链式调用、以及把「自己」传给别的函数。静态成员函数不绑定对象,因此没有 this
class Builder {
    std::string text_;
public:
    // 参数名与成员同名 → 用 this-> 消歧
    Builder& setText(const std::string& text) {
        this->text_ = text;   // this-> 点明左边是成员,不是参数
        return *this;      // 返回自身引用 → 可链式
    }
    Builder& append(std::string_view s) {
        text_ += s;             // 等价于 this->text_ += s
        return *this;
    }
    const std::string& str() const { return text_; }
    // 此处 this 的类型是 const Builder*,故改不了成员
};

// 链式调用(fluent interface)
Builder b;
b.setText("Hello").append(", C++").append("!");
b.str();   // "Hello, C++!"
链式调用务必返回引用 Builder& 而非值——返回值会每步拷贝一个副本,后续修改都作用在临时对象上,原对象纹丝不动。另一个高危操作是this 存进回调或线程里,对象却先销毁了——之后回调再用这个 this 就是访问已释放的内存(未定义行为)。这种「让对象活到回调执行完」的需求有标准解法,等 08 章讲完智能指针再回来看。
拷贝赋值 / 移动赋值运算符按惯例也 return *this;,好让 a = b = c; 这类连等成立(见下一卡 Rule of 5)。而 delete this; 虽合法(引用计数对象偶尔用到),但极易出错——之后碰任何成员或再次析构都是未定义行为,日常一律交给智能指针,不要手写。

六个特殊成员函数编译器都能替你生成——问题是生成得对不对,Rule of 0/3/5 就是判断这件事的决策树。

C++ 有 6 个特殊成员函数:默认构造、拷贝构造、拷贝赋值、移动构造、移动赋值、析构。Rule of 0:如果类不手动管理资源(用智能指针等),不要定义任何特殊成员。Rule of 5:如果定义了其中任何一个,通常需要定义全部 5 个(或用 = default / = delete)。
class Buffer {
    int* data_;
    size_t size_;
public:
    // 构造
    Buffer(size_t n) : data_(new int[n]), size_(n) {}

    // 析构
    ~Buffer() { delete[] data_; }

    // 拷贝构造(深拷贝)
    Buffer(const Buffer& other)
        : data_(new int[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;
    }

    // 拷贝赋值 & 移动赋值类似...
};
如果类拥有裸指针资源但没有自定义拷贝构造/赋值,编译器生成的默认拷贝只做浅拷贝,两个对象会共享同一块内存,析构时 double free 崩溃。这是 C++ 最经典的 bug 之一。
首选 Rule of 0:成员全用 vector / string / unique_ptr 这类自理类型,五个特殊成员一个都不写。再记一条生成规则:只要声明了析构函数(哪怕 = default),编译器就不再隐式生成移动构造/移动赋值——这样的类 std::move 会静默退化成拷贝,性能无声下降;确需移动就用 = default 把两个移动成员显式请回来。

C++ 允许给自定义类型重定义运算符,让 a + bv[i]cout << obj 对你的类也成立——这正是 02 章解码卡埋的伏笔:cout << x 之所以能输出各种类型,就是标准库为它们重载了 operator<<

两种形式:成员 vs 自由函数

  • 成员函数:左操作数是本类对象时用它,a + b 实为 a.operator+(b)——适合 + - * / [] () = += 等;
  • 自由(常配 friend)函数:左操作数不是本类时必须用它。最典型的就是 os << obj——左边是 std::ostream 不是你的类,所以 operator<< 只能写成自由函数。

能重载 / 不能重载

  • 可重载:算术、比较、下标 []、函数调用 ()、解引用 * / ->(智能指针就靠这两个,见 08 章)等;
  • 不可重载:::..*?:sizeof——它们是语言骨架,动不得。

C++20:<=> 三路比较一次到位

  • 过去要写全 == != < > <= >= 六个;C++20 一句 auto operator<=>(const T&) const = default;自动生成全部比较——= default<=> 还会连带隐式生成 operator==,六个运算符一个不用另写(g++ 15 ==/!= 直接可用);只有手写(非 default)<=> 时才需另配一个 operator==
struct Vec2 {
    double x, y;

    // ① 成员形式:左操作数是本类
    Vec2 operator+(const Vec2& o) const { return {x + o.x, y + o.y}; }

    // ② C++20:一行生成全部比较运算符
    auto operator<=>(const Vec2&) const = default;
};

// ③ 自由函数:左操作数是 ostream,只能写在类外
std::ostream& operator<<(std::ostream& os, const Vec2& v) {
    return os << '(' << v.x << ", " << v.y << ')';  // 返回流以支持链式
}

Vec2 a{1, 2}, b{3, 4};
std::cout << a + b;   // (4, 6) —— operator+ 和 operator<< 一起起作用
operator<<(及其他左操作数非本类的运算符)写成成员函数会失败——成员形式的左操作数被固定成本类,而这里左边是 ostream,只能用自由函数(常声明为 friend 以便访问私有成员)。另外别滥用重载:让 + 去干和「相加」无关的事,会让读者彻底误解代码,重载应只保留符合直觉的语义。
operator<< 返回 std::ostream&(流本身)是关键——正因如此才能 cout << a << b << c 链式书写,这与本章 this 卡「返回 *this 实现链式调用」是同一个套路。

同一句 s.area(),圆算圆的、矩形算矩形的——按对象实际类型在运行时分派,这就是虚函数撑起的多态。

继承让派生类复用基类的接口和实现。虚函数virtual)启用运行时多态——通过基类指针/引用调用时,会根据实际对象类型调用正确的函数版本。C++11 引入 override 关键字显式标记重写,防止拼错函数名导致的隐蔽 bug。
class Shape {
public:
    virtual double area() const = 0;  // 纯虚函数 → 抽象类
    virtual ~Shape() = default;       // ⚠️ 虚析构必须有!
};

class Circle : public Shape {
    double r_;
public:
    Circle(double r) : r_(r) {}
    double area() const override {    // override 防止拼错
        return 3.14159 * r_ * r_;
    }
};

// 多态:用基类的「引用」或「指针」指向派生类对象
Circle c(5.0);
Shape& s = c;          // 基类引用绑定派生对象
s.area();             // 调用的是 Circle::area(),返回 78.5

Shape* p = &c;         // 基类指针同理
p->area();            // 78.5

// 注意:多态必须经由引用或指针。这里 Shape 含纯虚函数(抽象类),
// 连 Shape s2 = c; 都编译不过;基类若是非抽象的,按值赋过去则会
// 静默「切片」——派生部分被截断、多态失效。见本章进阶段的 vtable 卡。

// 实际工程里这类对象多半建在堆上、由智能指针持有,写法是:
//   std::unique_ptr<Shape> sp = std::make_unique<Circle>(5.0);
// 现在把它当成「会自动释放的 Shape*」即可,08 章专门讲。
基类必须有虚析构函数!否则通过基类指针 delete 派生类对象是未定义行为——g++ 15 派生类的析构函数不执行(资源泄漏),开 ASan 则直接报 new-delete-type-mismatch——这是 C++ 多态使用中最常见的错误。另一条容易撞上的规则:虚函数不要带默认参数。默认参数由编译器按指针的静态类型(这里是 Shape)在调用点填入,函数体却按实际对象类型Circle)分派,两者叠加会执行出「Circle 的函数体 + Shape 的默认参数」这种谁也没写过的组合。
重写虚函数永远写上 override:拼错函数名或签名不匹配立即编译错(g++ 15 报 error: 'double Circle::aera() const' marked 'override', but does not override),不写则静默变成一个「新函数」,bug 藏到运行期才炸。存一组多态对象用 std::vector<std::unique_ptr<Shape>>,别用 vector<Shape>——后者按值存放会切片(见本章 vtable 卡)。
进阶 · 精确规则与陷阱(第一遍可跳过)

virtual 会用,但精通要懂它的成本模型与两个致命陷阱:它靠一张函数指针表(vtable)实现,而按值使用多态类型会悄悄切片

vtable 与 vptr

  • 每个含虚函数的类有一张静态 vtable(虚函数指针数组);每个对象头部塞一个隐藏 vptr 指向它——所以 sizeof 至少多一个指针宽;
  • 虚调用 = 取 vptr → 查表 → 间接跳转,无法内联,这是虚函数的主要开销;
  • 虚析构之所以必需:delete base_ptr 靠 vtable 找到正确的派生析构(呼应上一卡机制)。

对象切片(slicing):静默且无报错

  • 按值把派生对象赋给 / 传给基类,派生部分被截断、vptr 复位成基类 → 多态失效,编译器却不报错;
  • 多态一定用指针或引用Base&Base*unique_ptr<Base>;容器存 vector<unique_ptr<Base>> 而非 vector<Base>

final:封顶 + 去虚化

  • 类或虚函数标 final 禁止进一步继承 / 重写;还给编译器去虚化(devirtualize)并内联的机会,消掉虚调用开销。
struct Base {
    virtual void speak() const { std::cout << "base"; }
    virtual ~Base() = default;   // 无它则 delete 派生对象泄漏
};
struct Derived final : Base {   // final:不可再被继承
    void speak() const override { std::cout << "derived"; }
};

// ✅ 指针 / 引用 → 动态分派
std::unique_ptr<Base> p = std::make_unique<Derived>();
p->speak();          // "derived",查 vtable

// ❌ 按值 → 切片,多态没了
Derived d;
Base sliced = d;        // 派生部分被截断
sliced.speak();     // "base"!vptr 已复位
对含虚函数的对象做 memcpy / memset 会破坏 vptr,之后任何虚调用都是 UB——这类类不是 trivially-copyable,别按裸字节拷贝。而切片更隐蔽:vector<Base> v; v.push_back(derived); 编译通过却已丢失派生部分。判据:凡要多态,存 / 传的必须是指针或引用
构造 / 析构函数里调用虚函数只分派到当前层(此刻派生 vptr 尚未装好 / 已复位)——别指望在基类构造里调到派生实现,需要「构造后初始化」用两段式或工厂函数。对象内存布局的硬件视角见组成原理页。

拷贝一个 vector 要复制整块堆内存;可若源对象是马上就要销毁的临时量,复制纯属浪费——直接接管它的内部指针就好。移动语义(C++11)把这种「偷资源」合法化:用右值引用区分「之后还有人用的对象」与「用完即弃的对象」,给后者开一条零拷贝通道。

左值、右值与 &&

  • 左值:有名字、能取地址、之后还会被用(变量、成员);右值:临时对象、字面量、函数返回的临时——「用完即弃」;
  • T&&(右值引用)只绑定右值,于是重载能按来源分流:拷贝构造 T(const T&) 接左值,移动构造 T(T&&) 接右值;
  • 易错点:右值引用变量本身是个左值(它有名字)——函数体内再转手要重新 std::move;模板里的 T&& 更是另一回事(转发引用,见 07 章完美转发)。

std::move 与移动构造的分工

  • std::move 不移动任何东西,它就是 static_cast<T&&>——一张「这对象我不要了」的声明,作用只是让重载决议选中移动版本;
  • 真正的窃取发生在移动构造 / 移动赋值里:接管源对象的指针成员,再把源置空——两步缺一不可(不置空 → 源析构时双重释放);
  • 被移动后的对象处于有效但未指定状态:可以析构、可以整体重新赋值,不能再读它的值

noexcept:移动的隐形开关

  • vector 扩容搬迁旧元素时要保证强异常安全:只有移动构造标了 noexcept 才敢用移动,否则默默退回逐个拷贝——手写移动构造务必加 noexcept= default 生成的通常自动满足)。

何时 move 反而有害

  • return 局部变量别包 std::move:会抑制 NRVO(返回值优化,本可一次构造都不发生),强行退化成移动——直接 return v;
  • move 一个 const 对象const T&& 匹配不上移动构造,重载决议静默改选拷贝——不报错、不提示,性能白丢。
// 手写一次移动构造,看清"偷"的两个步骤
class Buffer {
    int* data_;
    size_t n_;
public:
    Buffer(Buffer&& other) noexcept           // noexcept:vector 扩容才敢用它
        : data_(other.data_), n_(other.n_) {  // ① 接管指针
        other.data_ = nullptr;                // ② 源置空,防双重释放
        other.n_ = 0;
    }
    ~Buffer() { delete[] data_; }             // 置空后源的析构安全无害
};

std::string a = "Hello";
std::string b = std::move(a);   // a 的堆内存被 b 接管
// a 此后"有效但未指定":可赋新值,别读旧值

std::vector<std::string> vec;
vec.push_back(std::move(b));    // 移动进容器,零拷贝

std::vector<int> makeData() {
    std::vector<int> v = {1, 2, 3};
    return v;                // ✅ 直接 return:NRVO 连移动都省掉
    // return std::move(v);  // ❌ 反而抑制 NRVO,强制走移动
}
std::move 三连坑:① 它只是个 cast,写了不等于发生了移动——对方没有移动构造、或对象是 const 时,静默退化成拷贝,性能损失无声无息;② move 之后继续读源对象,值是未指定的;③ return std::move(local) 画蛇添足,反而抑制了本可完全省略构造的 NRVO。
自定义类先想 Rule of 0/3/5(见本章构造析构卡):成员全是 vector / string / unique_ptr 这类会自理的类型时,五个特殊成员函数一个都别写,移动自动正确。需要「收下所有权」的接口用 sink 惯用法:按值收参数再 move 进成员——void setName(std::string s) { name_ = std::move(s); },调用方传左值则一拷一移、传右值则两移,两头都最优。

标准库 STL

自己写类之外,日常更多是用现成的。STL(标准模板库)提供容器、迭代器、算法三大组件,是 C++ 高效编程的基石——熟练使用它就能避免重复造轮子。本章讲机制与取舍:每种容器快在哪、慢在哪,迭代器何时失效,怎么让自定义类型进容器。「这个函数怎么调、复杂度多少」不在这里查——那是下一章「标准库速查」的活,两章是「讲透 vs 速查」的分工,不重复。

容器选型是用好 STL 的第一步:先弄清每种序列容器快在哪、慢在哪,九成场景的答案其实就是 vector。

vector 是最常用的容器——连续内存、随机访问 O(1)、尾部增删 O(1) 摊还。deque 支持头尾高效增删。list 是双向链表,任意位置插删 O(1) 但不支持随机访问(更省内存的单向版是 std::forward_list:每节点只存一个 next 指针、只能前向遍历,仅在内存极敏感又只需顺序访问时才用)。90% 的场景用 vector 就够了,因为连续内存对 CPU 缓存友好。

定长伙伴:std::array / bitset

  • std::array<T, N>:栈上定长、零开销、值语义(可拷贝 / 比较 / return),比 C 数组多了 .size() / .at() / 迭代器且不退化成指针;大小是类型的一部分,array<int,3>array<int,4> 是不同类型(std::array<int,3> a; 内置元素未初始化,清零要写 a{});
  • std::bitset<N>:定长位集合,.count() / .test() / .any() 加位运算,替代手写位掩码。
#include <vector>

std::vector<int> v = {1, 2, 3};
v.push_back(4);         // 尾部添加
v.emplace_back(5);      // 原地构造,避免拷贝
v.size();                // 5
v[0];                    // 1(不检查越界)
v.at(0);                 // 1(越界抛异常)
v.pop_back();             // 移除末尾

// 预分配容量避免频繁扩容
std::vector<int> big;
big.reserve(10000);     // 预留空间,不改变 size
留着元素的引用或指针再 push_back:一旦触发扩容,整块内存搬家,先前拿到的 int& r = v.front()&v[0] 连同迭代器全部悬垂——继续用是 UB,ASan 报「heap-use-after-free」。要么先 reserve 足量,要么改存下标而不是引用。
emplace_backpush_back 少一次拷贝/移动——它直接在容器内存中构造对象。对于非平凡类型优先使用 emplace_back。

迭代器是「容器」与「算法」之间的接口:算法要求最低的能力档位、容器提供某档迭代器——这解释了「为什么 sort 用不了 list」。而修改容器后哪些迭代器失效是 UB 高发区,必须成竹在胸。

六档能力(逐级增强)

  • input / output → forward → bidirectional → random-access →(C++20)contiguous;
  • std::sort 要 random-access,所以 list 用不了、得用成员 list::sortfind 只要 input,什么容器都行。

失效规则(按容器)

  • vector:一旦扩容(size 越过 capacity),所有迭代器 / 指针 / 引用失效;未扩容时插入点及其后失效——reserve 可预防;
  • deque:中间插删使全部迭代器失效;头尾插入不使引用失效(迭代器仍失效);
  • list / map / set:节点式,插入不失效任何迭代器,erase 只失效被删元素;
  • unordered_*:rehash(越过 max_load_factor)使迭代器全失效,但引用 / 指针不失效。
// 边遍历边删:必须用 erase 的返回值前进
for (auto it = v.begin(); it != v.end(); ) {
    if (*it < 0) it = v.erase(it);  // erase 返回下一个有效迭代器
    else ++it;
}

// list 不支持 std::sort(非随机访问)→ 用成员函数
std::list<int> lst = {3, 1, 2};
lst.sort();               // ✅   std::sort(lst.begin()...) ❌

// 预留容量,避免遍历中扩容令迭代器悬垂
std::vector<int> v2;
v2.reserve(1000);
在 range-for for (auto x : v) 内部对 vpush_back:一旦触发扩容,range-for 底层缓存的 end() 立刻悬垂 → UB。循环里改动容器结构,改用下标或 erase 返回值。经典的 erase-remove 惯用法(见 6 章「算法速查」卡)正是为安全批量删除而生。
选容器先想「要什么迭代器 + 改动模式」:要随机访问 / 缓存友好 → vector;要迭代器 / 引用在插入后稳定 → 节点式 list / mapreserve 是 vector 的头号性能与安全开关。

按 key 存取是日常最高频的需求:有序的红黑树家族与更快的哈希家族各有强项,这张卡帮你把两族一次分清。

map/set 基于红黑树,元素有序,查找/插入/删除 O(log n)。unordered_map/unordered_set 基于哈希表,无序,平均 O(1) 查找。当不需要有序遍历时,优先用 unordered 版本获得更好性能。若同一个 key 要存多个值,用 multimap / multiset(红黑树版)或 unordered_multimap / unordered_multiset(哈希版):允许重复键,但没有 operator[],插入用 insertemplace,查一批用 equal_range
#include <unordered_map>

std::unordered_map<std::string, int> scores;
scores["Alice"] = 95;
scores["Bob"] = 87;

// 安全查找(不会意外插入)
if (auto it = scores.find("Alice"); it != scores.end()) {
    std::print("{}", it->second);  // 95
}

// C++17: try_emplace 避免重复构造
scores.try_emplace("Charlie", 92);

// C++17: insert_or_assign 覆盖式插入
scores.insert_or_assign("Alice", 99);

// 一个 key 对应多个值:multimap / multiset(允许重复键,无 operator[])
std::multimap<std::string, int> mm;
mm.emplace("Alice", 95);
mm.emplace("Alice", 88);          // 同键再存一个,不覆盖
auto [lo, hi] = mm.equal_range("Alice"); // 取该键的全部值 [lo, hi)
for (auto it = lo; it != hi; ++it) std::print("{} ", it->second); // 95 88
operator[] 在 key 不存在时会自动插入默认值!比如 scores["Dave"] 会插入 {"Dave", 0}。只想查找不想插入,务必用 find()count()
只判断「在不在」,C++20 起写 m.contains(k)(此前用 m.find(k) != m.end())。选型口诀:不需要按序遍历和区间查询就默认 unordered_map,要按 key 顺序输出或用 lower_bound 才换 map

把自定义类型放进 map / set / priority_queue / unordered_map 是日常,但不给「序」或「哈希」就编译不过——这层是把「会用容器」推向「精通容器」的关卡。

有序容器:需要严格弱序

  • set<T, Cmp> 传比较 functor,或给类型定义 operator<(C++20 <=> 一步到位,见 04 章);
  • 比较必须是严格弱序:等价性用「!(a<b) && !(b<a)」判定。

unordered_*:需要相等 + 哈希两样

  • 相等(operator==)+ 哈希(特化 std::hash<T> 或传 hash functor);
  • 组合多字段的哈希别简单异或(易碰撞),用移位混合(hash_combine 式)。

透明比较:免临时对象查找

  • std::map<std::string, V, std::less<>>(C++14)允许用 string_view 直接查 string key,不构造临时 string
struct Point { int x, y; };

// 有序容器:给个严格弱序比较器
auto cmp = [](Point a, Point b){ return a.x < b.x; };
std::set<Point, decltype(cmp)> s(cmp);

// unordered_map:哈希 functor + operator==
struct PointHash {
    std::size_t operator()(Point p) const {
        std::size_t h = std::hash<int>{}(p.x);
        h ^= std::hash<int>{}(p.y) + 0x9e3779b9 + (h << 6) + (h >> 2); // 混合,非裸异或
        return h;
    }
};
bool operator==(Point a, Point b){ return a.x==b.x && a.y==b.y; }
std::unordered_map<Point, int, PointHash> m;
比较器写成 <=(非严格)会破坏严格弱序 → map / set 行为未定义(丢元素、越界),std::sort 传非严格谓词同样可能崩。永远用严格 <。哈希用裸异或(hx ^ hy)会让 (1,2)(2,1) 撞桶,务必加位移混合。
自定义类型若想一步到位可比较,给它 auto operator<=>(const T&) const = default;(C++20,见 04 章运算符重载),有序容器与排序即刻可用;unordered_* 仍需单独提供哈希。

适配器 = 在已有容器外包一层受限接口,底层可换。其中 priority_queue 是标准库的堆,出现在 Dijkstra、Top-K 等无数场景——它的「默认大顶堆」几乎人人踩过。

三个适配器

  • stack(LIFO)、queue(FIFO)默认底层 dequepriority_queue 默认底层 vector
  • 无迭代器、不可遍历,只暴露 top / front / back 等受限操作。

priority_queue:默认大顶堆

  • top()最大元素;push / pop 为 O(log n),top 只读不弹;
  • 造小顶堆:priority_queue<T, vector<T>, greater<T>>
#include <queue>
#include <stack>

std::stack<int> st;
st.push(1); st.top(); st.pop();   // LIFO

// 默认大顶堆:top() 最大
std::priority_queue<int> maxHeap;
maxHeap.push(3); maxHeap.push(1); maxHeap.push(4);
maxHeap.top();          // 4

// 小顶堆:传 greater
std::priority_queue<int, std::vector<int>, std::greater<>> minHeap;
top() / front()容器上是 UB(不抛异常),每次访问前必须 !empty()。另外 pop() 返回 void——想取值得先 top()pop(),别指望 pop 返回被弹的元素。
需要「按优先级取最值」用 priority_queue;只是 LIFO / FIFO 缓冲用 stack / queue。要遍历或随机访问就别用适配器——它们故意不给迭代器,改用底层 vector / deque

一百多个通用算法配上 lambda,多数手写循环都能换成一行意图明确的调用。

<algorithm> 提供了 100+ 个通用算法,配合Lambda 表达式(C++11)使用极为强大。Lambda 是匿名函数对象,语法为 [捕获](参数) -> 返回类型 { 函数体 },捕获列表决定能访问哪些外部变量。
#include <algorithm>
#include <vector>

std::vector<int> v = {5, 2, 8, 1, 9, 3};

// 排序
std::sort(v.begin(), v.end());              // 升序
std::sort(v.begin(), v.end(), std::greater<>()); // 降序

// Lambda 配合算法
auto it = std::find_if(v.begin(), v.end(),
    [](int x) { return x > 5; });

int sum = 0;
std::for_each(v.begin(), v.end(),
    [&sum](int x) { sum += x; }); // 按引用捕获 sum

// 常用算法速查
std::count_if(v.begin(), v.end(), [](int x){ return x%2==0; });
std::any_of(v.begin(), v.end(), [](int x){ return x>8; });
std::transform(v.begin(), v.end(), v.begin(),
    [](int x){ return x * 2; });
算法只经由迭代器读写,绝不改变容器大小:往空的 dststd::copy(src.begin(), src.end(), dst.begin()) 是写穿无效迭代器的 UB——ASan 直接报 SEGV。要边写边增长,改用 std::back_inserter(dst),或先 dst.resize(src.size()) 再写。
Lambda 捕获列表:[=] 按值捕获所有外部变量,[&] 按引用捕获所有,[x, &y] 混合捕获。C++14 起支持泛型 Lambda:[](auto x, auto y) { return x + y; }

「可能没有值」「几选一」「一次返回多个」这些日常语义,从此写进类型本身,不再靠 -1 和空指针的约定硬凑。

C++17 引入了一组类型安全的「和类型/积类型」工具:optional<T> 表示「可能有值也可能没有」;variant<T...> 是类型安全的联合体;any 可以持有任意类型的值。tuple<T...>(连同 pair)其实早在 C++11 就有,把多个不同类型的值打包成一个聚合——只是配合 C++17 的结构化绑定auto [a, b] = ...)才能一次性优雅解包。它们让代码更安全,替代了裸指针表示空值、union 和 void* 的老用法。
#include <optional>
#include <variant>
#include <any>
#include <tuple>

// optional:可能失败的查找
std::optional<std::string> findUser(int id) {
    if (id == 1) return "Alice";
    return std::nullopt;  // 没找到
}

if (auto user = findUser(1)) {
    std::print("{}", *user);  // "Alice"
}

// variant:类型安全的联合体
std::variant<int, std::string> val = 42;
val = "hello";  // 现在持有 string

std::visit([](auto& v) {
    std::print("{}", v);  // 自动匹配当前类型
}, val);

// any:持有任意类型,取值时必须知道确切类型
std::any a = 42;                 // 现在是 int
a = std::string("hi");           // 换成 string
std::string as = std::any_cast<std::string>(a); // 类型不符会抛 bad_any_cast

// tuple:打包多个值,用结构化绑定解包(C++17)
std::tuple<int, std::string> status() {
    return {200, "OK"};    // 直接列表初始化返回
}
auto [code, msg] = status();  // code=200, msg="OK"
std::println("{} {}", code, msg);
对空 optional* / -> 解引用是 UB,不做任何检查——Ubuntu 的 g++ 15 默认开 libstdc++ 断言,直接 abort:「Assertion 'this->_M_is_engaged()' failed」;换个配置则可能悄悄读到垃圾值。拿不准有没有值就用 value()(空时抛 bad_optional_access)或 value_or(默认值)
std::optional 替代返回指针或特殊值(如 -1)表示「无结果」,用 std::variant 替代继承层次做简单的多态分发,代码会更清晰安全。

两块常被忘掉、却是正经标准库的文本设施:<regex>(C++11)做正则匹配 / 提取 / 替换;<charconv>(C++17)的 from_chars / to_chars最快、不依赖 locale、不抛异常、不分配的数字⇄字符串转换——性能远胜 stringstream / stoi。(正则语法本身另见「正则表达式」专页。)

#include <regex>
#include <charconv>

// —— <regex> 正则 ——
std::string s = "订单 A-1024 已发货";
std::regex re(R"(([A-Z])-(\d+))");        // 原始字符串字面量免转义反斜杠
std::smatch m;
if (std::regex_search(s, m, re)) {         // 在串里搜;regex_match 要求整串匹配
    std::string whole = m[0];              // "A-1024"
    std::string id    = m[2];              // "1024"(捕获组 2)
}
std::string out = std::regex_replace(s, re, "[$2]");  // "订单 [1024] 已发货"

// 遍历所有匹配:sregex_iterator
for (std::sregex_iterator it(s.begin(), s.end(), re), end; it != end; ++it)
    std::println("{}", (*it)[0].str());

// —— <charconv> 快速转换(C++17)——
int n = 0;
const char* str = "42abc";
// 解析数字|传 起址+止址+接收变量(+进制)|返回 {ptr 停在首个未消费字符, ec 错误码}
auto [ptr, ec] = std::from_chars(str, str + 5, n);   // n=42,ptr 指向 'a'
if (ec == std::errc{}) { /* 成功 */ }

char buf[16];
// 格式化数字到缓冲|传 起址+止址+值(+进制/精度)|返回 {ptr 写入末尾, ec}
auto [p2, ec2] = std::to_chars(buf, buf + sizeof buf, 3.14);  // 写 "3.14"
std::regex 构造有成本、运行还偏慢:别把 std::regex 放进循环反复构造——提到循环外复用;重度正则场景很多人转投 RE2 / PCRE2。from_chars/to_chars 反过来是极致轻量:不认 locale、不跳前导空格、不加 0x 前缀,正因如此才快——要那些「智能」行为得用别的接口。
解析用户输入选型:要健壮又简单→from_chars(无异常、能拿到停止位置区分「真 0」与「解析失败」);要老接口→stoi(越界抛异常);别再用 atoi(出错返 0、无法区分失败)。

标准库速查

上一章讲透了 STL 的机制与取舍,这一章是随手可翻的函数参考——体例与「工程基础」页 04 章 C 标准库完全一致:先用一张任务反查表按「你想干什么」定位到设施与头文件,再翻后面以头文件命名的七张卡查具体操作。每个操作上方一行注释按「作用|复杂度|失效或陷阱」三段速读(无复杂度意义的省略中段)。和 C 的差别只在字段:C 关心返回值与 errno,C++ 关心复杂度与迭代器失效——后者是 C++ 容器最常咬人的地方。机制上的「为什么」不在这里重复,只指回上一章及 08/09/10 章的展开处。

C 的标准库好查,是因为「头文件 = 主题」且全是扁平函数——要弄字符串就翻 <string.h>,一列函数名扫过去就完了。C++ 不一样:设施分成类型(vector)· 成员函数(push_back)· 自由算法(sort)· 适配器(stack)四套,名字还常常不副实(std::move 不移动、std::remove 不删除)。所以查 C++ 标准库要分两步:先用这张反查表——左边是你想干的事,右边是该用的东西和它在哪个头里;拿到头文件名,再翻后面以头文件命名的八张卡看具体操作。那七张卡的体例与 C 完全对齐:卡标题就是头文件名,每个操作上方一行「作用|复杂度|失效或陷阱」。

文本 · 数值 · 时间

想做什么用这个头文件
拼接字符串s += t;多段拼用 std::format("{}-{}", a, b)<string> <format>
只读传字符串std::string_view(零拷贝,别比源串活得久<string_view>
字符串转数字std::from_chars(最快、不抛、能区分「真 0」与失败)<charconv>
数字转字符串std::to_string(简单)/ std::format(要格式)<string> <format>
匹配 / 替换文本std::regex_search / regex_replace(regex 对象提到循环外)<regex>
求和 / 归约std::accumulate;可并行版 std::reduce(C++17)<numeric>
把值限进范围std::clamp(x, lo, hi)<algorithm>
取类型上下限std::numeric_limits<T>::max()<limits>
生成随机数std::mt19937 + uniform_int_distribution(别用 rand()<random>
测一段耗时std::chrono::steady_clock::now() 两次相减<chrono>

容器 · 查找 · 排序

想做什么用这个头文件
存一串东西std::vector——拿不准就选它<vector>
长度编译期已知std::array<T,N>(栈上、值语义、不退化成指针)<array>
两端都要进出std::deque<deque>
按 key 查std::unordered_map(均摊 O(1));要有序用 std::map<unordered_map> <map>
去重std::set / unordered_set;已有 vector 则 sort+unique+erase<set> <algorithm>
排序std::ranges::sort(v)(C++20,省 begin/end,支持投影)<algorithm>
找一个元素std::find / find_if;已排序用 lower_bound<algorithm>
取最大的 k 个std::nth_element / partial_sort;流式用 priority_queue<algorithm> <queue>
按条件批量删std::erase_if(v, pred)(C++20 一步到位)<vector> 等容器对应头
只借用一段数组std::span<T>(非拥有视图,替代「指针 + 长度」两个参数)<span>

内存 · 错误 · 系统

想做什么用这个头文件
独占一个堆对象std::make_unique<T>(...)——默认选它,别裸 new<memory>
多处共享对象std::make_shared<T>(...);打破循环用 weak_ptr<memory>
可能没有值std::optional<T>(比「返回 -1 / 空指针」表意清楚)<optional>
几选一的类型std::variant<A,B> + std::visit(类型安全的 union)<variant>
一次返回多个值std::pair / std::tuple + 结构化绑定<utility> <tuple>
报告错误throw std::runtime_error(...);不想抛就 optional / expected(C++23)<stdexcept> <expected>
读写文件std::ifstream / std::ofstream<fstream>
查文件 / 遍历目录std::filesystem::exists / directory_iterator<filesystem>
跑个后台任务std::asyncfuture;自己管线程用 std::jthread(C++20)<future> <thread>
保护共享数据std::mutex + std::lock_guard;单个计数用 std::atomic<mutex> <atomic>
// ——— 四个最高频任务的成品写法(表里查到名字后,照着抄)———

// ① 整个文件读进 string
std::ifstream fin("a.txt");
std::string text{std::istreambuf_iterator<char>(fin), {}};

// ② 按行读
for (std::string line; std::getline(fin, line); ) {
    // 用 line;注意 getline 会吃掉换行符,行尾可能还留 '\r'(Windows 文本)
}

// ③ vector 去重(先排序,再把重复项挪到末尾删掉)
std::sort(v.begin(), v.end());
v.erase(std::unique(v.begin(), v.end()), v.end());

// ④ 统计词频,按出现次数从高到低
std::unordered_map<std::string, int> cnt;
for (const auto& w : words) ++cnt[w];      // 不存在的 key 自动值初始化为 0
std::vector<std::pair<std::string, int>> top(cnt.begin(), cnt.end());
std::ranges::sort(top, std::greater{}, &std::pair<std::string, int>::second);
//                       ↑ 比较器      ↑ 投影:拿 .second 去比,不用手写 lambda
C++ 标准库有一批名字不能望文生义,照字面猜必错:std::move 不移动任何东西(只是转成右值的 cast,见 04 章);std::remove / remove_if 不真正删除(只前移保留元素并返回新末尾,必须配 erase,见 10 章);std::array 与 C 数组是两回事(值语义、不退化);shared_ptr 只有引用计数线程安全,它指向的对象仍要你自己加锁。
查标准库认头文件,别认名字——cppreference 就是按 <algorithm> / <memory> 这样的头组织的,和本卡右列、以及后面七张速查卡的标题一一对应,比按名字瞎搜快得多。右列的头文件名就是导航键:查到哪个头,就翻标题里带那个头的卡;算法明细见本章「算法速查」卡,:: / << 这些符号的含义见 02 章解码卡。

从这张卡起,体例与「工程基础」页 04 章 C 标准库完全一致:一张卡管一组头文件,卡标题就是头文件名;每个操作上方一行注释,按「作用|复杂度|失效或陷阱」三段速读(无复杂度意义的省略中段)。和 C 的差别只在字段:C 关心返回值与 errno,C++ 关心复杂度迭代器失效——后者是 C++ 容器最常咬人的地方。本卡管顺序存放的那几个容器。

// ══════ <vector> ── 动态数组:连续内存,拿不准就选它 ══════
std::vector<int> v{1,2,3};
// 尾部追加一个元素|摊还 O(1)|⚠ 触发扩容时,所有迭代器与引用全部失效
v.push_back(4);
// 在尾部就地构造,省一次拷贝/移动|摊还 O(1)|失效规则同 push_back
v.emplace_back(5);
// 预分配容量|O(n)|已知规模时先调用一次,可完全避免中途反复扩容搬数据
v.reserve(1000);
// 下标访问,不检查越界|O(1)|⚠ 越界是 UB——不报错,读到垃圾值或直接崩
v[i];
// 下标访问,检查越界|O(1)|越界抛 std::out_of_range
v.at(i);
// 删除指定位置的元素|O(n),后续元素整体前移|被删位置及其之后的迭代器失效
v.erase(v.begin() + 1);
// 移除末尾元素|O(1)|⚠ 不返回被删的值,要用先 back() 取再 pop_back();空容器上调用是 UB
v.pop_back();
// 元素个数 / 是否为空 / 清空 / 首元素 / 尾元素|均 O(1)|clear 只析构元素不还内存,要还用 shrink_to_fit
v.size();  v.empty();  v.clear();  v.front();  v.back();

// ══════ <array> ── 栈上定长数组:值语义,可拷贝、可比较、可 return ══════
// 定长数组,长度是类型的一部分|元素在栈上,无堆分配|与 C 数组的关键差别:不退化成指针
std::array<int,3> a{1,2,3};
// 元素个数|O(1)|编译期常量,可用在 constexpr 与模板参数里
a.size();
// 值初始化全部元素为 0|⚠ 不写 {} 时内置类型元素是未初始化的垃圾值
std::array<int,3> z{};

// ══════ <deque> / <list> / <forward_list> ── 其余序列容器 ══════
// 双端队列:两端都能高效进出|push_front / push_back 均摊还 O(1)|内存分段不连续,随机访问慢于 vector
std::deque<int> dq;   dq.push_front(0);   dq.push_back(1);
// 双向链表:任意位置插入删除|已持有迭代器时 O(1)|无随机访问;插入永不使已有迭代器失效
std::list<int> ls;
// 把另一个链表的整段搬过来|O(1),链表独有|只改指针,不拷贝也不移动元素
ls.splice(ls.begin(), other);
// 单向链表:每个节点少存一个指针|内存极敏感时才值得用|没有 size()、不能反向遍历
std::forward_list<int> fl;

// ══════ <span> / <bitset> ── 视图与位集 ══════
// C++20:借用一段连续内存的非拥有视图|构造 O(1)|⚠ 不延长生命周期,别比源容器活得久
std::span<int> sp{v};
// 定长位集合,替代手写位掩码|长度编译期固定
std::bitset<8> bs{0b1010};
// 置位个数 / 测第 n 位|count 按机器字数与位数成正比,test O(1)|下标从最低位算起
bs.count();   bs.test(1);
「谁会让迭代器失效」是本卡最该背下来的一条vector 一旦扩容,迭代器与引用全部失效;deque 两端插入会让迭代器失效但引用仍有效;list / forward_list 最稳,插入永不失效、erase 只失效被删那个。在 range-for 里对容器做 push_back 是经典 UB(见 05 章「序列容器」详解卡)。
拿不准就 vector——连续内存对 CPU 缓存友好,常比「理论更优」的 list 还快;只有在确实需要迭代器稳定中间频繁插删且元素很大时才换链式结构。容器选型的完整对照见前一张「任务反查」卡。

按 key 存取的容器分两族:有序map/set(红黑树,O(log n),能按序遍历与区间查询)和无序unordered_*(哈希表,平均 O(1),但没有顺序)。适配器 stack/queue/priority_queue 则是在已有容器外包一层受限接口。语义上的「为什么」见 05 章「关联容器」与「容器适配器」两张详解卡,这里是随手可翻的操作速查。

// ══════ <map> / <set> ── 有序关联容器(红黑树),始终按 key 升序 ══════
std::map<std::string, int> m;
// 按 key 取值的引用并可赋值|O(log n)|⚠ key 不存在时会「顺手插入」一个默认值——只读时千万别用
m["k"] = 1;
// 按 key 取值,绝不插入|O(log n)|缺 key 抛 std::out_of_range
m.at("k");
// 判断 key 是否存在|O(log n)|C++20;老写法是 m.find("k") != m.end()
m.contains("k");
// key 不存在才插入|O(log n)|已存在则原样不动,且不白构造一个 value(优于 insert)
m.try_emplace("k", 1);
// 遍历全部键值对|O(n)|按 key 升序 —— 这正是有序容器存在的理由
for (const auto& [k, val] : m) {}
// 存在则覆盖,不存在则插入|O(log n)|C++17;与 operator[] 的区别是不要求 value 可默认构造
m.insert_or_assign("k", 2);
// 定位第一个 ≥ k 的元素|O(log n)|区间查询靠它,unordered_map 没有这个能力
m.lower_bound("k");
// 允许一个 key 存多个值|没有 operator[],只能 emplace / insert
std::multimap<std::string, int> mm;   mm.emplace("k", 1);   mm.emplace("k", 2);
// 取某个 key 的全部值区间 [lo, hi)|O(log n)|这是 multimap 唯一的取值方式
auto [lo, hi] = mm.equal_range("k");
// 有序集合:插入即自动去重|O(log n)|要允许重复 key 改用 multiset / multimap
std::set<int> s;   s.insert(5);
// 失效规则:插入不使任何迭代器失效,erase 只失效被删那一个 —— 关联容器的迭代器最稳

// ══════ <unordered_map> / <unordered_set> ── 哈希关联容器,无序 ══════
std::unordered_map<std::string, int> um;
// 预留桶数|O(n)|避免中途 rehash(rehash 会让所有迭代器失效,但引用仍有效)
um.reserve(1000);
// 计数惯用法|平均 O(1)|key 不存在时值初始化为 0,再自增
++um["w"];
// 按 key 删除|平均 O(1)|⚠ 哈希冲突严重时最坏退化到 O(n)
um.erase("w");
// 要顺序遍历或区间查询就换回 map;自定义类型当 key 需特化 std::hash + 提供 operator==,见 05 章「让自定义类型进容器」卡

// ══════ <stack> / <queue> ── 容器适配器:受限接口,无迭代器、不可遍历 ══════
// LIFO 栈|push / top / pop 均 O(1)|⚠ pop 不返回值,要先 top() 取出再 pop()
std::stack<int> st;   st.push(1);   st.top();   st.pop();
// FIFO 队列|push / front / pop 均 O(1)|同样 pop 不返回值
std::queue<int> q;    q.push(1);    q.front();  q.pop();
// 优先队列(堆)|push / pop 为 O(log n),top 为 O(1)|⚠ 默认是大顶堆,top() 给的是最大值
std::priority_queue<int> pq;   pq.push(3);   pq.top();
// 小顶堆的写法|把第三个模板参数换成 greater|必须把底层容器 vector 一起写全
std::priority_queue<int, std::vector<int>, std::greater<int>> minq;
mapoperator[]本卡最高频的线上事故来源:写 if (m["k"] == 0) 只是想读一下,却在容器里凭空插了一个键——容器悄悄变大、遍历时多出条目。只读一律用 contains / find / atoperator[] 只在确实想「有就取、没有就建」时用(如 ++cnt[w] 计数)。
有序 vs 无序怎么选:只按 key 存取、不在乎顺序 → unordered_map(平均更快);需要按序遍历、lower_bound 区间查询、或 key 类型没有现成哈希 → map。注意 unordered_*平均 O(1),被恶意构造的 key 打到冲突时会退化——对外网输入做哈希表索引时值得留心。

通用算法主要在 <algorithm>(数值归约在 <numeric>、C++20 惰性视图在 <ranges>)。下面按用途把最常用的算法分组速查,各配一个最小示例——除特别标注外都作用在迭代器区间 [first, last) 上。容器 / 字符串 / 智能指针的用法见 05 章 STL 与 08 章 RAII;容器选型见本卡末尾 tip。

#include <algorithm>   // 通用算法
#include <numeric>     // 数值:accumulate/reduce/iota/partial_sum/gcd…
#include <iterator>    // back_inserter 等插入迭代器
std::vector<int> v{5,2,8,1,9}, a{1,3,5}, b{2,3,4}, out;  // 示例数据;out 收集结果
auto pred = [](int x){ return x > 0; };   // 谓词
auto fn   = [](int x){ return x * x; };    // 一元变换

/* —— 排序与重排 —— */
std::sort(v.begin(), v.end());                 // 升序 O(n log n),传 comp 可改序
std::stable_sort(v.begin(), v.end());          // 保持相等元素的原相对次序
std::partial_sort(v.begin(), v.begin()+3, v.end()); // 只把最小 3 个排到最前
std::nth_element(v.begin(), v.begin()+2, v.end());  // 让第 3 名就位、两侧仅粗分(选第 k 名)
std::reverse(v.begin(), v.end());              // 原地反转
std::rotate(v.begin(), v.begin()+2, v.end());   // 循环左移,使第 3 个元素成为新开头
bool srt = std::is_sorted(v.begin(), v.end()); // 是否已升序
std::next_permutation(v.begin(), v.end());     // 生成下一个字典序排列

/* —— 查找与二分(二分类要求区间已排序) —— */
auto it = std::find(v.begin(), v.end(), 8);     // 顺序查找,找不到返回 end()
auto ev = std::find_if(v.begin(), v.end(), [](int x){ return x%2==0; }); // 找第一个满足谓词者
int  n  = std::count(v.begin(), v.end(), 8);    // 统计等于值的个数(count_if 用谓词)
auto aj = std::adjacent_find(v.begin(), v.end()); // 首个「相邻相等」处
bool in = std::binary_search(v.begin(), v.end(), 8); // 已排序区间判在否
auto lo = std::lower_bound(v.begin(), v.end(), 8); // 首个 ≥ 值的位置;upper_bound 为首个 > 值

/* —— 判定与比较 —— */
bool pos = std::all_of(v.begin(), v.end(), pred); // 全满足?any_of / none_of 同理
bool eq  = std::equal(a.begin(), a.end(), b.begin()); // 两区间逐元素相等?
auto ms  = std::mismatch(a.begin(), a.end(), b.begin()); // 首个不同处的一对迭代器

/* —— 极值与夹取 —— */
auto mx = std::max_element(v.begin(), v.end());   // 区间最大元素的迭代器;min_element 同理
auto [pmn, pmx] = std::minmax_element(v.begin(), v.end()); // 一次拿到最小 / 最大位置
int  hi = std::max({3, 9, 5});               // 对「若干值」取极值(区别于 _element 版)
int  y  = std::clamp(y0, 0, 10);            // 夹取单个标量,非区间算法(C++17)

/* —— 变换 · 生成 · 拷贝 · 删除 —— */
std::transform(v.begin(), v.end(), v.begin(), fn); // 逐元素变换,可写回原地
std::for_each(v.begin(), v.end(), [](int x){ std::print("{}", x); }); // 逐元素执行副作用
std::copy_if(v.begin(), v.end(), std::back_inserter(out), pred); // 条件拷贝到 out
std::generate(v.begin(), v.end(), []{ return 0; }); // 用函数逐个生成(fill 是常量版、iota 是递增版)
std::replace(v.begin(), v.end(), 0, -1);      // 把所有 0 换成 -1
auto mid = std::remove_if(v.begin(), v.end(), pred); // 前移保留元素、返回新逻辑末尾…
v.erase(mid, v.end());                        // …必须配 erase 才真正删除(见 pitfall)

/* —— 划分 —— */
auto pt = std::partition(v.begin(), v.end(), pred); // 满足谓词的挪到前段,返回分界迭代器
std::stable_partition(v.begin(), v.end(), pred); // 同上但保持相对次序

/* —— 有序集合运算(输入须已排序) —— */
std::merge(a.begin(),a.end(), b.begin(),b.end(), std::back_inserter(out)); // 归并两个有序区间
std::set_union(a.begin(),a.end(), b.begin(),b.end(), std::back_inserter(out)); // 并集;另有 set_intersection / set_difference
bool sub = std::includes(a.begin(),a.end(), b.begin(),b.end()); // a 是否包含 b(子集判定)

/* —— 归约与前缀(<numeric>) —— */
int sum = std::accumulate(v.begin(), v.end(), 0); // 求和 / 归约,第三参为初值
int dot = std::inner_product(v.begin(), v.end(), v.begin(), 0); // 内积 Σ vᵢ²
std::partial_sum(v.begin(), v.end(), out.begin());  // 前缀和 → out
std::adjacent_difference(v.begin(), v.end(), out.begin()); // 相邻差分 → out
int g = std::gcd(12, 18), l = std::lcm(4, 6); // 最大公约 / 最小公倍(C++17)
int rd  = std::reduce(v.begin(), v.end());   // C++17 可并行版 accumulate(求值无序)

/* —— 去重与删除(配合 erase) —— */
v.erase(std::unique(v.begin(), v.end()), v.end()); // 先 sort 再删相邻重复
std::erase_if(v, pred);                          // C++20:一步删除满足谓词者

/* —— C++20 ranges:省 begin/end · 可管道(详见 10 章 Ranges 卡)· 支持投影 —— */
std::ranges::sort(v);                          // 直接传整个容器,等价于 sort(v.begin(), v.end())
std::ranges::sort(people, {}, &Person::age);      // 投影:按成员排序,无需手写 comp
auto view = v | std::views::filter(pred)
              | std::views::transform(fn)
              | std::views::take(3);            // 惰性视图链;另有 drop / reverse / join
// C++23:auto vec = view | std::ranges::to<std::vector>();  // 惰性结果收集回容器
std::remove / remove_if 并不真正删除元素——它只是把要保留的元素前移、返回新的逻辑末尾迭代器,容器 size 不变,必须再调 erase 才删得掉(即 erase-remove 惯用法)。C++20 起用 std::erase / std::erase_if 一步到位更省心。
容器选型:随机访问 / 连续内存→vector;两端高效进出→deque;中间频繁增删且需迭代器稳定→list;按 key 最快查且不在乎顺序→unordered_map;需有序 + 区间查询→map;仅去重→set(有序)/ unordered_set(更快)。注意 unordered_* 平均 O(1),但哈希冲突严重时最坏退化到 O(n)
本卡只列工程中最高频的一批;标准库算法共 100+ 个,完整目录见 cppreference 的 <algorithm> / <numeric>

C++ 处理文本的四层:string 拥有并管理字符数据,string_view 只读借用不拷贝,format(C++20)做类型安全的格式化,charconv(C++17)做最快的数字⇄字符串转换。regex 做模式匹配。这一族最容易踩的两个坑——view 悬垂find 返回 npos 而非 -1——都标在下面对应行上。

// ══════ <string> ── 拥有型字符串:自己管理内存,可增可改 ══════
std::string str = "hi";
// 追加|摊还 O(追加长度)|可能重新分配,此后所有指向内部的指针/迭代器失效
str += "!";
// 追加 n 个重复字符|O(n)|常用于补齐 / 画分隔线
str.append(3, '.');
// 取子串|O(子串长度)|⚠ 会拷贝一份新 string;只读场景改用 string_view 免拷贝
str.substr(1, 2);
// 查找子串首次出现位置|O(n·m)|⚠ 找不到返回 std::string::npos(一个巨大的无符号数),不是 -1
str.find("i");
// 前缀判断|O(前缀长度)|C++20;另有 ends_with(同 C++20)与 contains(C++23)
str.starts_with("h");
// 取出 const char* 交给 C 接口|O(1)|⚠ str 一旦修改或析构,该指针立刻失效
str.c_str();

// ══════ <string_view> ── 只读非拥有视图:零拷贝,但只是「借」 ══════
// 从 string 构造只读视图|O(1),不分配不拷贝|⚠ 不延长源串寿命,别比源串活得久,更别 return 局部串的 view
std::string_view sv = str;
// 函数参数首选它|O(1) 传递|string / const char* / 字面量都能隐式转进来,一个签名通吃
void log(std::string_view msg);

// ══════ <format> / <charconv> ── 格式化与数字转换 ══════
// C++20 类型安全格式化|取代 printf|格式串错误在编译期就报,不会像 printf 那样运行期才炸
std::format("{}-{:.2f}", 1, 3.14159);      // → "1-3.14"
// 最快的「字符串→数字」|不抛异常、不分配、不认 locale|返回 {ptr 停在首个未消费字符, ec},必须检查 ec
auto [ptr, ec] = std::from_chars(p, p + n, out);
// 最快的「数字→字符串」|写进你给的缓冲,不分配|⚠ 缓冲不够时 ec 为 errc::value_too_large,不会截断也不报错
char buf[16];
auto [p2, ec2] = std::to_chars(buf, buf + sizeof buf, 3.14);
// 老接口:简单但慢|stoi 越界或非法输入会抛异常,to_string 对浮点精度不可控
std::stoi("42");   std::to_string(42);

// ══════ <regex> ── 正则(语法本身见「正则表达式」专页)══════
// 构造正则对象|⚠ 构造开销很大(要编译成状态机),务必提到循环外复用
std::regex re(R"(\d+)");
// 在串中搜索首个匹配|结果放进 match_results|另有 regex_match(要求整串匹配)/ regex_replace
std::regex_search(str, mt, re);
string_view 悬垂是新手最常见的内存错误std::string_view sv = get_name(); 里若 get_name() 按值返回 string,那个临时串在语句结束就析构,sv 当场变成悬垂视图——编译器不会警告。规则:view 只用作函数参数短命局部量,绝不用作成员变量或返回值,除非你能确证源串活得更久。
拼接与格式化的选型:两三段拼直接 +=;要格式(补零、小数位、对齐)用 std::format;热点路径上把数字转字符串用 from_chars / to_chars——比 stringstream 快一个数量级且不分配内存。std::ostringstream 只适合冷路径的临时拼装。

切分、去空白、转大小写——这些天天要用的操作,C++ 标准库偏偏没有现成函数(这本身就是高频困惑点)。下面是每个操作最稳的惯用法;凡涉及 <cctype> 的,都逃不开「先转 unsigned char」这一步(见 pitfall)。

#include <sstream>
#include <algorithm>
#include <cctype>

// ══ 切分:没有 split,最稳的是 getline + 分隔符 ══
std::vector<std::string> parts;
std::istringstream iss("a,b,c");
for (std::string tok; std::getline(iss, tok, ','); )
    parts.push_back(tok);            // {"a","b","c"};C++20 另有 views::split

// ══ 去空白 trim:find_first/last_not_of 掐两头 ══
std::string t = "  hi  ";
auto a = t.find_first_not_of(" \t\n");
auto z = t.find_last_not_of(" \t\n");
std::string trimmed = (a == std::string::npos)
    ? "" : t.substr(a, z - a + 1);   // "hi"

// ══ 大小写:transform + tolower/toupper,先转 unsigned char ══
std::string low = "AbC";
std::transform(low.begin(), low.end(), low.begin(),
    [](unsigned char ch){ return std::tolower(ch); });  // "abc"

// ══ 字符分类 <cctype>:同样必须转 unsigned char ══
std::isdigit((unsigned char)'7');  // isspace / isalpha / isalnum 同理

// ══ 全局替换:循环 find + replace,从替换点之后继续找 ══
std::string p = "a.b.c";
for (std::size_t i = 0; (i = p.find('.', i)) != std::string::npos; ++i)
    p.replace(i, 1, "/");            // "a/b/c"
<cctype> 全家(tolower / isdigit / isspace…)传 char 前必须转 unsigned charchar 在多数平台有符号,非 ASCII 字节是负值,传进去(除 EOF 外的负值)是 UB,在中文串上可能崩或返错。因此上面 transform 的 lambda 参数写成 unsigned char。另一个坑:全局替换若新串仍含旧模式(如把 "a" 换成 "aa"),从头 find 会无限循环——必须从替换点之后继续搜(上面的 find('.', i) 就是这么做的)。
选型速记:切分要简单→getline,要惰性零拷贝且在 C++20→std::views::split;大小写与字符分类只对 ASCII 正确,中文 / 重音字符得靠 ICU;拼接回去用 02 章的 ostringstream 或直接 += 加分隔符。

这一族解决的是「所有权」与「表达力」两件事:智能指针把「谁负责 delete」写进类型;optional / variant / tuple 则让「可能没有值」「几选一」「一次返回多个」不必再靠魔数、空指针和输出参数硬凑。RAII 与所有权转移的原理见 08 章,这里只列操作。

// ══════ <memory> ── 智能指针与所有权(原理详见 08 章)══════
// 创建独占所有权的堆对象|一次分配|默认就选它,别裸 new;不可拷贝,只能 std::move 转交
auto up = std::make_unique<T>(args);
// 像裸指针一样使用|O(1)|零运行期开销,unique_ptr 与裸指针一样大
up->f();   *up;
// 取出裸指针给旧接口用|O(1)|⚠ 只是「借看」,绝不能让对方 delete 它
up.get();
// 创建共享所有权的堆对象|控制块与对象一次分配|引用计数归零才析构
auto sh = std::make_shared<T>(args);
// 弱引用,不增加计数|用来打破 shared_ptr 循环引用|⚠ 用前必须 wp.lock() 提升并判空
std::weak_ptr<T> wp = sh;
// ⚠ shared_ptr 只有「引用计数」本身线程安全;它指向的对象仍要你自己加锁

// ══════ <optional> ── 「可能没有值」,比返回 -1 / 空指针表意清楚 ══════
std::optional<int> opt = 7;
// 判空后解引用取值|O(1)|⚠ 空时 *opt 是 UB;不确定就别用 *
if (opt) *opt;
// 取值,空则抛异常|O(1)|抛 std::bad_optional_access,比 UB 安全
opt.value();
// 取值,空则用默认值|O(1)|最省心的取法,不用先判空
opt.value_or(0);

// ══════ <variant> ── 类型安全的 union:几个类型选其一 ══════
std::variant<int, std::string> var = 42;
// 按类型取值|O(1)|⚠ 当前存的不是该类型时抛 std::bad_variant_access
std::get<int>(var);
// 对当前实际类型分派处理|O(1)|编译期检查是否覆盖了全部类型,漏一个就编译不过
std::visit(f, var);
// <any>:持有任意类型,取值时必须知道确切类型|⚠ 类型不符抛 bad_any_cast;能用 variant 就别用它(无编译期检查)
std::any an = 42;   std::any_cast<int>(an);

// ══════ <utility> / <tuple> ── 打包多个值 ══════
// 两个值打包,并用结构化绑定拆开|O(1)|C++17 起 pair/tuple 支持类模板参数推导,不用写类型
std::pair pr{1, 2};   auto [x, y] = pr;
// 任意多个值打包,按位置取|O(1)|下标必须是编译期常量
std::tuple tp{1, "a"};   std::get<0>(tp);
// 转成右值 / 完美转发|O(1),纯编译期|⚠ std::move 不移动任何东西,只是一次类型转换,见 04 章
std::move(x);   std::forward<T>(x);
newmake_* 不是风格之争f(std::shared_ptr<A>(new A), g()) 里,编译器可以先 new A、再调 g()、最后才构造 shared_ptr——若 g() 抛异常,那块 A永久泄漏make_shared / make_unique 把分配与接管压成一步,从根上消掉这个窗口。
返回值该用哪个:可能失败且调用方要知道原因 → std::expected<T, E>(C++23,见并发与错误卡);可能没有值但无所谓原因 → std::optional<T>;一定有值但有多个 → tuple + 结构化绑定。输出参数(T& out)在现代 C++ 里基本可以退休了。

三块彼此独立、但都极易用错的设施:数值归约的坑在初值类型,随机数的坑在把引擎当函数反复新建,计时的坑在选错时钟。下面每一条都把对应的陷阱标在行上。

// ══════ <numeric> / <cmath> / <limits> ── 数值 ══════
// 区间求和 / 归约|O(n)|⚠ 初值类型决定累加类型:对 double 传 0 会整数截断,必须传 0.0
std::accumulate(b, e, 0.0);
// 可并行的归约版本|O(n),可指定并行策略|C++17;要求操作满足结合律与交换律,否则结果不确定
std::reduce(b, e, 0.0);  // 完整归约/前缀家族见本章「算法速查」卡
// 最大公约数 / 最小公倍数|O(log n)|C++17
std::gcd(12, 18);   std::lcm(4, 6);
// 把值夹进 [lo, hi]|O(1)|⚠ 它住在 <algorithm> 而不是 <numeric>
std::clamp(x, 0, 10);
// 取类型的上下限|编译期常量|替代硬编码 INT_MAX,换类型时不用改代码
std::numeric_limits<int>::max();
// 常用数学函数|O(1)|注意 std::abs 对整数和浮点有不同重载,别用 C 的 abs()
std::sqrt(2.0);   std::abs(-3);

// ══════ <random> ── 随机数:引擎 + 分布,永远是两件东西 ══════
// 引擎:产生原始随机位|⚠ 只播种一次、长期持有,别在每次调用里新建(那样序列会重复)
std::mt19937 rng{std::random_device{}()};
// 分布:把引擎输出映射到需要的范围|闭区间 [1, 6]|换范围就换分布对象,别自己取模
std::uniform_int_distribution<> dist(1, 6);
// 取一个随机数|把引擎交给分布调用
dist(rng);
// ⚠ 别用 rand() % n:低位随机性差,且取模会让分布不均(前几个值概率偏高)

// ══════ <chrono> ── 时间点、时长与时钟 ══════
// 取当前时间点|O(1)|⚠ 测耗时必须用 steady_clock(单调递增,不受系统改表影响)
auto t0 = std::chrono::steady_clock::now();
// 两个时间点相减得 duration,再转成想要的单位|duration_cast 会截断(向零取整),不四舍五入
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
               std::chrono::steady_clock::now() - t0).count();
// 时长字面量:100ms / 2s / 5min|需先 using namespace std::chrono_literals
using namespace std::chrono_literals;
std::this_thread::sleep_for(100ms);          // sleep_for 本身在 <thread> 里
// 要「墙上时间 / 日历」用 system_clock(可能被用户改表跳变);C++20 起有 year_month_day 与时区支持
把随机引擎写在函数里是最隐蔽的一个 bugint roll() { std::mt19937 rng{std::random_device{}()}; return dist(rng); } 看着没问题,但每次调用都重新构造并播种——在同一毫秒内连续调用时,某些平台的 random_device 会给出相同种子,于是你连着摇出一串相同的点数。引擎应当是 static 或由调用方持有并传入。
计时选时钟的一句话:想知道「过了多久」用 steady_clock,想知道「现在几点」用 system_clock——两者不可互换。用 system_clock 测耗时,遇上 NTP 校时或用户改表就会算出负数时长。

C++ 的流体系用同一套 << / >> 接口覆盖三种目标:控制台(iostream)、文件(fstream)、内存字符串(sstream)。filesystem(C++17)则把路径、目录遍历、文件属性这些过去只能靠 POSIX 的操作标准化了。流最大的坑是失败不抛异常、只默默置错误位——所以每次打开与读取后都得判一下。

// ══════ <iostream> ── 标准输入输出 ══════
// 输出并换行|'\n' 比 std::endl 快得多:endl 每次都强制刷新缓冲区
std::cout << x << '\n';
// 整行读入(含空格)|⚠ operator>> 会在空白处停下,读整行只能用 getline
std::getline(std::cin, line);
// ⚠ 先 cin >> n 再 getline 会读到空行:>> 把换行符留在了缓冲里,中间需 cin.ignore()

// ══════ <fstream> ── 文件流 ══════
// 打开文件用于读|析构时自动关闭(RAII),不用手动 close
std::ifstream fin("a.txt");
// 检查是否打开成功|⚠ 流可隐式转 bool,但失败时不抛异常——不判断就往下读会读到一堆垃圾
if (!fin) { /* 打开失败,及早返回 */ }
// 逐行读到文件尾|把读取本身作为循环条件,这是唯一正确的写法
while (std::getline(fin, line)) { }
// ⚠ 别写 while (!fin.eof()):eof 要等「读失败之后」才置位,会多循环一次处理空行
// 打开文件用于写|默认清空原内容,要追加传 std::ios::app
std::ofstream fout("b.txt");

// ══════ <sstream> ── 内存字符串流 ══════
// 往内存里拼字符串,再取出结果|⚠ 拼装较慢,热点路径改用 std::format
std::ostringstream oss;   oss << 3;   oss.str();
// 把字符串当输入流解析|适合切分一行里的多个字段
std::istringstream iss(line);   iss >> a >> b;

// ══════ <filesystem> ── 文件系统(C++17)══════
namespace fs = std::filesystem;
// 判断存在 / 取大小 / 递归建目录|⚠ exists 与后续操作之间存在竞态,别依赖它保证安全
fs::exists(p);   fs::file_size(p);   fs::create_directories(p);
// 遍历目录下的条目|非递归|递归版是 recursive_directory_iterator
for (const auto& e : fs::directory_iterator(".")) e.path();
// 每个函数都有「抛异常」与「填 error_code」两个重载,后者适合不用异常的代码库
std::error_code ec;   fs::remove(p, ec);
while (!fin.eof()) 是 C++ 里流传最广的错误写法:EOF 标志要等到一次读取失败之后才置位,所以循环体会在文件末尾多执行一次,处理一份没读进来的空数据。正确写法永远是把读取动作本身当作循环条件——while (std::getline(fin, line))while (fin >> x)
流的错误检查是「查询式」而非「异常式」:失败只置 failbit / badbit,程序照常往下跑。要么每步都判(if (!fin)),要么调 fin.exceptions(std::ios::failbit) 让它改抛异常。默认的沉默失败是数据处理脚本读出空结果的头号原因。

最后一张卡收尾三块:并发(线程、锁、原子量、异步任务,原理详见 09 章)、类型层(编译期判类型,详见 07 章),以及错误处理整套 C 标准库在 C++ 里的位置。并发部分的每一条都标了「不这么写会怎样」——这一族的错误几乎都不会在测试里稳定复现。

// ══════ <thread> ── 线程 ══════
// 启动一个线程|C++20:析构时自动 join 并支持请求停止|优于裸 std::thread
std::jthread t([]{ /* ... */ });
// ⚠ 裸 std::thread 若析构前既没 join 也没 detach,会直接 std::terminate 掉整个进程

// ══════ <mutex> ── 互斥与 RAII 上锁 ══════
std::mutex mx;
// 进入作用域上锁、离开自动解锁|⚠ 别手写 lock/unlock:中途 return 或抛异常就会漏解锁 → 死锁
{ std::lock_guard lk(mx); /* 临界区 */ }
// 需要中途解锁 / 配合条件变量时用它|比 lock_guard 略贵,但可手动 unlock、可移动
{ std::unique_lock lk(mx); cv.wait(lk, []{ return ready; }); }
// 一次锁多个互斥量|C++17|⚠ 分别上锁易死锁,多锁必须用 scoped_lock 一把锁全(内部有防死锁算法)
{ std::scoped_lock lk(mx1, mx2); }

// ══════ <atomic> ── 无锁原子操作 ══════
// 单个变量的原子增减|比加锁快得多|⚠ 只保证单个变量原子,多个变量间的一致性它管不了
std::atomic<int> cnt{0};   cnt.fetch_add(1);

// ══════ <future> ── 异步任务与结果传递 ══════
// 异步跑一个任务并拿到 future|⚠ 不写 std::launch::async 时标准允许「根本不开线程」,等你 get() 才同步执行
auto fut = std::async(std::launch::async, []{ return 42; });
// 阻塞等待并取结果|⚠ 只能调用一次,第二次调用是 UB;任务里抛的异常会在这里重新抛出
fut.get();

// ══════ <type_traits> / <concepts> ── 类型层(详见 07 章)══════
// 编译期判断类型并在不满足时报错|零运行期开销|错误信息比模板报错友好得多
static_assert(std::is_integral_v<int>);
// C++20 现成 concept:std::integral / floating_point / same_as / convertible_to / ranges::range

// ══════ <stdexcept> / <expected> ── 错误处理 ══════
// 抛出标准异常|另有 logic_error / out_of_range / invalid_argument|捕获时用 const& 接,避免切片
throw std::runtime_error("boom");
// C++23:不抛异常地返回「值 或 错误」|错误信息带类型,比 optional 多告诉你「为什么失败」
std::expected<int, Err> r = f();

// ══════ <cXXX> ── 整套 C 标准库并入 std 命名空间 ══════
// <cmath> <cstring> <cstdlib> <cstdio> <cctype> <ctime>… 即 C 的 math.h / string.h / …(详见「工程基础」页 04 章)
// 名字进了 std::,语义仍是 C 的(无边界检查、无异常、无 RAII)
std::strlen("hi");   std::memcpy(d, s, n);   std::atoi("1");
// 但日常应优先用 C++ 对应物:string / string_view 替 strXXX,from_chars 替 atoi,format 替 printf
标 C++20 / C++23 的设施(jthreadformatexpectedprintflat_map…)编译器支持参差:多数需要 GCC 13+ / Clang 17+ / 较新 MSVC,且必须显式开 -std=c++20-std=c++23。写进项目前先查 cppreference 每页底部的「Compiler support」表,别假定能编过。
头文件别靠「传递包含」蹭——<vector> 里恰好间接包了 <string> 只是某个实现的巧合,换个编译器就编不过。用到什么就显式 include 什么;clang-tidy 的 misc-include-cleaner 或 include-what-you-use 能自动查漏。
更新的标准库设施(<bit>flat_mapmdspan<print>)见 10 章。

泛型编程与模板

STL 之所以能对任意类型通用,靠的正是模板。它是 C++ 最强大也最复杂的特性,让你编写与类型无关的通用代码。C++20 的 Concepts 大幅改善了模板的可读性和错误信息。

模板是 STL 对任意类型通用的根基:代码写一次,编译器按每种实际用到的类型替你生成一份。

模板让你写一次代码,用于多种类型。函数模板在调用时通常可以自动推导类型参数。类模板需要显式指定类型参数(C++17 起支持 CTAD 自动推导)。模板在编译期实例化,为每种使用的类型生成一份代码。
// 函数模板
template<typename T>
T max_val(T a, T b) {
    return (a > b) ? a : b;
}

max_val(3, 7);           // T = int,自动推导
max_val(3.14, 2.72);     // T = double

// 类模板
template<typename T, int N>
class FixedArray {
    T data_[N];
public:
    T& operator[](int i) { return data_[i]; }
    constexpr int size() const { return N; }
};

FixedArray<double, 10> arr;  // 10 个 double 的固定数组
模板的定义必须在头文件中(或使用显式实例化),因为编译器需要在每个使用点看到完整的模板定义才能实例化。把模板实现放在 .cpp 文件中会导致链接错误。
两个实参类型不一致时推导会直接失败:max_val(3, 2.72) 报「deduced conflicting types for parameter 'T' ('int' and 'double')」(g++ 15 报错节选)。此时显式指定 max_val<double>(3, 2.72),或先把实参统一成同一类型。

特化让你对特定类型给出不同实现——它是类型特征、STL 定制(std::hashvector<bool>)背后的机制,if constexpr 替代不了它。

全特化

  • 主模板 + template<> struct X<int> {…}:对某个具体类型完全重写。

偏特化(只有类模板能)

  • 对「一类」类型定制:X<T*>X<vector<T>>
  • 典型用途——写一个 trait,如用 <T*> 偏特化实现 is_pointer

函数模板不能偏特化

  • 函数模板只能特化或重载;想「按一类类型」定制函数,用重载(配 concepts / SFINAE 约束),别写偏特化。
// 主模板
template<typename T>
struct is_pointer { static constexpr bool value = false; };

// 偏特化:对「任意 T*」
template<typename T>
struct is_pointer<T*> { static constexpr bool value = true; };

static_assert(is_pointer<int*>::value);   // true
static_assert(!is_pointer<int>::value);    // false
特化必须在「首次会实例化主模板的使用点」之前可见,否则编译器已用主模板实例化,你的特化被忽略。而给函数模板写特化还会与重载决议打架(「为什么我的特化从不被选中」)——函数一律用重载而非特化。
标准库到处是特化:std::hash<MyType>std::numeric_limits<T>、迭代器 traits。要给自己的类型接入这些机制,就是在对应命名空间里写一个特化。类型层面的分派用特化,值层面的分支用 if constexpr(见本章末卡)。

把「T 得长什么样」直接写进函数签名,编译器报错时也能说人话——这是模板可用性十年来最大的一次升级。

Concepts 是 C++20 引入的革命性特性,让你对模板参数做编译期类型约束。不再需要晦涩的 SFINAE 技巧,模板报错信息也从「天书」变为人类可读的提示。
#include <concepts>

// 定义概念
template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;

// 用法一:requires 子句
template<typename T>
    requires Numeric<T>
T add(T a, T b) { return a + b; }

// 用法二:简写(推荐)
auto multiply(Numeric auto a, Numeric auto b) {
    return a * b;
}

add(1, 2);          // ✅ OK
add("a", "b");      // ❌ 编译错误:const char* 不满足 Numeric
重载偏序(「更特殊的重载优先」)只对命名 concept 生效:两个重载直接用 requires (std::is_integral_v<T>) 这类裸 trait 约束时,即使一个条件严格强于另一个,编译器也不认强弱关系——两者同时可行就报「call of overloaded 'f(int)' is ambiguous」(g++ 15 报错节选)。把条件包成 concept 再组合才有包含关系。
标准库已提供大量现成 concept:std::integralstd::floating_pointstd::same_asstd::convertible_tostd::ranges::range 等,优先使用它们。

Concepts 是 C++20 的答案;在此之前,「按类型约束重载」靠类型特征 + SFINAE。精通既要能读懂海量存量代码,也要明白 concepts 究竟替换了什么。

类型特征 <type_traits>

  • is_integral_v<T>remove_reference_t<T>conditional_t<b,X,Y>_v / _t 是取值 / 取类型的助手;
  • 一个 trait 就是「主模板 + 偏特化」(见本章特化卡)。

SFINAE 原则

  • 「替换失败不是错误」——模板实参替换失败时,该重载被悄悄剔除而非报错,从而实现按类型选择重载。

常用手法

  • std::enable_if_t<cond, R> 放返回类型 / 模板参数里开关重载;
  • void_t 检测惯用法:探测「类型是否有某成员」。
#include <type_traits>

// enable_if:只对整数类型启用这个重载
template<typename T,
         typename = std::enable_if_t<std::is_integral_v<T>>>
void f(T x) { /* 只有 T 是整数时才可见 */ }

// void_t 检测:T 是否有 ::value_type
template<typename, typename = void>
struct has_value_type : std::false_type {};
template<typename T>
struct has_value_type<T, std::void_t<typename T::value_type>> : std::true_type {};

// C++20 同一意图一行搞定,报错还可读:
// template<std::integral T> void f(T x);
两个用 enable_if 互斥开关的重载,条件必须真正不相交,否则两者都可行 → 歧义编译错误。而 enable_if 塞进默认模板参数时,两个「签名相同、仅 enable_if 不同」的重载会被视作重复声明——这正是 concepts(requires)出现要终结的痛点。
新代码优先 concepts(见上一卡,报错清晰十倍);但 <type_traits>_v / _t 助手在 if constexprstatic_assert、concept 定义里天天用,仍是基本功。

参数个数不定的泛型工具(make_unique、日志、print)全靠它:参数包一路收进来,折叠表达式一行展开去。

可变参数模板(C++11)接受任意数量、任意类型的参数。折叠表达式(C++17)让展开参数包变得简洁,不再需要递归终止函数的套路。这是实现 std::make_unique、日志函数等通用工具的基础。std::forward 用于「原样转发」参数:使原本可移动的临时实参在转发后仍保持其值类别,避免中转过程中被退化为左值而触发拷贝。
// C++17 折叠表达式:优雅地展开参数包
template<typename... Args>
auto sum(Args... args) {
    return (args + ...);  // 右折叠:a + (b + (c + d))
}

sum(1, 2, 3, 4);  // 10

// 打印所有参数
template<typename... Args>
void print(Args&&... args) {
    (std::cout << ... << args) << '\n';
}

print("x=", 42, ", y=", 3.14);
// 输出:x=42, y=3.14

// 完美转发(保持参数的值类别)
template<typename T, typename... Args>
std::unique_ptr<T> make(Args&&... args) {
    return std::make_unique<T>(std::forward<Args>(args)...);
}
对空参数包折叠 + 直接编译失败:调用 sum() 时 g++ 15 报「fold of empty expansion over operator+」。只有 &&(空包为 true)、||(空包为 false)和逗号三个运算符对空包有默认值,其余运算符都得像 (0 + ... + args) 一样带初值。
sizeof...(args) 编译期取参数个数;折叠时给个初值写成 (0 + ... + args),既定了结合方向也让空参数包合法。

这是模板里最深的一环:T&&类型推导下不是右值引用,而是能同时绑左值 / 右值的转发引用。搞懂它才能写出 emplace 式的零损耗包装,也才真正理解移动语义。

转发引用 vs 右值引用

  • 只有对被推导的 TT&& 才是转发引用;const int&&vector<T>&& 里的固定类型是普通右值引用。

引用折叠规则

  • & && &&&& &&;只有 && &&&&。左值实参令 T = U&,右值实参令 T = U

move vs forward

  • std::move = 无条件转成右值;std::forward<T> = 有条件保持原值类别——只在转发引用参数上用 forward。
// 完美转发:把参数原封不动(保持左右值)传给构造
template<typename T>
void wrapper(T&& arg) {          // T&& = 转发引用
    consume(std::forward<T>(arg)); // 左值→左值,右值→右值
}

int x = 1;
wrapper(x);        // 左值实参 → T=int&,折叠成 int&,forward 转出左值
wrapper(42);       // 右值实参 → T=int,forward 转出右值

// 变参 + 完美转发 = emplace 的实现套路
template<typename... Args>
void emplace(Args&&... args) {
    new (slot) T(std::forward<Args>(args)...);
}
std::move 一个转发引用参数——那会无条件把左值调用者的对象也「偷空」。转发引用还会贪婪压过拷贝 / 移动构造(template<class T> C(T&&)C(const C&) 更匹配)——用 concept / enable_if 约束住它,否则拷贝构造会被意外劫持。
口诀:看到被推导的 T&& 就配 std::forward<T>;看到确定类型的 X&& 就配 std::move移动语义的用户视角见 04 章,这里是它在泛型代码里的一般化。

同一份函数既能编译期算也能运行期跑,「能提前算的都提前算」从优化技巧变成了语言机制。

constexpr(C++11 引入;函数体在 C++14 才放宽为可含 if / 循环等多条语句)标记的函数可以在编译期执行,也可以在运行期执行。consteval(C++20)强制只能在编译期执行。if constexpr(C++17)实现编译期分支选择,替代了大量 SFINAE 和 enable_if 的写法。
// constexpr 函数:编译期 + 运行期都能用(多语句体需 C++14)
constexpr int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

constexpr int f5 = factorial(5);  // 编译期计算 = 120

// if constexpr:编译期分支(C++17)
template<typename T>
std::string to_str(T val) {
    if constexpr (std::is_integral_v<T>) {
        return std::to_string(val);
    } else if constexpr (std::is_same_v<T, std::string>) {
        return val;
    } else {
        return "???";
    }
}
constexpr 只是「允许」编译期执行,不是保证:结果不存进 constexpr 变量(或数组长度、模板实参等常量上下文),计算就悄悄发生在运行期。要强制编译期用 consteval——给它传运行期值立刻报「call to consteval function 'cfact(argc)' is not a constant expression」(g++ 15 报错节选)。
if constexpr 的威力在于:不满足条件的分支不会被实例化,即使那个分支里的代码对当前类型来说是编译错误也没关系。这比传统 SFINAE 写法清晰 10 倍。

内存管理与 RAII

泛型让代码更抽象,但 C++ 始终绕不开一个底层现实:内存要自己管。这是它的核心挑战——先看清 new/delete 手动管理的机制与危险(全书反复告诫的正主),再理解智能指针与 RAII 如何驯服它,就能写出无内存泄漏、异常安全的代码,达到接近 Rust 的安全性(指针、栈与堆、内存布局的硬件视角见组成原理页)。

本页从 00 章起就反复提醒你「别用 new/delete」——但要真正理解这条铁律,得先看清它管的是什么。局部变量在上,随作用域自动生灭;new 则在上开辟一块生命周期由你亲手掌控的内存:灵活到能跨函数、跨作用域存活,代价是每一次分配都欠下一次释放,而编译器不会替你记账。

new / delete 各做了两件事

  • new T(args):① 在堆上分配一块内存 → ② 在其上构造对象,返回 T*
  • delete p:① 调用对象的析构函数 → ② 归还内存。漏掉它 = 内存泄漏;
  • 所以裸指针只是「借来的门牌号」,谁 new 的谁负责 delete——所有权全靠人脑约定。

数组必须配对:new[]delete[]

  • new T[n] 分配一排对象,返回首元素指针;释放必须delete[],它才会逐个析构;
  • delete(无方括号)释放 new[] 的内存是未定义行为,反之亦然——二者形状必须一一对应

四类高发错误:都源于「人肉记账」

  • 泄漏:忘记 delete,或提前 return / 抛异常绕过了它;
  • 双重释放:同一指针 delete 两次 → 堆损坏;
  • 悬垂指针:delete 后指针未置空,又拿去解引用(use-after-free);
  • 异常不安全newdelete 之间一旦抛异常,栈展开跳过 delete → 泄漏。
// 单个对象:new 配 delete
int* p = new int(42);   // 堆上分配 + 构造,返回指针
std::print("{}", *p);       // 解引用用它
delete p;                   // 析构 + 归还内存(漏掉 = 泄漏)
p = nullptr;                // 置空,避免悬垂 / 双重释放

// 数组:new[] 必须配 delete[]
int* arr = new int[10]{};  // 10 个 int,全部零初始化
arr[0] = 1;
delete[] arr;                // ⚠️ 少了 [] 就是 UB

// 四类典型错误
int* a = new int(1);
// delete a; delete a;      // ❌ 双重释放 → 堆损坏
// delete a; *a = 2;        // ❌ 悬垂:use-after-free

void leaky() {
    int* buf = new int[256];
    mayThrow();                // ⚠️ 一旦抛异常,下面的 delete 被跳过
    delete[] buf;               // 正常路径才走到 → 异常路径泄漏
}
new/deletenew[]/delete[] 必须严格配对,混用是未定义行为——这是裸内存管理最隐蔽的坑之一。delete 空指针(nullptr)是安全的(什么都不做),但 delete不置空,指针就成了悬垂指针,极易被再次 delete 或解引用。还要记住:new 返回的裸指针不带长度、不带所有权信息,靠人脑追踪谁该释放——这正是它易错的根源。
看懂机制,是为了之后不再手写它:现代 C++ 里几乎每一个裸 new 都能被 std::make_unique / std::make_shared 取代,每一块 new[] 都该换成 std::vector。它们用 RAII 把「分配即欠一次释放」变成「作用域结束自动归还」,从根上消灭上面四类错误。下一张卡的智能指针三件套,就是这条铁律的现代答案。

把「谁负责释放」从人脑约定升级成类型系统的契约——三种智能指针,对应三种所有权关系。

现代 C++ 中应该几乎不使用 new/delete,转而使用智能指针。unique_ptr 独占所有权,零开销;shared_ptr 引用计数共享所有权;weak_ptr 弱引用,不增加计数,用于打破循环引用。
#include <memory>

// unique_ptr:独占所有权(首选)
auto p1 = std::make_unique<int>(42);
// auto p2 = p1;  ❌ 编译错误:不能拷贝
auto p2 = std::move(p1);  // ✅ 可以移动

// shared_ptr:共享所有权
auto sp1 = std::make_shared<std::string>("hello");
auto sp2 = sp1;  // 引用计数 = 2
sp1.use_count(); // 2

// weak_ptr:打破循环引用
std::weak_ptr<std::string> wp = sp1;
if (auto locked = wp.lock()) {
    std::print("{}", *locked);  // 安全访问
}
不要用 shared_ptr 管理数组(C++17 之前)!shared_ptr<int>(new int[10]) 析构时调用 delete 而非 delete[],导致未定义行为。C++17 后可以用 shared_ptr<int[]>,但更推荐直接用 vector
默认 unique_ptr + make_unique,确有共享需求才升级 shared_ptr——独占所有权零开销,还把「不能拷贝」写进了类型:误写 auto p2 = p1; 直接编译错,g++ 15 第一行报 error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&)'。函数只是「借用」对象时参数收 T* / T&,要「接管所有权」才按值收 unique_ptr——别为了传一下就上 shared_ptr。

智能指针不止管 new——真实代码用它包 C 资源(FILE*、socket、GPU 句柄)。而把 this 安全地交出去做 shared_ptr,只有一条正道。

自定义删除器

  • unique_ptr<FILE, decltype(&fclose)> 或无捕获 lambda 删除器,让 RAII 接管任意释放动作;
  • 删除器是 unique_ptr 类型的一部分,但对 shared_ptr 不是(被类型擦除进控制块);
  • 数组所有权用 unique_ptr<T[]>(自动 delete[])。

shared_from_this

  • 继承 enable_shared_from_this<T>,用 shared_from_this() 拿到与已有 shared_ptr 共享同一控制块的指针——异步回调里延长自身寿命的唯一正确方式。
// 自定义删除器:用 unique_ptr 管 FILE*
std::unique_ptr<FILE, decltype(&fclose)>
    fp(fopen("data.txt", "r"), &fclose);  // 离开作用域自动 fclose

// 数组所有权
std::unique_ptr<int[]> buf(new int[256]);      // 自动 delete[]

// shared_from_this:安全地把自己交出去
struct Session : std::enable_shared_from_this<Session> {
    void start() {
        asyncCall([self = shared_from_this()]{ self->onDone(); }); // 寿命延长到回调
    }
    void onDone();
};
在对象还没被任何 shared_ptr 持有时调 shared_from_this()(栈对象、或刚 new 未交给 shared_ptr)会抛 bad_weak_ptr。更致命的是用同一裸指针建两个独立 shared_ptrshared_ptr<T> a(p), b(p);)→ 两个控制块 → 双重释放。要共享就从既有 shared_ptr 拷贝,或用 shared_from_this。
make_shared 把对象与控制块融合成一次分配(更快、更少 cache miss),优先用它;代价是——只要还有 weak_ptr 存活,对象内存就不释放(融合块整体存活)。大对象 + 长命 weak_ptr 场景,改用 shared_ptr<T>(new T) 分开分配。

「离开作用域必调析构」是 C++ 给出的最强承诺——RAII 把资源释放挂在这个承诺上,从此异常安全不靠人肉记忆。

RAII 是 C++ 最重要的编程范式:在构造函数中获取资源,在析构函数中释放资源。由于 C++ 保证局部对象在离开作用域时(即使因异常退出)一定会调用析构函数,RAII 天然实现了异常安全的资源管理。智能指针、lock_guard、fstream 都是 RAII 的典型应用。
// RAII 的典型例子:lock_guard
std::mutex mtx;
{
    std::lock_guard<std::mutex> lock(mtx);
    // 持有锁,做一些操作...
    // 即使抛出异常,lock 的析构函数也会释放锁
}  // 离开作用域,自动解锁

// 自定义 RAII 包装器
class FileHandle {
    FILE* fp_;
public:
    FileHandle(const char* path, const char* mode)
        : fp_(std::fopen(path, mode)) {
        if (!fp_) throw std::runtime_error("open failed");
    }
    ~FileHandle() { if (fp_) std::fclose(fp_); }

    // 禁止拷贝,允许移动
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;
};
自己写 RAII 包装器时必须处理拷贝:编译器默认生成的拷贝会让两个对象持有同一个句柄,析构时释放两次——把示例里的 = delete 拿掉再写 FileHandle b = a;,ASan 立刻报 attempting double-free(第二次 fclose 释放已释放的 FILE)。要么 = delete 禁拷贝、只留移动,要么实现深拷贝/引用计数——二选一,不能不管。
记住这个原则:每个 new 都应该被某个析构函数里的 delete 对应。更好的做法是:根本不写 new/delete,全部用 make_unique / make_shared / vector。

出错的地方往往没能力处理错误——异常把「报告错误」与「处理错误」解耦,代价是你得看懂栈展开这条路。

C++ 用异常报告无法就地处理的错误:throw 抛出,try/catch 捕获。抛出后发生栈展开(stack unwinding)——从 throw 到匹配的 catch 之间,每个已构造的局部对象都会被逆序析构,这正是 RAII 成为异常安全基石的原因:资源由局部对象托管,无论正常返回还是异常退出都会被释放。标准异常以 std::exception 为根,logic_error(如 out_of_range,本页 STL 章的 at() 越界即抛它)与 runtime_error 均派生自它,what() 返回描述。noexcept 声明函数不抛异常,违反会直接 std::terminate;移动构造应标记 noexcept,容器才敢在扩容时用移动而非拷贝。
#include <stdexcept>

// 抛出与捕获
double divide(int a, int b) {
    if (b == 0)
        throw std::runtime_error("division by zero");
    return static_cast<double>(a) / b;
}

try {
    auto r = divide(1, 0);
} catch (const std::exception& e) {  // 按 const 引用捕获基类
    std::cerr << e.what();            // "division by zero"
}

// 标准异常层次:std::exception 是根
//   ├─ logic_error   (invalid_argument / out_of_range …)
//   └─ runtime_error (overflow_error / system_error …)

// noexcept:承诺不抛异常(移动操作应标记)
void cleanup() noexcept { /* 一旦抛异常直接 terminate */ }
按值捕获异常会发生切片(slicing)——派生异常被截成基类丢失信息,始终写 catch (const std::exception&)。异常路径上的资源必须交给 RAII 管理:绝不在 catch 里手动 delete,否则 throw 到 catch 之间一旦再抛就泄漏。析构函数默认 noexcept,从析构函数里抛异常会直接 std::terminate
异常适合「不该发生却发生了」的错误;对「预期之中的失败」(解析失败、查无此项、超时),C++23 的 std::expected<T, E> 往往更合适——返回值里同时装着成功值或错误原因,调用方必须显式检查(g++ 15 / libstdc++ 可用,__cpp_lib_expected = 202211)。捕获端口诀只有一条:catch (const std::exception& e)——按 const 引用、捕基类。

上一卡讲的是「操作可能失败」,这一卡讲的是另一件完全不同的事:「这里本来就不该发生」。前者用异常或 expected,后者用断言——把这两件事混起来,是错误处理设计里最常见的一处走偏。

第一步:分清「错误」与「bug」

可预期的失败程序自身的 bug
例子文件不存在、网络断了、用户输入非法传进来一个空指针、下标越界、不变量被破坏
该用异常 / std::expected / 错误码断言
发布版要不要检查——它随时会真的发生通常不要——它发生就说明代码写错了
调用方能处理吗能(重试、提示、降级)不能,只能改代码
  • 判据一句话:「这个条件为假,是外部世界的问题,还是我自己的问题?」 外部世界的问题就抛/返回,自己的问题就断言;
  • 推论:断言绝不能用来校验用户输入或外部数据。那类检查在发布版里会被删掉,等于没做——而它恰恰是发布版最需要的检查。

NDEBUG 不是「不检查」,是「整条表达式根本不执行」

int n = 0;
assert(++n > 0);
printf("n=%d\n", n);

调试构建     → n=1
-DNDEBUG    → n=0   ← 那个 ++n 彻底消失了
  • assert 是宏,NDEBUG 定义后它被展开成 ((void)0)——括号里的整个表达式连同副作用一起被删掉
  • 所以铁律是:断言里绝不能写有副作用的表达式assert(f() == 0) 这种写法在发布版里 f() 根本不会被调用,而这类 bug 只在发布版出现,最难查;
  • -DNDEBUGRelease 构建的标配——CMake 的 CMAKE_BUILD_TYPE=Release 就带着它(见 11 章)。所以「我本地跑得好好的」在这里有了一个具体的机制解释;
  • 顺带一个数字:同一个 int f(int *p){ assert(p); return *p; }-O2 下生成 11 条指令,加 -DNDEBUG3 条——断言的运行期成本是实打实的,这也是发布版要关掉它的理由。

失败时会发生什么

r.out: r.cpp:3: int main(): Assertion `x==1' failed.
exit=134   (128 + 6,即 SIGABRT)
  • 输出包含文件、行号、所在函数、原始表达式四样——所以断言的表达式本身就是错误信息,通常不必再写注释;
  • 想让信息更可读,有个老技巧:assert(x == 1 && "x 必须为 1")——字符串字面量恒为真,不改变判断,却会原样出现在报错里;
  • 它走的是 abort() 而不是异常:不会展开栈、不会跑析构函数,core dump 里保留的是出事那一刻的现场。这正是排查 bug 想要的,也是它不适合做「可恢复错误」的另一个理由。

编译期那一半:static_assert

  • 能在编译期判断的条件,永远优先用 static_assert——它把错误提前到构建阶段,而且 NDEBUG 对它完全无效(加了 -DNDEBUG 照样报错),发布版一样受保护;
  • 消息可以省略:static_assert(cond)C++17 起合法(C++11/14 下 -pedantic-errors'static_assert' without a message only available with '-std=c++17');
  • C++26 起消息不必是字符串字面量(P2741):可以是任何编译期能求值的字符串对象,于是能把实际值算进报错里——在 GCC 15 上用 -std=c++2c 编译通过:
    template <int N> struct S {
      static_assert(N > 0, std::format("N 必须为正,实际是 {}", N));
    };
    注意 GCC 15 认的拼法是 -std=c++2c,写 -std=c++26 时这条特性仍报「only available with -std=c++2c」;
  • 模板代码里 static_assert 常用来把 SFINAE 那种天书报错换成一句人话——但 C++20 之后这活该交给 concepts(见 07 章),约束写在签名上,报错更早也更准。

constexpr 函数里的 assert:一条会让人愣住的报错

constexpr int half(int x){ assert(x % 2 == 0); return x / 2; }

static_assert(half(4) == 2);   → 通过(条件为真,assert 不求值)
static_assert(half(3) == 1);   → error: call to non-'constexpr' function
                                  '__assert_fail'
  • 机制:assert 失败时要调用 __assert_fail,而它不是 constexpr 函数——于是「断言失败」在常量求值里表现为「调用了非 constexpr 函数」;
  • 报错完全不提你的断言,第一次遇到几乎必然被误导去查 constexpr 规则。记住这个映射,看到 __assert_fail 就知道是某个 assert 没过;
  • 好的一面是:constexpr 上下文里的断言会在编译期就被检查——同一句 assert 在编译期求值时是编译错误,在运行期才是 abort。

C++26 Contracts:定案了,但今天一行都编不过

int f(int x) pre(x > 0) post(r: r > 0) { return x; }
int g(int x){ contract_assert(x > 0); return x; }

g++ 15.2  -std=c++2c        → error: expected initializer before 'pre'
g++ 15.2  -std=c++2c -fcontracts → 同样报错
clang++ 21.1.8 -std=c++2c   → error: expected function body after function declarator
  • Contracts(P2900)是 C++26 的招牌之一:把前置条件、后置条件写进函数签名,从此它们是接口的一部分而不是实现细节——调用方看得见,工具也能读;
  • 相对 assert 的三点改进:①写在声明上assert 只能写在函数体里,调用方看不到);②有独立的求值语义(可选 ignore / observe / enforce,而不是 NDEBUG 一刀切);③后置条件能引用返回值
  • 但今天的结论很干脆:两大编译器都还不认这个语法-fcontracts 那个旧标志是给 C++20 时代的 Contracts TS 用的,救不了它。所以这一卡的实用建议仍然是 assert + static_assert
  • 值得现在就做的一件事:把前置条件用 assert 写在函数体开头、并在文档注释里写明。将来 contracts 可用时,这些断言几乎能一对一翻译成 pre(...)

别混进来的两个近邻

  • [[assume(expr)]](C++23)不是断言:它不做任何检查,只是向优化器保证「这里恒为真」。条件若不成立就是 UB——它是「加速」不是「查错」,与 assert 的目的完全相反
  • std::unreachable()(C++23,<utility>同理:标注「执行流到不了这里」,走到了就是 UB(C++20 下 -pedantic-errors'unreachable' is not a member of 'std',C++23 通过);
  • 两者的正确用法是在已经用断言验证过的地方再告诉优化器,而不是拿它们代替断言。调试构建断言、发布构建 assume 是常见的组合写法。
#include <cassert>
#include <expected>
#include <utility>   // std::unreachable(C++23)

// —— ① 外部世界可能失败 → 返回 expected,发布版照样检查 ——
auto parse_port(std::string_view s) -> std::expected<int, std::string> {
    int v{};
    auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec != std::errc{} || v < 1 || v > 65535)
        return std::unexpected("端口非法");   // 用户输入错 —— 不是 bug
    return v;
}

// —— ② 调用方违约 → 断言,发布版删掉 ——
void fill(int* dst, int n, int v) {
    assert(dst != nullptr && "dst 不能为空");  // 字符串恒真,只为让报错可读
    assert(n >= 0);
    for (int i = 0; i < n; ++i) dst[i] = v;
}

// —— ③ 编译期就能判的 → static_assert,NDEBUG 也删不掉 ——
template <typename T>
void send_raw(const T& v) {
    static_assert(std::is_trivially_copyable_v<T>,
                  "只能直接发送可平凡复制的类型");
    // …
}

// —— ④ assume / unreachable:告诉优化器,不做检查 ——
int parity(int x) {
    switch (x & 1) {
        case 0: return 0;
        case 1: return 1;
        default: std::unreachable();   // 到不了;真走到就是 UB
    }
}

// 反例:这一句在 -DNDEBUG 下什么都不做,check() 根本不会被调用
// assert(check() == 0);
四个高频误用:① 断言里带副作用——-DNDEBUG 后整条表达式消失,assert(++n > 0) 在发布版里 n 停在 0;② 拿断言校验外部输入——发布版删掉后等于没校验,而这正是最需要校验的地方;③ 能用 static_assert 的地方用了 assert——白白推迟到运行期,还会被 NDEBUG 关掉;④ [[assume]] 当断言用——它一个字都不检查,条件不成立直接 UB,比不写还危险。还有一个环境坑:只在 Debug 构建下跑过测试,那些被 NDEBUG 删掉的断言从来没在 Release 路径上验证过——CI 里应当两种构建都跑一遍测试
把「断言」和「错误处理」的分工写进团队约定,比逐条评审有效:凡是参数、不变量、内部状态 → assert;凡是 I/O、用户输入、外部服务 → expected/异常。另外断言要写得够密——它的价值在于「让 bug 在离现场最近的地方炸掉」,而不是在三层调用之后表现成一个莫名其妙的数值。真怕发布版漏掉关键检查,就别用 assert,自己写一个不受 NDEBUG 影响的 CHECK() 宏(Google 的 glog/absl 就是这么做的)。

RAII 保证不泄漏,但「抛异常后对象处于什么状态」需要更精确的契约——这是每个资深工程师设计时脑中的三档异常安全保证

三档保证

  • 基本:不泄漏、对象仍处于有效但未指定状态;
  • :要么成功、要么回滚到原状(提交或全无);
  • nothrownoexcept,绝不抛。

copy-and-swap:拿到强保证的配方

  • 把所有可能抛的工作做在一份副本上,最后用 noexceptswap 一步换入;
  • operator= 用它同时得到:强保证 + 自赋值安全 + 复用拷贝构造(按值传参即可)。

为何 move / swap / 析构必须 noexcept

  • 只有「最后提交那步不抛」才谈得上强保证;vector 扩容时,移动构造非 noexcept 会被降级为拷贝(以维持强安全)——性能悄悄打折。
class Widget {
    int* data_;
    std::size_t n_;
public:
    friend void swap(Widget& a, Widget& b) noexcept {
        std::swap(a.data_, b.data_);
        std::swap(a.n_, b.n_);
    }
    // 按值传参 = 复用拷贝构造;再 swap → 强保证 + 自赋值安全
    Widget& operator=(Widget rhs) noexcept {
        swap(*this, rhs);
        return *this;
    }
    // 移动构造标 noexcept,vector 扩容才会「移动」而非「拷贝」
    Widget(Widget&& o) noexcept : data_(o.data_), n_(o.n_) { o.data_ = nullptr; }
};
copy-and-swap 的「按值传参 + swap」会多一次拷贝 / 移动,热路径上可能是性能悲观——权衡后再用。反例更要警惕:移动构造若可能抛(忘标 noexcept),vector 在重分配时会退回逐个拷贝以维持强保证,你以为在移动、其实在深拷贝。
设计接口时先问:这个操作提供哪一档保证?把 swap、移动操作、析构一律标 noexcept,是让容器和算法跑得快、又不破坏异常安全的前提。异常的基础用法(throw / try / std::exception)见上一卡。

内存错误最可怕的不是崩溃,而是「常常不崩溃」——程序照跑、数据悄悄坏掉,所以排查要靠工具而非运气。

C++ 给了程序员极大的内存控制自由,但也意味着更多出错机会。最常见的内存错误包括:悬垂指针(指向已释放的内存)、内存泄漏(忘记释放)、缓冲区溢出(越界访问)、使用未初始化变量。现代编译器提供了 Sanitizer 工具帮助检测这些问题。
// 常见错误示例

// 1. 悬垂引用
int& bad() {
    int x = 42;
    return x;  // ⚠️ x 离开作用域就销毁了!
}

// 2. 迭代器失效
std::vector<int> v = {1, 2, 3};
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it == 2) v.erase(it);  // ⚠️ erase 后 it 失效!
}
// 正确写法:it = v.erase(it);

// 编译时开启 Sanitizer 检测
// g++ -fsanitize=address,undefined -g code.cpp
// clang++ -fsanitize=memory -g code.cpp
内存错误多数是未定义行为——「跑起来没事」不代表真没事。同一份 use-after-free 代码,不开 sanitizer 时打印垃圾值、正常退出;开 -fsanitize=address 后立刻现形:use-after-free 报 heap-use-after-free,重复 delete 报 attempting double-free,忘记释放则由内建的 LeakSanitizer 报 detected memory leaks 并列出每处 Direct leak of N byte(s)。返回局部变量引用这类静态可查的错误,g++ 开 -Wall 就会警告 -Wreturn-local-addr——警告别当耳旁风。
ASan(AddressSanitizer)检测越界和 use-after-free;UBSan 检测未定义行为;TSan 检测数据竞争。开发阶段务必开启这些工具,它们能发现肉眼难以发现的内存 bug。

并发与多线程

管好了单线程的内存,多核时代还要管好共享内存。现代 CPU 都是多核的,并发编程是充分利用硬件的必修课。C++11 起标准化了线程库,C++20 引入了协程与 latch/barrier/semaphore 等同步原语(原子操作与内存序背后的缓存一致性/内存模型,见组成原理页并行章)。

线程是并发的起点——创建它只要一行,难的是记住每条线程都必须有人负责收尾。

std::thread 是 C++11 引入的线程类。创建后必须 join()(等待完成)或 detach()(分离),否则析构时程序会调用 std::terminate 终止。C++20 的 std::jthread 自动 join,更安全。
#include <thread>

// C++11:std::thread
void work(int id) {
    std::println("Thread {} running", id);
}

std::thread t1(work, 1);
std::thread t2(work, 2);
t1.join();
t2.join();

// C++20:std::jthread(自动 join + 支持取消)
{
    std::jthread jt([](std::stop_token st) {
        while (!st.stop_requested()) {
            // 工作循环,可被外部请求停止
        }
    });
}  // jt 离开作用域自动 request_stop + join
std::thread 如果既没有 join 也没有 detach 就析构了,程序会直接 std::terminate() 崩溃。这是最常见的多线程入门错误。强烈建议用 C++20 的 jthread 避免此问题。
编译并发代码记得加 -pthread;怀疑共享数据没保护,用 -fsanitize=thread 跑一遍——TSan 会打出「WARNING: ThreadSanitizer: data race」并给出两处访问的调用栈。另外线程函数的参数一律按值拷贝进新线程,要传引用得包 std::ref,否则直接编译不过(g++ 15 报 static assertion failed: std::thread arguments must be invocable after conversion to rvalues)。

多个线程碰同一份数据要互斥,要等条件成熟再干活则靠通知——这是并发同步的第一套工具。

多线程共享数据时需要同步std::mutex 提供互斥访问,配合 lock_guard(RAII 自动解锁)使用最安全。condition_variable 实现线程间通知——让等待的线程在条件满足时被唤醒,而不是忙等。
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> tasks;

// 生产者
void produce(int val) {
    {
        std::lock_guard lock(mtx);  // C++17 CTAD
        tasks.push(val);
    }
    cv.notify_one();  // 唤醒一个等待者
}

// 消费者
void consume() {
    std::unique_lock lock(mtx);
    cv.wait(lock, []{ return !tasks.empty(); });
    int val = tasks.front();
    tasks.pop();
}
notify_one 不排队——通知那一刻没有线程在 wait,这次唤醒就永远丢了。不带谓词的 wait 碰上这种时序会永久阻塞(生产者先 notify、消费者后 wait,程序挂死)。带谓词的版本会先检查条件再决定睡,天然免疫;这也是共享状态必须持锁修改的原因——否则「检查条件」与「进入等待」之间有竞态窗口。
condition_variable::wait 一定要配合谓词(第二个 lambda 参数),否则会因虚假唤醒(spurious wakeup)导致 bug。谓词会在唤醒后自动检查条件是否真的满足。

上一卡会加锁,但真实并发的头号 bug 是死锁,以及读多写少场景的读写锁。这一层是从「能上锁」到「上对锁」。

死锁与加锁顺序

  • 最常见成因是加锁顺序不一致:线程 A 锁 1 再锁 2、线程 B 锁 2 再锁 1,互等;
  • std::scoped_lock(m1, m2)(C++17)一次性加多把锁,内部用死锁避免算法,取代手写 std::lock + 两个 adopt_lock

读写锁 shared_mutex

  • std::shared_lock(读,可多个并发)vs std::unique_lock(写,独占)——读多写少时提升并发;
  • unique_locklock_guard 略贵,但可延迟 / 提前解锁、可转移、能配 condition_variable

thread_local

  • 每线程一份存储 → 无共享即无需加锁,是消除竞争的另一条路。
#include <mutex>
#include <shared_mutex>

std::mutex m1, m2;
// 一次锁两把,内部避免死锁(不用管顺序)
{
    std::scoped_lock lk(m1, m2);   // C++17
    /* 临界区 */
}

// 读写锁:读并发、写独占
std::shared_mutex sm;
int read() {
    std::shared_lock lk(sm);       // 多个读者可同时持有
    return shared_data;
}
void write(int v) {
    std::unique_lock lk(sm);       // 写者独占
    shared_data = v;
}
持锁时调用用户回调 / 外部代码,若回调重新获取同一把(非递归)锁 → 自死锁。别在持锁期间调不受你控制的代码。另外 shared_mutex写饥饿风险(读者络绎不绝时写者迟迟拿不到锁),高写入场景要评估。
多把锁一律用 scoped_lock,别手写多个 lock_guard(顺序一错就死锁);单把锁用 lock_guard(最轻);需与条件变量配合或手动控制时机时用 unique_lock。能用 thread_local / 无共享设计绕开锁的,优先绕开。

不想亲手管理线程的生死,就把任务丢给 async、从 future 里取结果——面向任务而非线程的并行。

std::async 是最简单的异步执行方式——丢一个任务出去,拿到 future 对象,需要结果时调用 get() 即可。promise + future 提供更底层的线程间单次通信通道。
#include <future>

// async:最简单的异步
auto fut = std::async(std::launch::async, []() {
    // 在另一个线程里执行耗时计算
    std::this_thread::sleep_for(std::chrono::seconds(1));
    return 42;
});

// 做其他事情...

int result = fut.get();  // 阻塞等待结果:42

// promise + future:手动通信通道
std::promise<std::string> prom;
std::future<std::string> fut2 = prom.get_future();

std::thread t([&prom]() {
    prom.set_value("done");
});

std::print("{}", fut2.get());  // "done"
t.join();
std::async 返回的 future 析构时如果任务还没完成,会阻塞等待!这意味着如果你不保存返回值,std::async(...); 实际上是同步执行的。一定要保存 future 对象。
std::launch::async显式写——默认策略是 async|deferred,实现可以推迟到 get() 时才在当前线程执行。future::get() 只能调一次——取完 future 即失效,再调按标准是未定义行为,主流实现会抛 std::future_error(libstdc++ what() 为「No associated state」);结果要被多处消费,改用 shared_future

计数器、标志位这类单变量共享,不必动用互斥锁——atomic 用硬件指令直接保证原子性。

std::atomic 提供无锁的线程安全操作,适合简单的计数器、标志位场景。内存序(memory_order)控制编译器和 CPU 的指令重排行为——高级话题,初学者用默认的 memory_order_seq_cst 即可。
#include <atomic>

std::atomic<int> counter{0};

// 多线程安全地递增
void increment() {
    for (int i = 0; i < 1000; ++i) {
        counter.fetch_add(1);  // 原子加
        // 或 counter++; 也是原子操作
    }
}

// 原子标志位
std::atomic<bool> ready{false};
// 线程 A:
ready.store(true);
// 线程 B:
while (!ready.load()) { /* 等待 */ }
atomic 只保证单次操作原子。b = b + 1 是一次原子读加一次原子写,两线程各加 10 万次,远不到 20 万且每次都不同(一次跑出 13.8 万)——大量更新丢了。读-改-写要用 fetch_add / compare_exchange 这类单条原子操作;「先 load 判断再 store」的写法同样有此竞态。
atomic 只在小类型上才是真无锁——用 is_lock_free() 验证;64 字节结构体的 atomic 非 lock-free(退化为锁实现,g++ 还得链 -latomic 才能过链接)。涉及多个变量的不变式,老实用 mutex

上一卡的原子操作默认用最强的 seq_cst;精通并发要能下探到内存模型——放松内存序换性能、用 CAS 写无锁结构、并躲开 cache line 的暗坑。

四档内存序

  • seq_cst(默认,最强最慢)→ acquire / release(配对建立 happens-before)→ relaxed(只保证原子性、不排序,用于纯计数器);
  • release-store 与 acquire-load 配对:发布方 store(release) 之前的写,对 acquire-load 到该值的线程可见——这是无锁队列 / 双检锁的基石。

CAS:读-改-写的核心

  • compare_exchange_weak/strong:期望值匹配才写入,失败则重读重试;weak 允许伪失败但在循环里更快。

伪共享(false sharing)

  • 缓存一致性以 cache line(通常 64B)为单位;两线程各写同一行内不同变量 → 该行在核间反复失效弹跳,明明无逻辑竞争却比单线程还慢;
  • 解法:alignas(std::hardware_destructive_interference_size) 把热点数据各自对齐到独立行。
#include <atomic>

// relaxed:纯计数器,不需要排序
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);

// release/acquire 配对:发布数据 + 标志
std::atomic<bool> ready{false};
data = 42;                                    // 生产者
ready.store(true, std::memory_order_release);  // 之前的写对 acquire 可见
while (!ready.load(std::memory_order_acquire)) {} // 消费者
// 此处读 data 一定是 42

// CAS 循环:无锁地累加
int old = counter.load();
while (!counter.compare_exchange_weak(old, old + 1)) {}

// 躲伪共享:让每个热点计数器独占一整条 cache line
struct alignas(64) Padded { std::atomic<long> v; };
数据竞争是 UB,不是「读到旧值」这么温和——两线程无同步地读写同一非原子变量,编译器会假设它不发生并激进优化。而 relaxed 只保证单变量原子性不保证跨变量顺序:两个 relaxed 写在别的线程看来可能乱序到达。除非能证明正确,默认就用 seq_cst
无锁编程极难写对——先用 mutex / 标准并发原语把功能跑通,只在 profile 证明锁是瓶颈、且你能推理 happens-before 时才下放到 relaxed / CAS。数组「每线程一个计数器」是伪共享经典陷阱,用 alignas 填充到独立行。

C++20 在 mutex/condition_variable 之外补了三个更专用、更好用的同步原语,多线程「汇合点」不必再手搓条件变量。

三者分工

  • std::latch一次性倒数闩——设定一个计数,多个线程 count_downwait 的线程等它归零;用完即弃、不可重置。
  • std::barrier可复用栅栏——每一「代」所有线程都到齐才放行,可在到齐时跑一个完成函数,然后自动重置进入下一代。
  • std::counting_semaphore<N>:计数信号量——acquire 减一(为 0 则阻塞)、release 加一,天生适合限流 / 资源池;binary_semaphoreN=1 的别名。
#include <latch>
#include <barrier>
#include <semaphore>
#include <thread>

// —— latch:主线程等 3 个 worker 都就绪(一次性)——
std::latch ready{3};
for (int i = 0; i < 3; ++i)
    std::thread([&]{ /* init... */ ready.count_down(); }).detach();
ready.wait();                          // 三个都 count_down 后才继续

// —— barrier:N 个线程分代同步,每代到齐跑一次收尾 ——
std::barrier sync(3, []{ std::println("本代完成"); });  // 到齐时执行的完成函数
// 每个线程循环里调 sync.arrive_and_wait(); 到齐才进入下一代

// —— counting_semaphore:最多 2 个线程同时用受限资源 ——
std::counting_semaphore<2> slots{2};
slots.acquire();                       // 拿一个名额(没有则阻塞)
/* ... 使用受限资源 ... */
slots.release();                       // 归还名额
latch 不可重置,多轮同步要用 barrierbarrier 的完成函数在所有线程到齐后由其中一个线程执行、期间别的线程都在等——里面别做长活也别抛异常。信号量的 acquire/release 不绑定线程(可 A 拿 B 还),灵活但也容易漏还导致名额泄漏,优先用 RAII 包一层。
选型:只等「都到齐一次」→ latch;循环分阶段的并行(逐帧、逐轮迭代)→ barrier;限制并发数 / 资源池 / 生产者消费者配额 → counting_semaphore。它们都比裸 condition_variable 更难写错。

Ranges 与现代特性

把前面的容器、算法、模板推向更现代的写法。C++20/23 引入 Ranges、Modules、std::format 等划时代特性,还带来 <bit>、flat_map、mdspan、std::print 等一批标准库新设施,让 C++ 代码的表达力和可读性大幅提升。

把过滤、变换、截取串成一条管道,惰性求值、不产生中间容器——这是 STL 算法的 C++20 升级版。

Ranges 是 C++20 对 STL 算法的全面升级。你可以用管道操作符 | 将多个视图(view)串联起来,实现惰性求值的数据处理流水线,代码风格接近函数式编程。典型场景:数据清洗、日志过滤、把一批记录流式地「筛→转→截」;尤其是对大集合只取前几个时,惰性让你不必遍历全部、也不为中间结果分配容器。
#include <ranges>
#include <vector>

std::vector<int> nums = {1,2,3,4,5,6,7,8,9,10};

// 管道:取偶数 → 平方 → 前 3 个
auto result = nums
    | std::views::filter([](int n){ return n % 2 == 0; })
    | std::views::transform([](int n){ return n * n; })
    | std::views::take(3);

// result: [4, 16, 36](惰性求值,不产生中间容器)
for (int x : result) std::print("{} ", x);

// 对比传统写法(需要中间变量和多次遍历)
// ranges 版本更简洁且无中间容器开销
view 不拥有数据——底层容器改动或销毁后,视图跟着失效。且 views::filter缓存 begin():对 {1,2,3,4,5} 建偶数视图并遍历一次,再把 v[0] 改成 100,老视图重新遍历仍输出「2 4」、新建的视图才输出「100 2 4」。把视图当即用即建的一次性对象,别长期持有再复用。
Ranges 的 view 是惰性的——不会立即计算,只在迭代时才逐个产生值。这意味着即使数据量很大,只要最终只取前几个元素,开销就很小。

printf 不查类型、iostream 太啰嗦——format 用 Python f-string 式的占位符一次解决格式化问题。

std::format 用类似 Python f-string 的语法替代了丑陋的 printf 和繁琐的 iostream 格式化。C++23 进一步引入 std::print,让输出更简洁。典型场景:日志与报表、拼 JSON / SQL / 配置文本、面向用户的提示文案——凡是要把若干值按固定样式(补零、对齐、小数位)拼成字符串的地方,比手写 + 拼接或 stringstream 都短,且类型安全。
#include <format>

std::string name = "C++";
int version = 20;

// std::format(C++20)
std::string s = std::format("Hello, {}{}!", name, version);
// "Hello, C++20!"

// 格式说明符
std::format("{:>10}", 42);     // "        42"(右对齐)
std::format("{:#06x}", 255);   // "0x00ff"(十六进制)
std::format("{:.2f}", 3.14159); // "3.14"(保留两位小数)

// std::print(C++23)—— 直接输出,不需要 cout
// std::print("Hello, {}!\n", name);
std::format 的格式串必须是编译期常量——传运行期字符串直接编译失败(g++ 15 报「call to consteval function … is not a constant expression」)。运行期格式串要走 std::vformat + std::make_format_args,且后者只收左值:直接塞字面量 42 也编译不过,得先存进变量。
std::format 是类型安全的(printf 的格式符与参数类型不匹配是未定义行为,format 则在编译期就报错拒绝),而且支持自定义类型的格式化(通过特化 std::formatter)。

协程是 C++20 的招牌语言特性——可暂停 / 恢复的函数:遇到 co_await / co_yield 就保存状态、交还控制权,之后从原地继续。异步与惰性生成器的统一底座。

三个关键字

  • co_return 返回值、co_yield 惰性产出(天然做 generator)、co_await 挂起等待(做异步 task);
  • 函数体里只要出现其一,它就是协程。

编译器做了什么

  • 把函数体改写成状态机;协程帧默认堆分配(可被 HALO 优化省掉)。

标准只给底座

  • 语言层要 promise_type / coroutine_handle;好用的 generator / task 类型靠库——std::generator 要到 C++23
#include <generator>   // C++23

// 惰性斐波那契:co_yield 逐个产出,产出后挂起
std::generator<int> fib() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;              // 挂起,下次从这里恢复
        auto next = a + b; a = b; b = next;
    }
}

for (int x : fib() | std::views::take(10))
    std::print("{} ", x);   // 0 1 1 2 3 5 8 13 21 34

// 异步:co_await 一个 awaitable,挂起而不阻塞线程
Task<std::string> fetch() {
    auto conn = co_await connect();
    co_return conn.read();
}
协程帧的生命周期独立于调用者:若在 lambda 协程里按引用捕获局部变量、或 co_await 一个已销毁的 awaitable,恢复时就是悬垂引用 / UB。协程参数也要留意——按值传比按引用安全(引用可能在首次挂起后失效)。
协程与 Ranges 都做「惰性流」,但协程能表达跨挂起点的有状态计算(generator 循环里的状态自动保存)。C++20 只有语言支持,实战用 generator / task 前先确认标准库版本(std::generator 需 C++23 或第三方库如 cppcoro)。

08 章讲了异常;expected 是它的值语义对手——把错误放进返回值和类型签名,是禁用异常的高性能 / 嵌入式代码库的主力错误通道。

expected<T, E>

  • 要么持有结果 T、要么持有错误 E——optional 的「带原因」升级版;
  • .has_value() / *(取值)/ .error()(取错误);失败用 return std::unexpected(err);

单子式链

  • .and_then / .transform / .or_else 串起来,出错自动短路,不用层层 if 或 try/catch。

相比异常

  • 正常路径零开销、错误由调用方显式处理、错误类型进签名;与 optional / variant(05 章)对照:expected 语义化了「成功 xor 失败」。
#include <expected>   // C++23

std::expected<int, std::string> parse(std::string_view s) {
    if (s.empty())
        return std::unexpected("empty input");   // 失败:带原因
    return std::stoi(std::string(s));            // 成功:直接返回值
}

auto r = parse("42");
if (r) std::print("{}", *r);          // 有值
else   std::print("{}", r.error());  // 拿错误原因

// 单子链:出错自动短路
auto out = parse("10")
    .transform([](int x){ return x * 2; });   // 有值才执行
expected 解 *、或对有值的调 .error() 都是 UB(不像 .value() 会抛 bad_expected_access)——务必先判 has_value() 或用 operator bool。别把 * 当成「安全取值」。
API 设计的现代取向:可恢复、可预期的失败用 expected(错误进签名、强制调用方处理);真正异常、跨多层的错误用 exception。两者不是对立,是分工——见 08 章异常。

头文件被 #include 复制粘贴了五十年,Modules 让接口编译一次、按需导入,还不受宏污染。

模块是替代 #include 头文件的新机制。模块编译一次、按需导入,不受宏污染和重复包含的影响,可以大幅缩短编译时间。虽然编译器支持仍在完善中,但这是 C++ 的未来方向。
// math_utils.cppm —— 模块定义
export module math_utils;

export int add(int a, int b) {
    return a + b;
}

export double pi() {
    return 3.14159265358979;
}

// main.cpp —— 使用模块
import math_utils;
import <print>;    // 标准库也可以用 import

int main() {
    std::println("{}", add(1, 2));
}
三大编译器如今都能编译具名模块(g++ 15 与 clang 21 通过,g++ 15 连 import std; 都能跑),真正的短板移到了生态:构建系统只有 CMake 3.28+ 配 Ninja / Visual Studio 生成器支持模块,绝大多数第三方库仍按头文件发布。上项目前要确认的是整条工具链,而不只是编译器。
g++ 15 已可用:g++ -std=c++20 -fmodules -c math_utils.cppm 先编模块、再编 main 即通;C++23 还能 import std;(g++ 15 加 -fmodules -fsearch-include-path bits/std.cc 一条命令跑通)。但 import <iostream>; 这类头单元要先用 -fmodule-header 预编译,否则报「imports must be built before being imported」。工程里交给 CMake 3.28+ 配 Ninja 排编译顺序,别手工管依赖。

除了 Ranges/format/协程这些「大」特性,C++20/23 还往标准库塞了一批小而实用、却常被忽略的设施——它们都是标准的一部分,不是第三方。这里一次点清。

// —— <bit> 位操作(C++20):把哈希 / 位图 / 协议字节序里的手写位技巧变成标准函数 ——
#include <bit>
std::popcount(0b1011u);          // 1 的个数 = 3
std::countl_zero(1u);            // 前导 0 个数(用于算 log2 / 对齐)
std::bit_width(37u);             // 表示该数所需位数 = 6
std::has_single_bit(16u);        // 是否 2 的幂 → true
std::bit_cast<int>(3.14f);      // 按位重解释类型(取代 union 双关(UB)和繁琐的 memcpy 惯用法)
std::byteswap(0x1234u);         // 字节序翻转(C++23)

// —— flat_map / flat_set(C++23):有序但底层是连续数组 ——
std::flat_map<int, std::string> fm;   // 接口同 map,查快、缓存友好,插入慢
// 适合「建好后大量查、极少改」,替代 map 省内存、省指针跳转

// —— mdspan(C++23):多维视图,不拥有内存(图像像素 / 矩阵线代 / 科学计算,把一维 buffer 当多维用)——
int raw[6] = {1,2,3,4,5,6};
std::mdspan md(raw, 2, 3);            // 把一维缓冲看成 2×3
md[1, 2];                            // 多维下标(C++23 起 operator[] 支持多参)

// —— <print>(C++23):直接打印,不必 cout << 也不用 format 喂流 ——
#include <print>
std::println("{} + {} = {}", 1, 2, 3);   // 自带换行,类型安全

// —— 诊断类 ——
#include <source_location>
auto loc = std::source_location::current();  // 拿到当前 文件/行/函数(C++20,取代 __FILE__ 宏)
#include <stacktrace>
std::cout << std::stacktrace::current();     // 运行期抓调用栈(C++23)
这些是标准里最年轻的一批,编译器 / 标准库支持参差:<print><stacktrace>flat_mapmdspan 支持要逐个查——g++ 15.2 在 -std=c++23<print> / flat_map 可用、<stacktrace> 还须链 -lstdc++exp,而 mdspan 连头文件都还没有。用前先查 cppreference 的「Compiler support」表,别想当然。
记忆锚点:<bit> 把「判 2 的幂、数前导 0、按位重解释」这类以前靠奇技淫巧或 UB 的操作,变成有名字、可移植、编译期可算的标准函数——现代 C++ 里别再手写 x & (x-1) 这类谜语了。

C++26 已在 2025 年冻结特性、约 2026 正式发布——但「定稿」不等于「能用」。主流编译器只落地了一批零碎的便利特性,几个真正改变游戏的还停在实验分支。这张卡帮你分清今天能写还得再等,别被「C++26 已发布」带着把没影的东西写进生产。

已经能用(GCC 14+ / Clang 19+,-std=c++26

  • pack 索引 Ts...[i]:直接取参数包第 i 个类型 / 值,不用再递归拆包;
  • 占位符 _:结构化绑定 / 变量里显式丢弃不用的名字,还能重复用;
  • 饱和运算 std::add_sat 等:溢出钳到上下限而非 UB / 回绕;
  • = delete("理由"):删除函数时附一句给调用方的提示;
  • #embed:把二进制文件当数组塞进源码(与 C23 同款,见工程基础页)。

还在实验分支,别用于生产

  • 静态反射^^ 取元 + std::meta,P2996)——C++26 的头号特性:编译期遍历成员、自动生成序列化 / ORM / 枚举转字符串。目前只有 Clang 实验 fork 与 EDG,主线编译器尚无;
  • Contractspre / post / contract_assert,P2900)——把前置 / 后置条件写进函数签名,运行期或编译期检查,仍是实验实现;
  • std::execution(sender / receiver,P2300)——标准化的异步 / 并行框架(对标 Asio 的地位,见 12 章);参考实现 stdexec 可用,但标准库尚未内置。

怎么对待

  • 便利特性可在个人项目按需用(确认 -std=c++26 + 编译器够新);
  • 反射 / contracts / execution 现在当「知道有这回事、读得懂 demo」即可,别押到团队默认——它们也正是最晚普及的部分。团队 / 生产的默认基线仍以 C++23 为界。
// ══ 已经能用(GCC 14+ / Clang 19+,-std=c++26)══
template <typename... Ts>
using first = Ts...[0];         // pack 索引:取包里第 0 个类型(P2662)

auto [x, _] = get_pair();          // _ 占位:显式丢弃不用的绑定(P2169)
int y = std::add_sat(INT_MAX, 1);  // 饱和:钳到 INT_MAX,不 UB(P0543)

void old() = delete("请改用 new_api()");  // 删除并给理由(P2573)

static const unsigned char logo[] = {  // #embed:二进制塞进源码(C23/26)
    #embed "logo.png"
};

// ══ 还在实验分支,别用于生产(下面都是示意,主线编译器多半编不过)══
// 静态反射(P2996):编译期拿类型信息、自动生成代码
// constexpr auto ms = std::meta::nonstatic_data_members_of(^^Point);
// Contracts(P2900):前置/后置条件进签名
// int div(int a, int b) pre(b != 0) { return a / b; }
// std::execution(P2300):标准 sender/receiver 异步框架
别被「C++26 已发布」误导,把反射 / contracts / std::execution 写进生产——它们的标准实现尚未进主流 libstdc++ / libc++,换台机器就编不过。这三个是 C++26 的招牌,也恰恰是最晚普及的部分。反倒是 #embed、pack 索引这类「小而简单」的普及得快。
判断某个 C++26 特性今天能不能用,别信「标准已定稿」这句话——直接查 cppreference 的编译器支持表或试编一下。同样是 C++26,pack 索引 早进了 GCC 14,静态反射却还在 fork 里。个人项目尝鲜随意,团队 / 生产默认仍以 C++23 为界。

工程实践

语言特性学完,最后一步是把它们变成能交付的工程。工业级 C++ 代码不仅仅是语法正确——还需要构建系统、测试框架、代码质量工具和良好的设计模式支撑。这些是从学生走向职业开发者的关键。

make 是最底层也最经典的构建工具。Makefile 由若干规则组成,每条规则回答三件事:造什么(目标)、依赖谁(前置)、怎么造(下一行 Tab 缩进的命令)。make 依据文件修改时间只重编过时的目标——增量构建由此而来。理解 Makefile 是理解 CMake 的基础,CMake 最终生成的往往正是它。

自动变量与模式规则

  • $@=目标名、$^=全部依赖(去重)、$<=第一个依赖、$?=比目标新的依赖——省去手写文件名。
  • %.o: %.cpp模式规则,一条规则批量描述「任何 .o 由同名 .cpp 编出」。
  • $(wildcard *.cpp) 收集源文件、$(patsubst %.cpp,%.o,…) 转成 .o 列表——新增源文件不必改 Makefile。

变量赋值的四种写法

  • := 立即展开,行为最可预测——优先用它= 是递归展开(用到才求值,易自我引用)。
  • ?= 仅未定义时赋值(给可被 make CXX=clang++ 覆盖的默认);+= 追加(CXXFLAGS += -g)。

头文件依赖:手写 Makefile 的头号陷阱与根治

make 只比时间戳、不看内容:改了某个 .hpp 但规则没声明这条依赖,make 就不重编,造成「改了却没生效」。C++ 头文件多、模板还常放在头里,手写依赖几乎必漏。根治:编译加 -MMD -MP 让 g++/clang++ 顺带产出 .d 依赖文件,再 -include 引进来,依赖全自动(见代码)。

# Makefile —— 自动收集源文件 + 自动头文件依赖
CXX      := g++
CXXFLAGS := -std=c++23 -Wall -Wextra -O2 -MMD -MP
SRCS     := $(wildcard *.cpp)
OBJS     := $(patsubst %.cpp,%.o,$(SRCS))

app: $(OBJS)
	$(CXX) $(CXXFLAGS) $^ -o $@       # $^=全部 .o  $@=app

%.o: %.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@    # $<=对应的 .cpp

.PHONY: clean
clean:
	rm -f $(OBJS) $(OBJS:.o=.d) app

-include $(OBJS:.o=.d)   # 引入自动生成的头文件依赖

# make -j8  并行编译   make -n  只打印命令   make -B  强制重建
① 命令行必须以 Tab 缩进(用空格报 missing separator)。② 每行命令在独立子 shell 里跑,cd 不跨行,要连贯得用 && 写成一行。③ C++20 模块(import)引入了编译期依赖顺序,朴素的 %.o: %.cpp 无法表达模块间依赖——这正是手写 Makefile 在现代 C++ 里力不从心、需要 CMake 的地方之一。
除玩具项目外,C++ 界几乎不手写 Makefile,而是用 CMake(见下)生成。但读懂 Makefile 仍是必备技能:排查构建问题、看懂 make VERBOSE=1 打印出的真实编译命令时都用得上。

CMake 是 C++ 事实上的标准构建系统(元构建工具)。你在 CMakeLists.txt 里只声明「有哪些目标、依赖什么、要什么标准」,CMake 据此生成各平台真正的构建文件(Ninja / Makefile / Visual Studio / Xcode)。掌握它是参与任何 C++ 开源项目的前提。

两段式:配置 → 构建

  • ①配置 cmake -B build -G Ninja:探测编译器与依赖、在 build/ 里生成工程(out-of-source,不污染源码树)。
  • ②构建 cmake --build build -j:调用底层工具真正编译,平台无关,CI 一套脚本通吃。

一切皆 target:Modern CMake 的核心

具体目标设属性,并用三个关键字控制属性是否传递给依赖它的 target:

  • PRIVATE=只自己用;INTERFACE=只给用户(header-only 库的典型);PUBLIC=两者都要(库的公开头目录)。
  • 于是 target_link_libraries(app PRIVATE mylib) 会把 mylib 的公开头目录、编译选项、宏自动带给 app。别再用 include_directorieslink_libraries 这类污染全局的老命令。

引入第三方库的三条路

  • find_package(fmt REQUIRED):找系统已装的库,配合 vcpkg / Conan 包管理器最常用。
  • add_subdirectory(third_party/x):把随源码放进来的子项目(git submodule)纳入构建。
  • FetchContent:配置阶段直接从 Git 拉取并构建,无需预装——现代小项目「开箱即用」的做法。

构建类型、Presets 与 C++20 模块

  • -DCMAKE_BUILD_TYPE=Release 选优化档(Debug 带调试信息、Release 开 -O3 -DNDEBUG),缓存进 build/CMakeCache.txt
  • CMakePresets.json 把生成器、构建类型、选项固化成命名预设,cmake --preset debug 一键复现,团队与 CI 共享。
  • 要用 C++20 模块,需 CMake 3.28+ 与 Ninja/新版编译器,并用 target_sources(FILE_SET CXX_MODULES FILES …) 声明模块接口文件——依赖扫描(CXX_SCAN_FOR_MODULES)对 C++20 target 默认就是开的,模块间依赖由 CMake 自动扫描排序,这是手写 Makefile 做不到的。
# CMakeLists.txt 基本结构
cmake_minimum_required(VERSION 3.20)
project(MyApp LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 一组源文件先编成库,再让可执行文件用它
add_library(mylib src/mylib.cpp)
target_include_directories(mylib PUBLIC include/)   # PUBLIC:传给用它的人

add_executable(app src/main.cpp src/utils.cpp)
target_link_libraries(app PRIVATE mylib)          # app 自动获得 mylib 的公开属性

# 配置并构建:
# cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
# cmake --build build -j       ctest --test-dir build
单配置生成器(Makefile / Ninja)下不传 -DCMAKE_BUILD_TYPE 默认是空串——CMakeCache.txt 里是 CMAKE_BUILD_TYPE:STRING=,编译命令里既没有 -O 也没有 -g:拿这种构建跑基准慢得没有参考价值,调试又没符号。更阴的是这个值不做校验-DCMAKE_BUILD_TYPE=Relaese(拼错)被静默接受,照样按「无旗标」构建。所以要显式传 Release / Debug / RelWithDebInfo,最好用 CMakePresets 固化,别赌默认值。
坚持 Modern CMake 的 target 思维:用 target_xxx 系列命令(target_link_librariestarget_compile_options)而非 link_libraries 等全局命令,每个 target 管理自己的属性和依赖。网上很多老教程是 CMake 2.x 的全局命令风格,认准 target 写法即可避坑。

C++ 至今没有官方包管理器——不像 Rust 的 Cargo、Node 的 npm、Python 的 pip。于是冒出几套互相竞争的方案,没有赢家。好在它们大多和 CMake 打通(上一张卡的 find_package / FetchContent 就是消费入口),你要做的是按场景选一套、并只用一套

四条主流路线

  • vcpkg(微软):清单模式 vcpkg.json 声明依赖,配置时自动装齐;库最多、上手最快,Windows / CI 友好;
  • Conan(JFrog):conanfile + 二进制缓存,支持多版本 / 多 ABI / 私有仓,企业级依赖治理更强,学习曲线也更陡;
  • CMake FetchContent:不装任何包管理器,配置阶段直接从 Git 拉源码编译——小项目 / 少量依赖「开箱即用」;
  • pkg-config + 系统包管理器(apt / brew):传统 Unix 方式,pkg-config 吐编译链接旗标;跨平台一致性差,但装系统级大库最省事。

怎么选

  • 个人 / 小项目、依赖少 → FetchContent,零安装;
  • 库多、跨平台、CI 要稳 → vcpkg 清单模式(依赖锁进 vcpkg.json,可复现);
  • 企业、多版本 / 私有仓 / 二进制分发 → Conan
  • 只用系统那几个大库(OpenSSL 之类)→ 系统包管理器 + pkg-config
# —— vcpkg 清单模式:vcpkg.json 里声明依赖 ——
# { "dependencies": ["fmt", "spdlog"] }
cmake -B build \
  -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake
# 配置时自动下载/编译/装好;CMakeLists 里照常 find_package(fmt)

# —— FetchContent:CMakeLists.txt 里直接拉,零安装 ——
# include(FetchContent)
# FetchContent_Declare(fmt GIT_REPOSITORY .../fmt GIT_TAG 10.2.1)
# FetchContent_MakeAvailable(fmt)  → target_link_libraries(app fmt)

# —— pkg-config:装系统库后取旗标 ——
sudo apt install libssl-dev
g++ app.cpp $(pkg-config --cflags --libs openssl) -o app
三套方案可以混用,但别乱混:同一个库既 apt 装了系统版、又被 vcpkg 装了一份,find_package 可能找到你没预期的那份,版本对不上,链接期或运行期出诡异错误(甚至一个进程里链进两份不同版本的同名符号)。规则:一条依赖定死一个来源,要么全交给包管理器、要么全 FetchContent,别一半一半。
两个稳妥默认:CI / 跨平台项目用 vcpkg 清单模式(版本锁进 vcpkg.json、可复现);个人练习和 header-only 依赖用 FetchContent(零安装)。别把系统 apt install 的库当跨平台方案——换台机器 / 换系统就崩,只适合固定 Linux 环境。

靠 main 里手写 if 加打印来验证代码,改一处就得肉眼重查一遍——测试框架把这些检查变成可以批量反复运行的断言。C++ 标准库不提供测试设施,这件事完全交给了生态,而 GoogleTest 是事实标准。

拿到框架:FetchContent,别再手动装

  • 过去要么 apt install libgtest-dev 污染系统、要么把源码 vendored 进仓库。现在的标准做法是 CMake 的 FetchContent:四行声明,配置阶段自动下载、随项目一起编译,队友和 CI 拉下仓库就能跑,无需任何预装;
  • (WSL2 / g++ 15.2 / CMake 4.2):全新配置含下载 GoogleTest 1.17 约 5.4 秒,首次把 gtest 与用例一起编出来约 17 秒,之后只改测试文件的增量重编约 2.4 秒——一次性成本,日常迭代不受影响;
  • 代价是体积:拉下来的 gtest 源码 4.6 MB,链好的测试二进制 约 694 KB。对比之下 doctest 是单头文件、编译最快,适合「只想要断言、不想要框架」的场景;Catch2 v2 也曾是单头,v3 起改为静态库分发,这一点常被过时的教程写错。

EXPECT 与 ASSERT:C++ 测试的第一分岔口

  • EXPECT_* 失败后继续执行本条用例,ASSERT_* 失败后立即 return。同一条用例里连写两个失败的 EXPECT_EQ两条 Failure 都会被报出来;换成 ASSERT_EQ,它后面的语句一行都不执行
  • 选择规则很实用:后续代码依赖这次检查的结果时用 ASSERT(指针非空、容器非空、解析成功),否则一律用 EXPECT——一次跑出全部失败点,比修一个跑一次高效得多;
  • 断言后面可以用 << 追加自定义说明,它只在失败时打印。

失败信息为什么值钱:它会把表达式求值给你看

  • 手写 assert(a == b) 失败时只告诉你「断言失败」;GoogleTest 会把表达式原文和它的实际值一起打出来。EXPECT_EQ(0.1 + 0.2, 0.3) 的报告是:
    Expected equality of these values:
      0.1 + 0.2
        Which is: 0.30000000000000004
      0.3
  • 它对标准容器开箱即用EXPECT_EQ(a, b) 直接比较两个 std::vector<int> 的内容并通过——不用自己写循环,也不用自己写打印。
# —— CMakeLists.txt:四行把 gtest 拉进来 ——
#  include(FetchContent)
#  FetchContent_Declare(googletest URL https://github.com/google/
#      googletest/archive/refs/tags/v1.17.0.tar.gz)
#  FetchContent_MakeAvailable(googletest)
#
#  enable_testing()
#  add_executable(demo_test test.cpp)
#  target_link_libraries(demo_test GTest::gtest_main)  # 自带 main
#  include(GoogleTest)
#  gtest_discover_tests(demo_test)   # 让 ctest 逐条看见每个 TEST

#include <gtest/gtest.h>

int add(int a, int b) { return a + b; }

TEST(AddTest, BasicAdd) {          // TEST(套件名, 用例名)
    EXPECT_EQ(add(2, 3), 5);
    EXPECT_EQ(add(-1, 1), 0) << "失败时才打印这句";
}

// —— EXPECT 继续,ASSERT 停下——
TEST(Demo, ExpectKeepsGoing) {
    EXPECT_EQ(1, 2);           // 报一条 Failure…
    EXPECT_EQ(3, 4);           // …这条也照样执行并报出来
}
TEST(Demo, AssertStops) {
    ASSERT_EQ(1, 2);           // 失败即 return
    ADD_FAILURE() << "这行不会被执行";
}

// —— 失败信息会把表达式求值给你看 ——
TEST(Demo, ShowsValues) {
    EXPECT_EQ(0.1 + 0.2, 0.3);
    // Expected equality of these values:
    //   0.1 + 0.2
    //     Which is: 0.30000000000000004
    //   0.3
}

// —— 容器开箱即比 ——
TEST(Demo, Containers) {
    std::vector<int> a{1, 2, 3}, b{1, 2, 3};
    EXPECT_EQ(a, b);           // ✅ 直接比内容
}
分不清 EXPECT_*ASSERT_* 是最容易记混的一处:EXPECT 失败后继续往下执行——EXPECT_EQ(v.size(), 3u) 失败后接着跑 v.at(0),空 vector 直接在测试体里抛出越界异常,报告反而更难读;后续代码依赖检查结果(指针非空、容器非空)时要用 ASSERT。而 ASSERT 展开里带一句裸 return;只能用在返回 void 的函数里——放进返回 int 的辅助函数,g++ 15 报 error: void value not ignored as it ought to be
装 gtest 不用动系统:FetchContent_Declare + FetchContent_MakeAvailable(googletest) 两句,配置阶段自动拉取、随项目一起构建(本卡所有数字就是这么跑出来的,全程没有 sudo、没碰系统目录);链接 GTest::gtest_main 就不用自己写 main。

再加 include(GoogleTest) + gtest_discover_tests(目标)ctest 会把每条 TEST 当成一个独立条目——一个含参数化用例的二进制被展开成 9 个 ctest 条目,失败汇总直接列出是哪两条挂了,而不是只告诉你「这个大二进制返回了非零」。单跑一条用 --gtest_filter--gtest_filter="Float.*" 只跑该套件)。

写到第五条用例就会发现两件事在重复:每条都在搭同样的场景,以及每条只有输入输出不同。GoogleTest 分别给了固件和参数化;再加上浮点断言,日常单测需要的就齐了。

TEST_F:固件,每条用例拿到全新的一份

  • 继承 ::testing::Test 写一个类,把公共数据放成员、把准备放 SetUp()、把清理放 TearDown(),用例改用 TEST_F(固件类名, 用例名)
  • 关键语义:框架为每条用例新建一个固件对象,不是共享一个。三条 TEST_F 共用一个初始化成 {1,2,3} 的 vector,中间那条 push_back 到 4 个元素,第三条读到的仍然是 3 个——用例之间天然隔离,这正是它比全局变量强的地方;
  • TearDown() 只用于必须显式释放的东西;成员是 RAII 类型(vectorunique_ptrfstream)时析构函数已经做完了,不必写。

TEST_P:一张表跑多组,失败时告诉你是哪一组

  • 继承 ::testing::TestWithParam<参数类型>,用例里用 GetParam() 取当前这组,最后用 INSTANTIATE_TEST_SUITE_P 把数据喂进去(::testing::Values(...) 逐个列,ValuesIn(容器) 喂一个数组);
  • 三组数据被展开成三条独立用例 Cases/ParamTest.DoublesIt/0/2,其中故意写错的第三组失败信息带上了 where GetParam() = (5, 11)——直接告诉你是哪一组数据挂的,这是参数化相对「在一条用例里写个 for 循环」的核心优势:循环版只会告诉你「某次比较失败了」;
  • 边界值(0、负数、最大值、空容器)最适合进这张表——把它们列成数据,比复制粘贴五条用例好维护得多。

浮点:EXPECT_EQ 是错的

  • EXPECT_EQ(0.1 + 0.2, 0.3) 失败,因为左边实际是 0.30000000000000004
  • 正确写法两个:EXPECT_DOUBLE_EQEXPECT_FLOAT_EQ)按 4 ULP 的相对误差比较,同一对值通过——不需要你自己想容差该取多少;EXPECT_NEAR(a, b, 容差) 则由你指定绝对容差,配 1e-9 通过;
  • 选择规则:值的量级未知或跨度大 → DOUBLE_EQ(相对误差自适应);有明确的业务精度要求 → NEAR(比如金额精确到分就写 1e-2)。
// —— 固件:每条用例都会重新构造一次 ——
class VecFixture : public ::testing::Test {
 protected:
    void SetUp() override { v = {1, 2, 3}; }
    // TearDown() 只在需要显式释放时才写;RAII 成员不用
    std::vector<int> v;
};

TEST_F(VecFixture, StartsWithThree) { EXPECT_EQ(v.size(), 3u); }
TEST_F(VecFixture, MutatesIt)       { v.push_back(9);
                                       EXPECT_EQ(v.size(), 4u); }
TEST_F(VecFixture, StillThree)     { EXPECT_EQ(v.size(), 3u); }
// ↑ 三条全过:上一条的 push_back 影响不到下一条


// —— 参数化:一张表跑多组 ——
class ParamTest
    : public ::testing::TestWithParam<std::pair<int, int>> {};

TEST_P(ParamTest, DoublesIt) {
    auto [in, want] = GetParam();
    EXPECT_EQ(in * 2, want);
}
INSTANTIATE_TEST_SUITE_P(Cases, ParamTest,
    ::testing::Values(std::pair{1, 2},
                     std::pair{3, 6},
                     std::pair{5, 11}));   // ← 故意写错的一组

// 展开成三条独立用例,失败那条报:
//   [  FAILED  ] Cases/ParamTest.DoublesIt/2,
//               where GetParam() = (5, 11)     ← 是哪组数据一目了然


// —— 浮点:三种写法,第一种是错的 ——
EXPECT_EQ(0.1 + 0.2, 0.3);              // ❌ 失败
EXPECT_DOUBLE_EQ(0.1 + 0.2, 0.3);       // ✅ 4 ULP 相对误差
EXPECT_NEAR(0.1 + 0.2, 0.3, 1e-9);     // ✅ 自定绝对容差
这两个坑 GoogleTest 都替你堵上了,值得知道它是怎么堵的。

忘写 INSTANTIATE_TEST_SUITE_P:换成别的框架这会是「用例被静默跳过、测试全绿却少跑了几条」。GoogleTest 不然——它会合成一条用例专门来失败,报出 GoogleTestVerification.UninstantiatedParameterizedTestSuite<Uninst>,信息是 Parameterized test suite Uninst is defined via TEST_P, but never instantiated. None of the test cases will run.,还顺手给出抑制宏 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(Uninst)退出码 1

SetUp() 写成 Setup()(小写 u):按 C++ 规则这只是新增一个无人调用的函数,本该编译通过、准备工作一次不做。GoogleTest 在基类里埋了一个同名虚函数、返回一个叫 Setup_should_be_spelled_SetUp 的哨兵类型,于是拼错会在编译期撞出 error: conflicting return type specified for 'virtual void F::Setup()'(g++ 15.2),连 override 都不用加。

把这两处记成正面经验:好框架应当让「写错」变成响亮的失败,而不是安静的通过
固件类名就是用例报告里的套件名,所以给它起「被测对象 + 场景」的名字(ParserWithEmptyInput)比起 MyFixture 有用得多——失败列表里读到的是这个名字。

需要整套用例只做一次的重量级准备(起一个数据库、读一个大文件),用固件的静态成员配 static void SetUpTestSuite() / TearDownTestSuite(),别放进 SetUp()——后者每条用例都会重跑一遍。

C++ 没有反射,做替身只能靠继承一个接口再覆写。gmock 把这件事变成两行宏,还顺带给了「这个方法该被调几次、参数是什么」的断言能力——它和 GoogleTest 是同一个仓库的两半,装了 gtest 就已经有它,只需把链接目标换成 GTest::gmock_main

两步:声明替身,再声明期望

  • 被依赖的东西必须是带虚函数的接口virtual 方法 + virtual ~T() = default)——这是 C++ 的硬约束,也顺带解释了为什么「面向接口编程」在 C++ 里对可测性影响格外大;
  • 替身类里每个方法写一行 MOCK_METHOD(返回类型, 方法名, (参数表), (override))参数表外面那对括号不能省,否则含逗号的类型(std::map<K, V>)会被预处理器当成两个宏参数拆开;
  • 用例里用 EXPECT_CALL(对象, 方法(参数匹配)) 声明期望:.Times(n) 限次数,.WillOnce(Return(x)) / .WillRepeatedly(...) 给返回值;参数位可以写具体值、_(任意)或匹配器(Ge(3)HasSubstr("k"))。

关键语义:EXPECT_CALL 是「期望」,在析构时结算

  • 不只是打桩——声明了却没被满足,用例会失败,且失败发生在 mock 对象析构那一刻。一条声明了 save("never", 9) 却从未发生的期望,报 Actual function call count doesn't match EXPECT_CALL(...)Expected: to be called onceActual: never called - unsatisfied and active
  • 参数对不上时它逐个列出差异:输出 Expected arg #0: is equal to "never" / Actual: "k"Expected arg #1: is equal to 9 / Actual: 1——比自己写替身类再手动记录调用清楚得多;
  • 所以 EXPECT_CALL 必须写在被测代码执行之前。写在后面,那次调用已经被算作「没有期望的调用」了。

没设期望的调用只是警告——用例照样绿

  • 对一个一条 EXPECT_CALL 都没写的 mock 调方法,gmock 只打一条 GMOCK WARNING: Uninteresting mock function call - returning default value用例通过。也就是说「被测代码偷偷多写了一次库」默认发现不了;
  • 要让它变成失败,用 StrictMock<MockDb>——同一段代码在 StrictMock 下直接 [ FAILED ]。反过来 NiceMock<T> 连警告都不打,用于「这个依赖被调很多次,我只关心其中一处」;
  • 默认返回值是类型的零值boolfalseint0,输出里明写 Returns: false / Returns: 0)。被测逻辑恰好在零值上也走得通时,最容易误以为测过了;
  • 实用分工:关心「有没有被调、怎么调的」用 EXPECT_CALL;只是需要个返回值用 ON_CALLNiceMock——后者只打桩不断言,免得把实现细节写死进测试。
// CMake:链接目标换成 gmock_main(装了 gtest 就已经有它)
//   target_link_libraries(t GTest::gmock_main)

#include <gmock/gmock.h>
using ::testing::Return;
using ::testing::_;            // 任意参数
using ::testing::StrictMock;

// —— 被依赖的接口:必须有虚函数 ——
class Db {
 public:
    virtual ~Db() = default;
    virtual bool save(const std::string& k, int v) = 0;
    virtual int  load(const std::string& k) = 0;
};

// —— 替身:一个方法一行 ——
class MockDb : public Db {
 public:
    MOCK_METHOD(bool, save, (const std::string&, int), (override));
    MOCK_METHOD(int,  load, (const std::string&), (override));
    //          ↑ 参数表外这对括号不能省:含逗号的类型会被宏拆开
};

TEST(Gmock, SetsExpectation) {
    MockDb db;
    EXPECT_CALL(db, save("k", 1)).Times(1).WillOnce(Return(true));
    EXPECT_CALL(db, load(_)).WillOnce(Return(1));
    EXPECT_TRUE(handle(db));        // ← 期望写在被测代码之前
}

// 期望没被满足 = 用例失败,报错:
//   Actual function call count doesn't match EXPECT_CALL(...)
//     Expected: to be called once
//       Actual: never called - unsatisfied and active
//   参数对不上还会逐个列:
//     Expected arg #0: is equal to "never"    Actual: "k"

// —— 没设期望的调用只是警告,用例照过 ——
TEST(Gmock, JustAWarning) {
    MockDb db;                       // 一个 EXPECT_CALL 都没有
    handle(db);                      // GMOCK WARNING: Uninteresting mock
}                                    //   function call - returning default value
                                     // [       OK ]   ← 通过了!

TEST(Gmock, StrictMakesItFail) {
    StrictMock<MockDb> db;           // 同样的代码
    handle(db);                      // [  FAILED  ]  ← 变成失败
}
默认返回值是「零值」,这会让错误的实现悄悄通过。没写 WillOnce 时,返回 bool 的 mock 方法一律给 falseint0、指针给 nullptr(输出里明写 Returns: false)。被测逻辑要是恰好在这些零值下也走得通,你会得到一条毫无意义的绿色用例。凡是返回值参与判断的方法,显式写上 WillOnce(Return(...))

还有一条容易忘:接口的析构函数必须是虚的——写 virtual ~Db() = default; 不是可选项,否则通过基类指针删除 mock 对象是未定义行为(08 章讲过)。
能不用 gmock 就不用。C++ 里做替身还有个更轻的路子:把依赖写成模板参数而不是虚接口,测试时传一个普通的假类进去——没有虚函数开销、没有额外依赖、编译期就绑定好。gmock 的独门价值在于断言调用方式(次数、顺序、参数);只是要个返回值的话,手写一个十行的假类往往更清楚。

需要断言调用顺序时用 ::testing::InSequence:在作用域里声明一个,之后的 EXPECT_CALL 就必须按书写顺序发生。

会开调试器,是从「加 print 猜」到「直接看现场」的分水岭——一个从入门到精通的学习者不该没用过 gdb / lldb。

读崩溃现场

  • 编译带 -g -Og-O0 更好读);崩溃后 runbt(backtrace)看调用栈是第一反应;
  • core dump 事后调试:ulimit -c unlimited 生成 core,gdb ./app core 还原崩溃瞬间。

断点与单步

  • break file:line / b func 下断点,next / step / continue 走,print expr / info locals 看现场。

watchpoint:抓「谁改了它」

  • watch var 在变量被写时中断——定位「值被莫名改坏」类 bug 的利器。
# 带调试信息编译(优化会打乱行号与变量)
g++ -g -Og -o app main.cpp

# gdb 常用流程
gdb ./app
(gdb) break main.cpp:42     # 下断点
(gdb) run                   # 运行,崩溃或断点处停下
(gdb) bt                    # backtrace:看调用栈
(gdb) info locals           # 看当前局部变量
(gdb) watch total           # total 被改写时中断
(gdb) print obj.field       # 求值任意表达式

# 事后调试 core dump
ulimit -c unlimited && ./app     # 崩溃生成 core
gdb ./app core                   # 直接跳到崩溃现场
-O2 编出的 Release 二进制调试时,变量常显示 <optimized out>、单步「跳来跳去」(编译器重排 / 内联了代码)——排查崩溃要用 Debug 构建(-Og -g),别对着优化产物读源码行号。lldb 是 Clang / macOS 生态的对应工具,命令基本可类比(bt / b / p)。
调试器和 Sanitizer(见本章后一卡)互补:Sanitizer 告诉你「哪里错、什么错」,调试器让你停在那一刻看清上下文。二者配合是定位内存 / 并发 bug 的标准组合。

这些不是从别的语言搬来的 GoF 套路,而是 C++ 自己的机制——头文件编译模型、模板、虚函数开销——逼出来的解法,读任何一个大库的源码都会撞见它们。

C++ 有许多独特的设计模式和惯用法。Pimpl(指针到实现)隐藏实现细节、减少编译依赖。CRTP(奇异递归模板模式)实现静态多态,零开销。Type Erasure 结合模板和虚函数,实现灵活的类型擦除(如 std::function)。类型擦除指对外隐藏具体类型:如 std::function 无论存入普通函数、lambda 还是函数对象,对外接口一致、均可统一调用。
// Pimpl 惯用法:编译防火墙
// widget.h
class Widget {
    struct Impl;              // 前向声明
    std::unique_ptr<Impl> pImpl_;
public:
    Widget();
    ~Widget();
    void doWork();
};

// CRTP:静态多态(零虚函数开销)
template<typename Derived>
class Base {
public:
    void interface() {
        static_cast<Derived*>(this)->implementation();
    }
};

class Concrete : public Base<Concrete> {
public:
    void implementation() {
        std::println("Concrete impl");
    }
};
Pimpl 配 unique_ptr 有个必踩的坑:析构函数必须声明在头文件、定义在 .cpp 里(那里 Impl 才是完整类型)。把代码里那行 ~Widget(); 删掉、让编译器在头文件里内联生成析构,g++ 15 报 error: invalid application of 'sizeof' to incomplete type 'Widget::Impl'——因为 unique_ptr 析构要 delete 一个只有前向声明的类型。移动赋值同理:头文件里声明,.cpp 里 = default
Pimpl 最大的好处是修改类的私有成员时,只需要重新编译 .cpp 文件,所有包含头文件的其他文件都不用重新编译。对大型项目来说能节省大量编译时间。

肉眼 review 抓不完低级错误——把格式、风格、常见 bug 模式交给工具流水线,人只管设计与逻辑,这是专业 C++ 项目的标配分工。

专业的 C++ 项目需要多层质量保障:编码规范(Google C++ Style / C++ Core Guidelines)、静态分析(clang-tidy 自动检查常见错误和坏味道)、格式化(clang-format 统一代码风格)、动态分析(Sanitizer 运行时检测)。
# clang-tidy:静态分析(检查 100+ 种常见错误)
clang-tidy src/*.cpp -- -std=c++23

# .clang-tidy 配置文件
# Checks: 'modernize-*,performance-*,bugprone-*'
# 常见检查项:
#   modernize-use-auto
#   modernize-use-nullptr
#   bugprone-use-after-move
#   performance-unnecessary-copy-initialization

# clang-format:代码格式化
clang-format -i --style=file src/*.cpp

# 编译选项:开启所有警告
# g++ -Wall -Wextra -Wpedantic -Werror
clang-tidy 必须知道每个文件怎么编译:直接 clang-tidy main.cpp(不带 --、目录里也没有编译数据库)报 Error while trying to load a compilation database: Could not auto-detect compilation database。两条出路:命令行 -- 之后手写旗标(如本卡示例),或者让 CMake 生成数据库——配置时加 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON,产出的 build/compile_commands.json 让 clang-tidy / clangd 都能找到正确的编译方式。另外别一上来 Checks: '*' 全开——里面不少是特定项目的家规(fuchsia-*altera-*),噪声会把真问题淹没,从 bugprone-* 加少量 modernize-* 起步。
C++ Core Guidelines(isocpp.github.io/CppCoreGuidelines)是 Bjarne Stroustrup 和 Herb Sutter 维护的最权威的 C++ 编码规范,clang-tidy 的很多检查项(cppcoreguidelines-*)就是基于它的。

C++ 没有官方命名规范——不像 Go 的 gofmt、Rust 的 rustfmt、Python 的 PEP8。标准库用 snake_case,Google / LLVM / Qt 各有一套还互相打架。所以真正的规则只有两条:选一套、全项目一致,且服从所在代码库的既有风格(改别人的代码入乡随俗,别掺自己的习惯)。

几套主流约定(认长相即可,别背)

  • 标准库 / Boost:一律 snake_case,连类型都是(std::vectorstd::string_view);
  • Google Style:类型 PascalCase、函数 PascalCase、变量 snake_case、成员 snake_case_(尾下划线)、常量 kMaxSize、宏 UPPER_CASE;
  • LLVM:类型 PascalCase、函数 camelCase、变量 PascalCase;Qt:方法 camelCase、成员 m_member

一套自洽的新项目默认

  • 类型 / 类 / 枚举:PascalCaseHttpClient);函数 / 变量:snake_case 或 camelCase 二选一,别混
  • 成员变量加尾下划线 member_(或前缀 m_)以区别局部变量;编译期常量 / 枚举值 kConstant 或 UPPER_CASE;
  • 一律 UPPER_CASE——宏没有作用域、极易撞名,这是唯一的硬约定;文件:源 .cpp/.cc、头 .hpp/.h,文件名 snake_case 或与主类同名。

别踩保留标识符

  • _Foo(下划线 + 大写)、__foo(含双下划线)在任何位置都保留给实现;全局作用域的 _foo(下划线 + 小写)也保留——自定义名字这么起就是 UB,多半悄悄和标准库 / 编译器内部符号撞名。
// 一套自洽的默认(新项目起步用)
class HttpClient {          // 类型 / 类:PascalCase
public:
    void send_request();   // 函数:snake_case(本项目统一,别混 camelCase)
private:
    int         retry_count_;  // 成员:尾下划线,和局部变量区分
    std::string base_url_;
};

constexpr int kMaxRetries = 3;   // 编译期常量:kXxx(或 UPPER_CASE)
#define LOG_LEVEL 2              // 宏:一律 UPPER_CASE(无作用域、易撞名)

// 保留标识符——别自己起这些名字:
//   _Foo   __foo    → 任何位置保留给实现
//   _foo            → 全局作用域保留

// 用工具强制统一,别靠自觉:
//   clang-format -i src/*.cpp                    # 格式
//   clang-tidy 的 readability-identifier-naming   # 命名
别用匈牙利命名法lpszNamedwCount)——把类型编进名字,类型一改名字就骗人,现代 C++ 靠类型系统而非名字前缀表达意图。另一个高频错误是标识符里的下划线:开头下划线 + 大写(_Buf)、任何位置的双下划线(my__var)都是实现保留的,重名即 UB 且报错往往莫名其妙——下划线只放在名字中间或结尾才安全。
团队里命名之争最没营养——把规则写进 .clang-format + clang-tidy 的 readability-identifier-naming,CI 里强制,就没人能跑偏,也省了 review 时吵大小写。选型别自创,直接抄一份成熟规范(Google / LLVM)改。

编译器插桩,在运行时抓住肉眼与静态分析漏掉的内存 / 并发 bug——这是现代 C++ 质量保障的运行期支柱,也是 15 章自测流程依赖的工具。

四个 Sanitizer

  • ASan:越界 / use-after-free / 内存泄漏(shadow memory,约 2× 变慢);
  • UBSan:有符号溢出、错位对齐、空指针解引用等 UB;
  • TSan:数据竞争(记录 happens-before);
  • MSan:读未初始化内存——但要求整个程序含标准库都用 MSan 编译,否则误报。

日常用法

  • -fsanitize=address,undefined -fno-omit-frame-pointer -g 是常用组合;运行期用 ASAN_OPTIONS / UBSAN_OPTIONS(如 halt_on_error=1)调行为;
  • CI 里跑一份 sanitizer 构建,是拦住内存 / 并发 bug 进主干的标准做法。
# ASan + UBSan:日常组合
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 main.cpp
./a.out   # 一有越界/UB 立刻打印带行号的报告

# TSan:查数据竞争(与 ASan 互斥,单独一次构建)
g++ -fsanitize=thread -g main.cpp

# 运行期开关
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 ./a.out
ASan 与 TSan 互斥,不能同时 -fsanitize=address,thread——要分两次构建各跑一遍。而且 Sanitizer 只能发现被实际执行到的路径上的错误,没跑到的代码它一无所知——所以必须和测试 / fuzzing 配合覆盖。报告要有可读函数名 + 行号,需 llvm-symbolizer 在 PATH 里。
Sanitizer 快、且比 Valgrind 覆盖面更全,是开发与 CI 期首选(Valgrind 仍适用于无法插桩的场景)。要探索未知边界输入,用 -fsanitize=fuzzer(libFuzzer)自动生成用例——测试测已知输入,fuzzing 找你没想到的。

C++ 的存在意义是性能,而不会测量就只能瞎猜——这是从「能写」到「写得快」的分水岭。铁律:先 profile 定位热点,再动手。

微基准:Google Benchmark

  • BENCHMARK(fn) 自动决定迭代次数,给出稳定的 ns/op;
  • benchmark::DoNotOptimize(x) / ClobberMemory() 阻止编译器把被测代码优化掉(否则测出 0)。

宏观剖析:perf / VTune

  • perf record / perf report、火焰图找出真正的热点,别凭直觉优化冷代码。

纪律

  • 基准必须在 Release + -O2 / -O3 下测,Debug 数字毫无参考价值。
#include <benchmark/benchmark.h>

static void BM_sort(benchmark::State& st) {
    std::vector<int> v(st.range(0));
    for (auto _ : st) {          // 框架自动迭代计时
        std::sort(v.begin(), v.end());
        benchmark::ClobberMemory();  // 别让编译器优化掉
    }
}
BENCHMARK(BM_sort)->Range(8, 8<<10);
BENCHMARK_MAIN();

# 宏观剖析: perf record ./app && perf report
微基准极易被死代码消除骗到——不 DoNotOptimize / ClobberMemory,编译器会把「结果没被用」的被测代码整段删掉,测出假的超快结果。CPU 频率缩放(turbo)、散热、后台负载也会让数字抖动,要多跑取稳定值、并锁定 Release 构建。
优化顺序永远是:profile 定位热点 → 改 → 再测验证。「过早优化是万恶之源」——先让 perf / VTune 告诉你 5% 的热点在哪,把力气花在那里,而不是凭感觉重写冷代码。

网络编程

C++ 至今没有标准网络库,这一章先讲清这件事的来龙去脉,再把事实标准 Asio 讲透:io_context 事件循环、RAII 管住 socket 生命周期、error_code 与异常两套并存的错误处理、以及 Asio 最精巧也最难懂的设计——completion token,同一个异步调用换个参数就能在回调、future、C++20 协程三种风格间切换。落点是一个单线程扛并发的协程 echo 服务器。本章代码全部在 Linux 上用 g++ 15.2 + Asio 1.30.2(standalone 版)编译运行。前置知识:socket 是什么、TCP 为什么会粘包、epoll 怎么回事,属于「工程基础」页第 06 章网络编程,那一章用 C 讲;这一章只讲 C++ 在它之上加了什么。

定位与选型

这是学 C++ 网络编程时第一个该问清楚的问题。Python 有 socketasyncio、Go 有 net、Rust 有 tokio 生态,而 C++ 标准库里关于网络一个字都没有——不是被遗忘,是一场持续了十几年、至今没打完的仗。

一条没走通的标准化之路

  • 2003 年,Christopher Kohlhoff 开始写 Asio,2004 年首次公开发布,2005 年底通过 Boost 评审。它很快成为 C++ 网络编程的事实标准,Boost 收录了一份(boost::asio),作者同时维护一份不依赖 Boost 的独立版。
  • 2018 年,以 Asio 为蓝本的 Networking TS(技术规范 ISO/IEC TS 19216:2018)正式发布,看起来只差临门一脚就能进标准。
  • 然后它卡住了,卡在一个看似无关的问题上:异步任务该由谁来调度。Networking TS 建在一套早期的 executors 提案上,而 executors 本身还在推倒重来。委员会不愿意把一个建在流沙上的网络库焊进标准。
  • executors 那条线最后演化成了 senders/receiversstd::execution),在 C++26 落地。网络库则至今没有进标准——而且方向比「换个异步模型重做」更激进:2024 年委员会的网络组已建议不要再做 BSD socket 风格的低层接口,转向更高层的连接抽象,现实目标已经排到 C++29。

所以今天的现实是

  • Asio 就是事实标准。它不是「一个候选库」,而是「那个库」——学 C++ 网络编程基本等于学 Asio。这份投入也不会浪费,但要摆正预期:将来标准里的网络库,API 形状大概率不是 Asio 这个样子,你带走的是「异步 I/O 该怎么组织」的经验,不是具体函数名。
  • 标准里唯一沾边的是 <system_error>std::error_code):它源自 Boost.System,由 Beman Dawes 主笔、Kohlhoff 深度参与,需求正是来自 Asio——这是 Asio 反向推动进标准的部分
  • 但别把 std::execution 也算进这份功劳,那是个常见误解:sender/receiver 恰恰是否决了 Asio 异步模型的那一派。2021 年 10 月委员会投票,「网络库应基于 Asio 的异步模型」未获共识,「应基于 sender/receiver」获得明确支持;Kohlhoff 本人是反对方,还联名写过论文argue「Networking TS 已经成熟,而 P2300 没有」。两套模型是竞争关系,不是上下游。

三层分工,别学串了

讲什么在哪
协议与系统调用socket/bind/listen、TCP 为什么粘包、epoll、SIGPIPE工程基础页 06 章(用 C 讲)
C++ 的包装Asio、RAII、错误处理、协程异步本章
HTTP 应用框架路由、JSON、ORM、MVC下一章 13 C++ Web 库
别被「标准里其实已经有了」的假象骗到:libstdc++ 里真有一份 <experimental/net>(Networking TS 的部分实现),g++ 15 下连 std::experimental::net::ip::tcp::acceptor 都能编译跑通——但它是那条已被放弃路线的化石:实现不完整、不再演进,MSVC 与 libc++ 从未提供,跨平台代码根本没法靠它。看到教程或 AI 生成的代码里出现 std::experimental::net,直接换成 Asio。
装 Asio 比想象的简单:它是纯头文件库,独立版下载解压后 -I 指过去就能用,唯一的链接依赖是 -pthread
g++ -std=c++20 -Iasio/include main.cpp -o main -pthread
不需要 Boost。什么时候才需要 boost::asio?只有当项目已经在用 Boost、或者要用到 Boost 特有的周边(如 Beast 的 HTTP/WebSocket)时——两者的 API 几乎逐字相同,换个命名空间和头文件路径即可。

Asio 名字里的 async 容易让人以为它只能写异步。其实它同步 API 一样完整,而且是理解这个库最好的入口——先把「对象怎么组织」搞清楚,再去啃异步。

三个核心对象

  • io_context:整个库的中枢,一个执行上下文。底层就是 epoll/kqueue/IOCP 的事件循环(工程基础 06 章讲的那个),Asio 把它包成了跨平台的对象。同步调用不需要它干活,但仍要拿它构造 socket——因为 socket 需要知道自己归哪个上下文管。
  • ip::tcp::acceptor:对应 C 里的监听 fd。构造时传一个 endpoint 就一次做完 socket + bind + listen 三件事。
  • ip::tcp::socket:对应连接 fd。accept 出来一个,或者自己 connect 出去一个。

对照 C 版看差别

  • C 里要写五步(socket/setsockopt/bind/listen/accept),Asio 的 acceptor acc(io, endpoint) 一行做完前四步SO_REUSEADDR 也默认帮你设了。注意这个默认只属于「收 endpoint 的那个构造函数」:如果你先 acceptor acc(io) 再手动 open/bindreuse_address0,得自己 set_option——否则又会撞上工程基础 06 章那个「重启服务器 bind 失败」。
  • C 里 send 可能只发一部分必须循环补发,Asio 的 asio::write 保证写完才返回——它就是 writen 的库版本。要「能写多少写多少」的语义才用 write_some
  • C 里 recv 返回 0 表示对端关闭,Asio 这里会变成一个 asio::error::eof 错误——这是最容易绊倒 C 老手的地方,下一卡细讲。

现在你来:把它跑起来,再和 C 版比一比

  • 装好独立 Asio(tip 里那条 -I … -pthread),把右边补上 io_contextmain 编译跑通,nc 127.0.0.1 9000 敲一行看回显。你会发现整段比工程基础页 C 版的 echo 短一大截——五步 API 缩成一行 acceptorsend 循环缩成一句 asio::writeclose 交给了析构。逐行对着 C 版看 Asio 到底替你做掉了什么,一目了然。
  • 撞它的天花板:开两个 nc 同时连、第一个别断开——第二个敲什么都没回音(直接超时)。因为同步版 accept 之后整段收发是串行的,和 C 的迭代版一模一样。这个「卡住」正是后面协程卡要解决的问题,先亲手撞上,学协程的动机才立得住。
#include <asio.hpp>
using asio::ip::tcp;

asio::io_context io;

// 一行 = socket + bind + listen(还默认设了 SO_REUSEADDR)
tcp::acceptor acc(io, tcp::endpoint(tcp::v4(), 9000));

for (;;) {
    tcp::socket sock = acc.accept();       // 阻塞等一个连接

    try {
        char buf[1024];
        for (;;) {
            // read_some:读到多少算多少,返回实际字节数
            std::size_t n = sock.read_some(asio::buffer(buf));
            // asio::write:保证全部写完才返回,相当于 C 里的 writen
            asio::write(sock, asio::buffer(buf, n));
        }
    } catch (const std::system_error& e) {
        // 对端正常关闭在这里表现为 asio::error::eof,不是返回 0
        std::printf("连接结束: %s\n", e.what());
    }
    // sock 离开作用域自动 close —— 不需要手写,也忘不掉
}
asio::buffer 不持有内存,只是「指针 + 长度」的视图(和 std::span 一个性质)。同步调用里这没问题,但一旦进入异步——你必须自己保证缓冲区活到操作完成为止。把局部数组交给 async_write 然后函数就返回,是 Asio 新手第一号崩溃原因:栈上的缓冲早没了,异步操作还在往那片内存里读写。协程写法能大幅缓解这个问题(缓冲区活在协程帧里,随协程一起存活),这也是本章最后主推协程的原因之一。
tcp::v4() 只监听 IPv4。想同时接受 IPv4 和 IPv6,用 tcp::v6() 构造——Linux 上默认开启双栈,IPv4 客户端会以 IPv4-mapped 地址(::ffff:127.0.0.1)连进来。想要纯 IPv6 则显式设 asio::ip::v6_only(true)。这个默认行为各平台不一致(Windows 默认相反),跨平台服务要显式设定,别赌默认值。

如果 C++ 只是把 C 的函数换个名字包一层,那不值得单开一章。它真正的收益集中在三件事上——而这三件恰好是 C 版网络代码最容易出错的三个地方。

① RAII:fd 泄漏这个问题被彻底删掉了

  • C 里每条 return、每个 break、每次错误分支都要记得 close(fd),漏一处就是 fd 泄漏,跑几万个连接就把进程打死。
  • Asio 的 socket 是 move-only 的 RAII 对象:析构即 close,异常抛出时栈展开也会 close。这不是「更方便」,是让一整类 bug 不再可能发生——正是 08 章 RAII 那套思想在系统资源上的兑现。
  • 让一个 acceptor 离开作用域,再建一个新的,内核把同一个 fd 号发了回来——说明前一个确实被关掉了:
    [RAII] acceptor 持有 fd=6
    [RAII] 上一个已析构,新 acceptor 复用了 fd=6 (同一个号,说明确实被关掉了)

② 两套错误处理,同一个函数

  • Asio 里几乎每个调用都有两个重载:不传 error_codestd::system_error,传了就把错误填进去、绝不抛
  • 这不是冗余,是给不同场景准备的:「连不上就该终止」用异常版(代码干净,错误自动上浮);「失败是常态、要逐个重试」用 ec 版(比如遍历候选地址、事件循环里不能让异常穿出去)。
  • 两者对同一个错误的表现:
    [ec 版]   连不存在的端口: ec=111 msg="Connection refused"
    [异常版] 抛出: code=111 what="connect: Connection refused"
    底下是同一个 errno(111 = ECONNREFUSED),只是包装方式不同。ec.value() 就是你在 C 里查的那个 errno。

③ resolver:getaddrinfo 的现代皮肤

  • tcp::resolver 对应 C 的 getaddrinfo,但返回的是一个可以 range-for 的结果集,不用手写链表遍历,也不用记得 freeaddrinfo(RAII 管了)。
  • 更省事的是 asio::connect(sock, results)它自己遍历所有候选地址逐个试连,直到某个成功。C 里那个「别只试第一个」的纪律,在这里是库的默认行为。
// ── 两套错误处理:同一个 connect,两种风格 ──
tcp::socket s(io);
asio::error_code ec;
s.connect(endpoint, ec);            // 传 ec:不抛,失败填进 ec
if (ec) std::printf("%d %s\n", ec.value(), ec.message().c_str());

try {
    s.connect(endpoint);              // 不传 ec:失败抛 std::system_error
} catch (const std::system_error& e) {
    std::printf("%d %s\n", e.code().value(), e.what());
}

// ── resolver + connect:域名解析、逐个候选试连,全在库里 ──
tcp::resolver res(io);
auto results = res.resolve("example.com", "80");
for (const auto& e : results)      // 可直接 range-for
    std::printf("%s:%d\n", e.endpoint().address().to_string().c_str(),
                e.endpoint().port());

tcp::socket sock(io);
asio::connect(sock, results);       // 自动逐个候选试,连上为止
「对端正常关闭」在两套 API 里长得完全不一样,而且都容易写错。C 里 recv 返 0 一目了然;Asio 里它是 asio::error::eof——异常版会抛出来,ec 版会填进 ec。用异常版写循环时,正常的连接结束会走进 catch,很多人第一反应是当成错误打日志告警,结果满屏 "End of file"。正确姿势是把 ec == asio::error::eof 单独判掉,当作正常收尾,剩下的才是真错误。
asio::error_code(即 std::error_code)能和 errno 无缝对照,排查时非常有用:但只在 ec.category()asio.systemec.value() 才等于 errno(ec.message() 也就是 strerror 的那句话)。Asio 自有的错误走另一套编号:asio::error::eofvalue()2,而 errno 2 是 ENOENT「No such file or directory」——照着 errno 表去查会得出完全错误的结论。所以判断错误一律用 ec == asio::error::eof 这种符号比较,别拿数值比。ec.category().name() 能告诉你这个错误来自哪一层(asio.system / asio.misc / asio.netdb)。所以工程基础 06 章「排错工具箱」那套 ss / tcpdump / strace 的方法在这里原样适用——底下还是同一批系统调用,Asio 没有变魔术。
异步与协程

这是 Asio 最值得学、也最容易被跳过的设计。理解它之后,那些看起来毫不相干的用法会突然收拢成一件事。

那个反直觉的设计

  • 每个异步操作(如 async_read_some)的最后一个参数completion token。你传什么进去,不只决定「完成时怎么通知你」,还决定这个函数返回什么类型
  • 回调函数 → 返回 void,完成时调你的回调。这是最原始的形态。
  • asio::use_future → 返回 std::future<size_t>,你可以 .get() 等它。
  • asio::use_awaitable → 返回一个可 co_await 的对象,配 C++20 协程用。
  • asio::deferred → 什么都不启动,返回一个「待发射」的对象,可以先组合再执行。

为什么值得费这个劲

  • 库作者只写一遍:Asio 内部对每个操作只实现一份异步逻辑,三种(以及未来更多种)风格是同一份实现的不同「适配层」。
  • 用户可以渐进迁移:老代码回调风格、新模块协程风格,可以在同一个 io_context 里共存,不用推倒重来。
  • 这套机制也让它有条件适配未来的异步模型——理论上再加一种 token 即可。但别把这想得太顺:如卡 1 所说,std::execution 和 Asio 的模型是竞争关系,Asio 1.30 里并没有 sender/receiver 支持。

三代写法的实际手感

同一件事(读一次数据),三种风格:

  • 回调:逻辑被撕成碎片,「读完之后做什么」写在另一个函数里。多步流程会套成著名的回调地狱,而且错误处理要在每一层重复。
  • future:能写成顺序代码,但 .get() 会阻塞调用线程——在事件循环线程里调它直接死锁。所以它适合「主线程等后台 I/O」,不适合写服务器。
  • 协程co_await 挂起的是协程而不是线程,代码顺序读、异常能正常 try/catch、局部变量跨挂起点存活。这是今天写 Asio 的默认选择
// ① 回调:返回 void,完成时调 lambda
sock.async_read_some(asio::buffer(buf),
    [](asio::error_code ec, std::size_t n) {
        if (!ec) { /* 「读完之后」的逻辑被隔到这里 */ }
    });

// ② future:返回 std::future<size_t>
std::future<std::size_t> f =
    sock.async_read_some(asio::buffer(buf), asio::use_future);
std::size_t n = f.get();        // 注意:会阻塞线程,别在事件循环里调

// ③ 协程:返回可 co_await 的对象 —— 同一个函数,只换了最后一个参数
std::size_t n2 =
    co_await sock.async_read_some(asio::buffer(buf), asio::use_awaitable);

// 三者调的是同一个 async_read_some。差别只在 completion token。
回调风格里有一条必须记住的规则:回调可能在别的线程上执行(只要有多于一个线程在跑 io_context::run())。这意味着同一个连接的两个回调可能并发跑,共享状态就会数据竞争。Asio 的解法是 strand——把一组 handler 串行化,保证它们不会并发执行,相当于一把不用加锁的锁。单线程 run() 时不需要 strand;一旦开多线程,每连接一个 strand 几乎是必备的。
use_awaitable 之外还有一个常用变体 asio::as_tuple(asio::use_awaitable):它让 co_await 返回 (error_code, size_t) 元组而不是抛异常。在「EOF 是正常情况」的循环里特别顺手——不用 try/catch 包一层,直接 auto [ec, n] = co_await ... 然后判 ec 即可。

把前面几卡拼起来:这是一个完整的、单线程的、能同时服务任意多个连接的 echo 服务器。它的核心循环读起来和「一次只服务一个连接」的同步版几乎一样——这正是协程的全部价值。

三个新面孔

  • awaitable<T>:协程的返回类型。函数体里有 co_await/co_return,它就是协程而不是普通函数。
  • co_spawn(executor, 协程, 完成处理):把一个协程扔进事件循环去跑。传 detached 表示「不关心它什么时候结束」——每来一个连接就 spawn 一个 session 协程,然后立刻回去 accept 下一个。
  • this_coro::executor:在协程内部取到「我归哪个执行上下文管」,用来 spawn 子协程。

单线程,三个客户端同时在线

$ g++ -std=c++20 -Iasio/include -o echo echo.cpp -pthread
$ ./echo &
coro echo on 9900
$ for i in 1 2 3; do (echo "client$i" | nc -q1 127.0.0.1 9900 &); done
client2
client1
client3
连接结束: End of file
  • 三个客户端各自拿回自己的数据,而进程里只有一条线程io.run() 只被调用了一次)。
  • (输出顺序每次都不一样,那只是三个 nc 谁先启动的竞态,不能拿来当并发的证据——真正的证据是上一条:三条连接同时在线,而进程里只有一条线程。)
  • 连接结束: End of file 就是上一卡说的 asio::error::eof:对端关闭在异常版 API 里表现为一次抛出,这是正常收尾而不是故障

现在你来:一条线程,同时喂饱三个人

  • 编译跑通(-std=c++20),照 explain 那样开三个 nc 同时连——三个都拿回自己的数据。别看输出顺序(那只是谁先启动的竞态),看硬证据ps -o nlwp= -C echo 打出 1,一条线程扛住了三条并发连接。回头和上一张同步卡对照——同样是 echo,那边第二个连接就卡死了,这边仨人同时在线。
  • 踩 move-only 那个坑:把 co_spawn(ex, session(std::move(sock)), detached) 里的 std::move 去掉——编译期当场报错 error: use of deleted function(socket 的拷贝构造被删了,)。C++ 的 move-only 类型在这里替你挡下了「把连接对象拷错、两条协程共享同一个 socket」的运行期竞态——正是工程基础页那个「不能把 &conn 传给 pthread_create」的坑,只不过这次编译器帮你拦在了运行之前。
#include <asio.hpp>
#include <cstdio>

using asio::ip::tcp;
using asio::awaitable;
using asio::co_spawn;
using asio::detached;
using asio::use_awaitable;

// 每个连接一个协程:写起来像同步,跑起来是非阻塞
awaitable<void> session(tcp::socket sock) {
    try {
        char data[1024];       // 活在协程帧里,跨 co_await 依然有效
        for (;;) {
            std::size_t n = co_await sock.async_read_some(
                asio::buffer(data), use_awaitable);
            co_await asio::async_write(
                sock, asio::buffer(data, n), use_awaitable);
        }
    } catch (const std::exception& e) {
        std::printf("连接结束: %s\n", e.what());   // eof 走这里
    }
}   // sock 随协程帧析构,自动 close

awaitable<void> listener(unsigned short port) {
    auto ex = co_await asio::this_coro::executor;
    tcp::acceptor acc(ex, tcp::endpoint(tcp::v4(), port));
    std::printf("coro echo on %u\n", port);
    std::fflush(stdout);
    for (;;) {
        tcp::socket sock = co_await acc.async_accept(use_awaitable);
        // spawn 完立刻回去 accept 下一个,不等这条连接结束
        co_spawn(ex, session(std::move(sock)), detached);
    }
}

int main() {
    asio::io_context io;
    co_spawn(io, listener(9900), detached);
    io.run();          // 事件循环跑在这一条线程上
}
std::move(sock) 不能省。socket 是 move-only 的,session(sock) 编译就过不去;更隐蔽的是把 socket 的引用传进协程——accept 循环的下一轮立刻把它覆盖掉,协程醒来时操作的已是别人的连接。这和工程基础 06 章里「不能把 &conn 传给 pthread_create」是同一个错误的两种语言写法,只不过 C++ 的 move-only 类型让编译器帮你挡下了大半。
另一条:detached 表示丢弃协程抛出的异常session 里没被 catch 住的异常会被静默吞掉,调试期建议换成打印异常的完成处理器,别让故障无声消失。
要多核就把 io.run() 放进多条线程——io_context 本身是线程安全的,N 条线程跑同一个 run() 即可(典型 N = 核数)。但从这一刻起,同一连接的两个 handler 可能真并发,共享状态必须用上一卡的 strand 保护。先单线程跑通、确认逻辑对,再考虑加线程——绝大多数服务的瓶颈根本不在这里。

最后一张卡回答两个实际问题:同类库里怎么选,以及——什么时候根本不该自己写网络代码

横向对比

形态适合
Asio(standalone)纯头文件,仅依赖 -pthread默认选它。不想引入 Boost 的项目、跨平台、要用协程
Boost.Asio同一份代码的 Boost 版项目已在用 Boost;或要配 Beast(Boost 的 HTTP/WebSocket 库,只吃 Boost 版)
libuvC 库(Node.js 的底座)要和 C 代码混编、或需要它那套文件系统/进程/信号的统一异步抽象
Seastarshare-nothing 分片架构极端吞吐场景(ScyllaDB 用它)。要求按它的模型重写整个程序,代价很高

更该先问的问题:你确定要写这一层吗

  • 如果你要做的是一个 HTTP 服务——别用 Asio 从零写。HTTP 的正确解析(分块编码、头部折叠、超时、慢速攻击防护)是个泥潭,直接上 下一章 13 的 cpp-httplib 或 Drogon,Drogon 底下用的也是同一套事件循环 + C++20 协程。
  • 如果你要的是和别的服务通信——先看有没有现成的客户端库(gRPC、Redis、数据库驱动),它们大多已经基于 Asio 或 libuv。
  • 真正需要直接写 Asio 的场景:自定义二进制协议、代理/网关、游戏服务器、IoT 设备通信、需要精细控制连接生命周期与背压的中间件。共同点是——没有现成协议库可用

接着往哪走

  • 底下不清楚 → 工程基础页 06 章 网络编程(C 版:粘包分帧、epoll、TIME_WAIT、SIGPIPE)。Asio 只是把这些包起来了,没让它们消失,排查线上问题时你还得下到那一层。
  • 上面要做产品 → 下一章 13 C++ Web 库
  • 协程本身不熟 → 本页 10 章的现代特性部分,以及 09 章并发——协程和线程解决的是不同的问题,别混为一谈。
standalone Asio 和 Boost.Asio 不能混用:虽是同一份代码维护出的两个发行版,编译后 asio::io_contextboost::asio::io_context两个毫不相干的类型,拿一个去喂期待另一个的库直接编译失败。所以这个选择是传染性的——一旦要配 Beast(只认 Boost 版),整个项目就得统一用 boost::asio;网上抄来的示例也要先看清命名空间和头文件(<asio.hpp> vs <boost/asio.hpp>)属于哪一版,混抄两版的代码怎么改都编不过。
一个务实的学习路径:先用同步 API 把 echo 服务器写通(半小时),再改成协程版(这时你会真切体会到 co_await 让代码没怎么变形),最后才去读回调风格的老代码(多数现存项目和文档还是回调写的,得看得懂)。反过来先啃回调和 completion token 的实现细节,十有八九会放弃。

C++ Web 库

上一章讲的 Asio 是「怎么收发字节」,这一章是「怎么把 HTTP 服务做出来」——HTTP 的正确解析(分块编码、头部折叠、超时与慢速攻击)是个泥潭,实践中一律用现成框架而不是拿 Asio 从零写。生态里两大主流:cpp-httplib 是单头文件、同步阻塞的极简库,把一个 REST 端点或 HTTP 客户端嵌进现有程序几行就够;Drogon 是功能完整的高性能异步框架(TechEmpower 基准常年前列),自带路由、MVC 控制器、jsoncpp、ORM 与 C++20 协程,用来从零搭一个正经服务。本章先各用两张卡把两者详细讲清,最后一张卡逐项对比、给出选型。

cpp-httplib(头文件级 · 同步)

yhirose/cpp-httplib 的单个 httplib.h 丢进项目即可。Server 上按方法注册 handler,签名统一是 (const Request&, Response&):从 req 读、往 res 写。路径参数用正则捕获,命中的分组在 req.matches 里。

#include "httplib.h"
using namespace httplib;

Server svr;

// 静态路由
svr.Get("/health", [](const Request& req, Response& res) {
    res.set_content("ok", "text/plain");
});

// 路径参数:用正则捕获,matches[1] 是第 1 个分组
svr.Get(R"(/users/(\d+))", [](const Request& req, Response& res) {
    std::string id = req.matches[1];
    res.set_content("user " + id, "text/plain");
});

// 查询参数 ?q=... 与请求体
svr.Post("/search", [](const Request& req, Response& res) {
    std::string q = req.get_param_value("q");   // 没有则返回空串
    bool hasQ = req.has_param("q");
    std::string body = req.body;                // 原始请求体
    res.set_content(body, "application/json");
});

svr.set_default_headers({{"Server", "myapp"}});
svr.listen("0.0.0.0", 8080);                 // 启动,阻塞
listen阻塞调用,且模型是每请求一线程——handler 里做重活会占住线程,高并发下别把它当异步框架用。头文件虽只有一个,但把整套实现拉进每个编译单元,编译较慢;大项目可用仓库自带的 split.py 把它拆成 httplib.h + httplib.cc 单独编译。
要 HTTPS:装 OpenSSL、编译期定义 CPPHTTPLIB_OPENSSL_SUPPORT,再用 SSLServer / SSLClientsvr.set_mount_point("/", "./www") 一行即可托管静态文件目录。

cpp-httplib 不含 JSON,惯例是配 nlohmann/json(也是单头文件)手动序列化。同一个库还自带 HTTP 客户端 Client,写爬虫 / 调外部 API 很顺手。

#include "httplib.h"
#include <nlohmann/json.hpp>
using json = nlohmann::json;

// —— 服务端返回 JSON ——
svr.Get("/api/user", [](const auto& req, auto& res) {
    json j = {{"id", 1}, {"name", "Alice"}};
    res.set_content(j.dump(), "application/json");   // dump() → 字符串
});

// 解析进来的 JSON 请求体
svr.Post("/api/user", [](const auto& req, auto& res) {
    auto body = json::parse(req.body, nullptr, false);
    if (body.is_discarded()) { res.status = 400; return; }  // 解析失败
    std::string name = body.value("name", "");
    res.status = 201;
});

// —— 客户端:调外部 API ——
Client cli("http://localhost:8080");
auto res = cli.Get("/api/user");            // 返回 httplib::Result
if (res && res->status == 200)              // res 为真才代表连上了
    auto data = json::parse(res->body);
auto r2 = cli.Post("/api/user", R"({"name":"Bob"})", "application/json");
客户端 Get 返回的 Result先判真再取 ->status:连接失败时 res 为假,直接解引用会崩。json::parse 默认解析失败会抛异常——处理不可信输入时用上面的「nullptr, false + is_discarded()」非抛出形式。
想要「一次抓多页」「带超时/重试」的客户端逻辑,配合 cli.set_connection_timeout / set_read_timeout。爬虫向的更完整用法见「爬虫」专页。
Drogon(高性能异步框架)

Drogon 用 CMake 构建,drogon_ctl create project myapp 生成骨架;小实验也可单文件内联注册。核心是 drogon::app() 单例——挂监听、加载配置、run() 进事件循环。正经项目则用 HttpController 把路由声明在类里(MVC 的 C),比一堆 registerHandler 清晰可维护:METHOD_LIST_BEGIN/END 宏块把「路径 + 方法」映射到成员函数,路径里的 {} 占位按顺序绑到函数尾部参数

// CMakeLists.txt: find_package(Drogon CONFIG REQUIRED); target_link_libraries(app PRIVATE Drogon::Drogon)
#include <drogon/drogon.h>
using namespace drogon;

// —— 方式一 · 内联注册(适合小实验):路径占位 {1} 依次映射到 handler 尾部参数 ——
int main() {
    app().registerHandler("/users/{1}",
        [](const HttpRequestPtr& req,
           std::function<void(const HttpResponsePtr&)>&& cb,
           std::string id) {                     // {1} → id
            auto resp = HttpResponse::newHttpResponse();
            resp->setBody("user " + id);
            cb(resp);
        },
        {Get});                                    // 允许的方法

    app().addListener("0.0.0.0", 8080)
         .setThreadNum(16)                    // 事件循环线程数(0 = 按核数)
         .run();                              // 阻塞进入事件循环;也可 loadConfigFile("config.json")
}

// —— 方式二 · HttpController(正经项目推荐,MVC 的 C)——
#include <drogon/HttpController.h>
class User : public HttpController<User> {
public:
    METHOD_LIST_BEGIN
    ADD_METHOD_TO(User::getOne, "/users/{id}", Get);   // 路径 · 方法 ·(可选)过滤器
    ADD_METHOD_TO(User::create, "/users", Post);
    METHOD_LIST_END

    void getOne(const HttpRequestPtr& req,
                std::function<void(const HttpResponsePtr&)>&& cb,
                std::string id) {                  // {id} 绑到这里
        auto resp = HttpResponse::newHttpResponse();
        resp->setBody("id=" + id);
        cb(resp);
    }
    void create(const HttpRequestPtr& req,
                std::function<void(const HttpResponsePtr&)>&& cb) {
        std::string q = req->getParameter("q");    // 查询/表单参数
        cb(HttpResponse::newHttpResponse());
    }
};
// 无需手动 new:Drogon 启动时自动发现并实例化所有 HttpController
控制器不用你手动注册——只要类定义链接进程序,Drogon 启动时会自动扫描实例化。忘写 METHOD_LIST_BEGIN/END 块、或方法签名与路径占位数量对不上,会导致路由不生效或编译期报错。
生产项目推荐 drogon_ctl create project 生成的结构 + config.json(监听、DB、日志、线程数都在里面),代码里只 app().loadConfigFile("config.json").run();ADD_METHOD_TO 第三参起可挂过滤器(中间件),如 ..., Get, "LoginFilter"),鉴权/限流写成 HttpFilter 子类复用。

Drogon 内建 jsoncppJson::Value)收发 JSON。真正的杀手锏是 C++20 协程:handler 写成 Task<HttpResponsePtr>,用 co_await 等数据库 / 下游请求,代码像同步一样直,运行时却全程非阻塞——配 drogon::orm 访问数据库尤其便捷。

#include <drogon/drogon.h>
using namespace drogon;

// —— 收发 JSON ——
void handler(const HttpRequestPtr& req,
             std::function<void(const HttpResponsePtr&)>&& cb) {
    auto in = req->getJsonObject();          // 解析请求体 → shared_ptr<Json::Value>
    Json::Value out;
    out["name"] = (*in)["name"].asString();
    out["ok"]   = true;
    cb(HttpResponse::newHttpJsonResponse(out));  // 自动设 application/json
}

// —— 协程 + ORM:像同步一样写异步 ——
Task<HttpResponsePtr> getUser(HttpRequestPtr req, std::string id) {
    auto db = app().getDbClient();
    // co_await 期间线程去处理别的请求,结果就绪再回来
    auto rows = co_await db->execSqlCoro(
        "select name from users where id=$1", id);
    Json::Value out;
    if (!rows.empty()) out["name"] = rows[0]["name"].as<std::string>();
    co_return HttpResponse::newHttpJsonResponse(out);
}
别在协程 / 事件循环线程里做阻塞调用(同步文件 IO、std::this_thread::sleep、阻塞的第三方库)——会卡住整个事件循环,把「异步高性能」直接打回原形。重活扔到 app().getIOLoop() 之外的线程池,或用框架提供的异步接口。SQL 用 参数占位($1,别拼字符串——防注入。
协程 handler 需 C++20 与支持协程的编译器;不想上协程也可用回调式 execSqlAsync。ORM 还有 Mapper<Model> 提供 findByPrimaryKey / findBy 等类型安全接口,配 drogon_ctl create model 生成模型类。

两者不是「谁更好」,而是取向不同:一个把 HTTP 能力嵌进已有程序,一个从零搭高并发服务。逐项对比:

维度cpp-httplibDrogon
形态 / 集成单头文件 httplib.h,丢进项目即用CMake 库 + 依赖,有 drogon_ctl 脚手架
并发模型同步阻塞,每请求一线程非阻塞事件循环(epoll/kqueue)+ 多线程 Reactor
性能够用;线程一多就见顶基准常年前列,高并发吞吐强
内置能力路由 + 客户端;JSON/ORM 都要自己配路由、MVC 控制器、jsoncpp、会话、过滤器、ORM、协程
异步写法无(同步)C++20 协程 co_await,也支持回调
HTTP 客户端自带 Client(调 API / 爬虫顺手)也有客户端,但重心在服务端
C++ 版本C++11 起C++17 起;协程要 C++20
学习 / 编译成本几乎为零;头文件拖慢编译要学框架约定;构建与依赖更重
最适合桌面 / 嵌入式开控制口、内部小工具、写客户端面向公网、要吞吐、带数据库的正经服务
// ——— 同一个 "/hi" 端点,两库写法对照 ———

// cpp-httplib:一个 main 就是一个服务,同步直白
#include "httplib.h"
int main() {
    httplib::Server svr;
    svr.Get("/hi", [](const auto& req, auto& res) {
        res.set_content("Hello", "text/plain");
    });
    svr.listen("0.0.0.0", 8080);   // 阻塞,逐请求分线程
}

// Drogon:注册路由 → 事件循环,响应经回调交出(异步)
#include <drogon/drogon.h>
int main() {
    drogon::app().registerHandler("/hi",
        [](const drogon::HttpRequestPtr&,
           std::function<void(const drogon::HttpResponsePtr&)>&& cb) {
            auto resp = drogon::HttpResponse::newHttpResponse();
            resp->setBody("Hello");
            cb(resp);                    // 拿到响应后回调,非阻塞
        });
    drogon::app().addListener("0.0.0.0", 8080).run();
}
两边对 handler 的纪律正好相反,迁移代码时最容易出错:cpp-httplib 每请求一线程,handler 里做阻塞调用(查库、读文件)只占住自己那条线程,天经地义;同样的代码搬进 Drogon,阻塞的是事件循环线程,一条慢请求能拖住这条 loop 上的所有连接(上一卡的 pitfall 正是讲这个)。反方向也一样——选框架就是选并发模型,路由写法五分钟就能迁完,handler 的写法纪律必须跟着换,这才是两个库真正的分水岭。
一句话选型:只是要个 HTTP 口、或做客户端 → cpp-httplib;要建服务、要并发吞吐与数据库 → Drogon。两者都要 C++17+(httplib 最低 C++11),Drogon 的协程用法要 C++20。

C++ 的界面:Qt、Dear ImGui 与其它

C++ 没有标准 GUI,社区方案分成两种范式——它们的差别不在控件长相,而在「界面状态归谁管」。这一章先把两种范式讲清楚,再逐个看今天的可选项与它们的真实代价:依赖、构建、许可、打包体积。

选 GUI 库之前先选范式。这个选择决定了你的程序结构长什么样——控件树 + 回调,还是主循环 + 每帧重画。选错了,之后每一天都在跟框架较劲。

两种范式的根本差别

  • 保留模式(retained):库替你保存一棵控件对象树。你 new 一个按钮、设好属性、挂上回调,之后库负责记住它、重绘它、把事件送给它。Qt、wxWidgets、GTK、Win32 都是这一派;
  • 即时模式(immediate):库不保存任何界面状态。每一帧你把整个界面重新声明一遍if (ImGui::Button("确定")) { ... } 直接返回「这一帧有没有被点」。Dear ImGui 是代表;
  • 关键差别是「界面状态的唯一副本在哪」:保留模式里它在控件对象里(于是你要负责让它和业务数据保持同步),即时模式里它就是你的业务数据(于是不存在同步问题)。

各自的代价

保留模式即时模式
CPU 占用空闲时几乎为零(事件驱动)每帧重建,空闲也在跑(可降频到按需重绘)
状态同步要手动保持控件与数据一致天然一致,没有第二份状态
外观可与系统原生一致自绘,不像原生应用
无障碍与输入法由工具包对接系统,成熟普遍很弱——读屏器基本不可用
适合长期驻留的桌面应用调试面板、编辑器、游戏内界面

最容易被忽略的一条:无障碍

  • 即时模式的界面在系统看来就是一块像素——没有控件树,读屏器无从读起,输入法候选框的定位也难做;
  • 面向公众的产品时这几乎是硬约束(很多地区对无障碍有法规要求);做给自己团队用的工具时则完全不是问题;
  • 这条差异比「哪个更好看」重要得多,却几乎不出现在对比文章里。
// 保留模式:建对象 → 挂回调 → 把控制权交给事件循环
auto* btn = new QPushButton("保存");
QObject::connect(btn, &QPushButton::clicked, [&] { save(); });
btn->show();
return app.exec();                       // 之后由框架驱动

// 即时模式:主循环归你,每帧重新声明界面
while (!shouldClose()) {
    newFrame();
    if (ImGui::Button("保存")) save();     // 返回值就是「这一帧被点了」
    ImGui::SliderInt("音量", &volume, 0, 100);  // volume 是你自己的变量
    render();
}
别用即时模式做「大部分时间在等用户」的桌面应用。它要求你有一个持续运行的主循环,于是一个本该空闲时零占用的工具,变成了持续 30~60 FPS 的耗电大户。反过来也一样:用保留模式做游戏内调试面板会很痛苦——每帧变化的数据要不停地手动同步进控件。范式与程序的驱动方式必须对齐,这比库的功能列表重要得多。
即时模式最便利的一点,是「界面和数据不同步」这一整类 bug 直接不存在:没有第二份状态可以走样。代价是每帧都要重建界面描述——但现代机器上一个中等复杂度的面板每帧只要几十微秒,真正的成本是「持续占用一个渲染循环」,笔记本上会体现为耗电。多数即时模式框架都支持「无输入时降到几帧每秒」来缓解。

Qt 是 C++ 桌面开发事实上的重量级选项(2026-07 现查官方发布目录,最新是 6.11,长期保留的 LTS 分支是 6.5 与 6.8)。它的强项是「什么都有」,代价是它不只是一个库,而是一整套框架:自己的对象模型、自己的构建步骤、自己的容器与字符串类。

三件需要先理解的事

  • ① 信号槽与 mocQObject 的信号槽机制不是纯 C++——构建时要先用 moc(元对象编译器)扫描带 Q_OBJECT 宏的头文件,生成额外的 C++ 源码再一起编译。所以「加了 Q_OBJECT 后链接报 undefined reference to vtable」几乎总是 moc 没跑或没重跑;
  • ② 两套界面技术Widgets(传统控件,桌面感强、适合工具与表格类应用)与 QML/Quick(声明式、动画流畅、适合触屏与定制视觉)。两者可以混用但心智完全不同,新项目要先选一条主线;
  • ③ 对象树与所有权:给 QObject 指定 parent 后,parent 析构时会自动 delete 子对象。这是 Qt 自己的内存管理约定,与本页 05 章讲的智能指针是两套体系——QWidget 塞进 unique_ptr 再交给 parent,会 double free

许可这件事必须一开始就问清楚

  • Qt 提供开源(LGPL/GPL)与商业双许可。用 LGPL 时的关键约束是:用户必须能替换掉你使用的 Qt 库——实践上意味着动态链接、并提供替换所需的信息;
  • 静态链接 Qt 发布闭源产品通常需要商业许可,某些模块本身就是 GPL 或商业专有;
  • 这不是法律意见,但它是选型时必须先落实的一条——比技术评估更早,因为结论可能直接排除某个方案。

什么时候不该选 Qt

  • 只要一个小工具窗口 → 整套框架太重(安装包动辄几十 MB 起);
  • 团队完全不熟悉它的对象模型 → 学习曲线是真实成本,不是「看两天文档」;
  • 目标是嵌入到别人的程序里(插件、游戏内面板)→ 事件循环会打架,即时模式更合适。
// 一个最小 Qt Widgets 程序(CMake 侧要开 AUTOMOC)
#include <QApplication>
#include <QPushButton>

int main(int argc, char** argv) {
    QApplication app(argc, argv);
    QPushButton btn("点我");
    QObject::connect(&btn, &QPushButton::clicked, [&] {
        btn.setText("被点了");
    });
    btn.show();
    return app.exec();
}

# CMakeLists.txt —— AUTOMOC 这一行是新手最常漏的
find_package(Qt6 REQUIRED COMPONENTS Widgets)
set(CMAKE_AUTOMOC ON)                # 不开的话 Q_OBJECT 会链接失败
add_executable(app main.cpp)
target_link_libraries(app PRIVATE Qt6::Widgets)
「undefined reference to vtable for MyWidget」是 Qt 新手的头号报错,而它几乎从来不是链接库缺失。真正的原因是加了 Q_OBJECT 宏但 moc 没有为这个类生成代码:可能是没开 AUTOMOC、可能是类定义写在 .cpp 里(moc 默认只扫头文件)、也可能是改了头文件但增量构建没重跑 moc。判据:删掉 build 目录重新构建,如果好了就是第三种。
Qt 的对象树是「谁 new 的不一定谁 delete」——约定是:有 parent 的对象由 parent 负责释放,栈上的对象自己管。把这条和本页 05 章的所有权规则并排记:Qt 的 parent-child 就是一种「非侵入式的 unique_ptr 树」,只是所有权关系写在运行时而不是类型里。理解成这样,就不会再想着给 QWidget* 套智能指针了。

Dear ImGui 的分发方式很不寻常:它没有构建系统、没有依赖、也不发布二进制——你把几个 .cpp 直接加进自己的项目一起编译。下面的数字是本机把 v1.92.9 拉下来实际编译得到的。

这个库有多小

  • 核心只有 4 个 .cppimgui.cppimgui_draw.cppimgui_tables.cppimgui_widgets.cpp,合计约 4.1 万行(另有 imgui_demo.cpp 约 1.1 万行,是可选的示例大全);
  • WSL 上 g++ -O2 编译这四个文件约 20 秒(本机数字,你的机器不同,量级参考即可),产出四个 .o 合计约 1.6 MB;
  • 没有任何第三方依赖:不需要 CMake、不需要包管理器,把源文件丢进项目就行——这是它在游戏引擎与嵌入式工具里流行的直接原因。

但它需要一个「后端」

  • ImGui 本身只产出顶点与索引数据,它不开窗口、不收输入、不画像素;
  • 因此你还要选一对后端:平台后端(GLFW / SDL3 / Win32)负责窗口与输入,渲染后端(OpenGL3 / Vulkan / DirectX11 / Metal)负责把顶点画出来;
  • 官方仓库的 backends/ 目录里都有现成实现,各是一个 .cpp——所以完整接入通常是「4 个核心 + 2 个后端」六个源文件

适合与不适合

  • 适合:引擎/渲染器的调试面板、性能监视器、关卡编辑器、内部工具、嵌入式设备上的本地界面;
  • 不适合:面向普通用户的桌面软件(自绘外观、无障碍薄弱、无系统原生行为)、需要复杂文本编辑与输入法的场景;
  • 它是 C++ 库:纯 C 项目要通过 cimgui 绑定使用(本站工程基础页的「C 的界面」那一章提过同一件事)。
# 本机上的接入方式:没有包管理,直接编源码
$ curl -sL -o imgui.tar.gz https://github.com/ocornut/imgui/archive/refs/tags/v1.92.9.tar.gz
$ tar xzf imgui.tar.gz && cd imgui-1.92.9
$ wc -l imgui.cpp imgui_draw.cpp imgui_tables.cpp imgui_widgets.cpp
  18626 imgui.cpp
   6814 imgui_draw.cpp
   4904 imgui_tables.cpp
  11040 imgui_widgets.cpp     # 核心合计约 4.1 万行

$ g++ -c -O2 imgui.cpp imgui_draw.cpp imgui_tables.cpp imgui_widgets.cpp
# 本机约 20 秒,产出四个 .o 共约 1.6 MB(你的机器会不同,看量级)

// 每帧的用法:声明即界面,返回值即事件
ImGui::Begin("调试面板");
ImGui::Text("帧时间 %.2f ms", dt * 1000);
if (ImGui::Button("重置相机")) camera = {};
ImGui::Checkbox("显示线框", &wireframe);      // 直接绑到你的 bool
ImGui::End();
即时模式最反直觉的一条:控件靠「ID」而不是对象来区分,而 ID 默认由标签文字生成。于是同一个窗口里两个都叫「删除」的按钮会被认成同一个控件——点一个两个都亮,或者状态互相串。修法是用 ## 给出隐藏后缀("删除##row1")或用 PushID(i) 包住循环体。for 循环里生成控件时几乎必然撞上这一条,而症状(状态错乱)看起来完全不像是 ID 冲突。
imgui_demo.cpp 编进调试构建里非常划算:它内置一个「所有控件的活体文档」窗口(ImGui::ShowDemoWindow()),想知道某个控件怎么用,直接在运行的程序里找到它、再去 demo 源码里搜同名函数。这比翻文档快得多——这也是 ImGui 长期没有传统文档站却依然好用的原因。

Qt 与 ImGui 之外还有几条路,各自补上一个 Qt 太重、ImGui 太原始的位置。选型时把「怎么发给用户」一起考虑进去——桌面 C++ 项目最后卡住的地方,八成是打包而不是编码。

其它可选项(版本为 2026-07 现查各自的最新发布)

方案版本特点代价
wxWidgets3.3.3包装各平台原生控件,外观最像本地应用;LGPL 且例外条款宽松API 偏老派,文档风格陈旧
FLTK1.4.x极小极快,静态链接后单文件几 MB控件少、外观朴素
Slint1.17.1声明式 DSL + 编译期生成代码,C++/Rust 双支持,面向嵌入式与桌面生态新;许可分开源与商业两条线
Web 前端 + 本地服务C++ 只做后端,界面交给浏览器;跨平台一次解决多一层进程与协议;启动依赖浏览器
系统 WebView 嵌入用系统自带浏览器内核渲染界面,产物比 Electron 小得多各平台内核行为不一致

打包才是真正的门槛

  • Windows:MSVC 运行时依赖、Qt 需要 windeployqt 收集 DLL 与插件、代码签名(不签会被 SmartScreen 拦);
  • macOS.app 目录结构、macdeployqt、签名 + 公证(不公证用户打不开)、通用二进制要同时编 x86_64 与 arm64;
  • Linux:发行版差异大,实践上走 AppImage / Flatpak 打包整套依赖;
  • 经验法则在项目第一周就把「空程序的完整打包流程」跑通一次,别等功能写完。这一步暴露的问题(许可、依赖、签名、体积)往往会反过来影响技术选型。

一条务实的路线

  • 先问:用户是谁。团队内部工具 → ImGui 或 Web 界面最省事;面向客户的商业软件 → Qt / wxWidgets,并早点解决许可与签名;嵌入式屏幕 → Slint 或 LVGL 这类为受限设备设计的方案;
  • 再问:这个界面会活多久。一次性工具别上重框架,长期产品别贪图省事选一个生态即将枯竭的库;
  • 最后才比较 API 好不好用——它是三个问题里最不重要的一个。
# 打包这一步的真实工作量(以 Qt 为例,各平台各一套)
# Windows
$ windeployqt --release app.exe          # 收集 DLL、平台插件、翻译文件
$ signtool sign /fd SHA256 /a app.exe    # 不签名会被系统拦截警告

# macOS
$ macdeployqt App.app -dmg
$ codesign --deep --options runtime -s "Developer ID" App.app
$ xcrun notarytool submit App.dmg --wait # 不公证:用户双击直接被拒

# Linux
$ linuxdeploy --appdir AppDir --output appimage

# 参考量级:一个「空窗口」程序打包后的体积
#   FLTK 静态链接      几 MB
#   Qt Widgets 动态    几十 MB(含 Qt 库与插件)
#   Electron           一百多 MB(自带整个 Chromium)
「先用 GUI 库把功能做出来,跨平台以后再说」是 C++ 桌面项目最贵的一个假设。跨平台的成本不在代码——现代库的 API 本来就是跨平台的——而在构建矩阵、依赖收集、签名公证、以及各平台特有的行为差异(文件对话框、DPI 缩放、菜单栏位置、输入法)。这些只有真正在目标平台上打包运行才会暴露。第一周跑通打包,比第一周写出功能更重要。
如果界面需求是「表单 + 表格 + 图表」而团队里有前端能力,「C++ 内嵌一个本地 HTTP 服务 + 浏览器界面」往往是最省总成本的方案:跨平台问题一次解决、界面迭代不用重编译、远程访问免费得到。代价是多一层协议与一个浏览器依赖。路由器、NAS、打印机、示波器的管理界面几乎全走这条路,不是偶然。

从这里到精通:路线图

语法地图铺完了,剩下的路要靠项目垒出来。最后这一章给出收尾路线:难度递进的动手项目、按阶段的书单,以及一条自测标准。

C++ 学习曲线陡峭但回报巨大,从入门到胜任需要 1–3 年持续投入。路径只有一条:多写代码、多读优秀开源项目、多用编译器诊断工具。按下面的顺序走,每一步都有明确产出物。

动手项目(难度递进)

  • ① 命令行工具:用 RAII + STL 写一个 grep / JSON 格式化器,全程零裸 new,clang-tidy 零告警——把「现代 C++ 肌肉记忆」练出来。
  • ② 造轮子:手写一个简化版 vectorunique_ptr(含移动语义、异常安全),再和标准库实现对照——模板、拷贝控制、内存管理一次全过手。
  • ③ 并发实战:实现一个固定线程数的任务池(mutex + condition_variable + future),用 ThreadSanitizer 验证无数据竞争,再用 Google Benchmark 量化吞吐。
  • ④ 写点会联网的东西:用 Asio 协程写一个带自定义分帧协议的聊天服务器(12 章),再把它改造成 HTTP 服务(13 章)——网络代码是把 RAII、移动语义、并发、错误处理一次全用上的最佳场景,也最容易看出「现代 C++ 写法」值多少钱。
  • ⑤ 走进真实工程:挑一个中型开源项目(fmt、spdlog、Catch2 都很友好),从修一个 good-first-issue 开始——读大库源码是从「会写」到「写得好」的必经之路。

书与资料(按阶段)

  • 入门:《C++ Primer (5th)》系统打底;薄而全的《A Tour of C++ (3rd)》是 Stroustrup 的官方导览。
  • 进阶:《Effective Modern C++》42 条实战建议必读;模板深水区配《C++ Templates (2nd)》。
  • 并发专项:《C++ Concurrency in Action (2nd)》。
  • 日常工具:cppreference.com(最权威的标准库参考)、godbolt.org(在线看编译产物)、CppCon 年度大会视频、isocpp.org 跟进标准动态。
C++ 学习资料的年代杀伤力比别的语言大得多:2011 年之前的书和大量网络教程还在教裸 new/deleteNULLchar* 字符串,照着练会把本页前面拆掉的坏习惯原样装回去。挑资料先查两件事——出版或更新年份、示例用的 -std= 档位;看到 #include <iostream.h>(前标准时代写法)或通篇手写 new[]/delete[] 当最佳实践的,直接换。面试题网站上的「经典 C++ 题」也常停在 C++98,可以拿来了解历史,别当今天的写法背。
一条自测标准:给你一段崩溃的老式 C++ 代码,你能否有序排查——先开 -Wall -Wextra 和 clang-tidy 看静态告警,用 gdb / lldb + backtrace 定位崩溃点,再上 AddressSanitizer/UBSan 定位内存错误,最后用智能指针与 RAII 重构消灭错误根源。能把这套流程当成本能,这一页就毕业了。