从 C# 到 C++:以读懂真实项目为目标
从 C# 到 C++:以读懂真实项目为目标
面向有 C# 基础的读者。主线为现代 C++,默认讨论 C++20,新增特性注明版本。
本文中的短代码用于解释局部语义,可能省略头文件、外围类或函数;请勿将所有代码块拼接成一个程序。
目录
- 1. 从 C# 迁移时先换掉哪些假设
- 2. 一个 C++ 程序如何变成可执行文件
- 3. 基础类型、初始化与表达式
- 4. 指针、引用、const 与声明阅读
- 5. 对象生命周期、RAII 与所有权
- 6. 函数、参数与返回值
- 7. class、struct 与特殊成员函数
- 8. 值类别、移动语义与完美转发
- 9. 继承、多态与常见对象设计
- 10. 智能指针与借用接口
- 11. 容器、迭代器与算法
- 12. 字符串、视图、I/O 与错误处理
- 13. Lambda、回调与函数对象
- 14. 模板、Concepts 与编译期计算
- 15. 头文件、链接、宏与模块
- 16. 并发、原子与协程
- 17. 内存布局、未定义行为与底层边界
- 18. 构建、依赖、调试与测试
- 19. 从零阅读一个陌生仓库
- 20. 配套项目逐层解读
- 21. C# → C++ 速查表
- 22. 高频陌生写法与报错词典
- 23. 阅读能力检查与继续深入的方向
1. 从 C# 迁移时先换掉哪些假设
C++ 和 C# 的 if、for、类定义看起来接近,最重要的差异却不在这些语法上,而在对象的存在方式、资源释放和编译模型。
1.1 C++ 的类对象默认具有值语义
struct Player {
int hp{100};
};
Player a;
Player b = a; // b 是另一个对象,复制成员
b.hp = 20; // a.hp 仍是 100
Player& alias = a;
alias.hp = 50; // 修改 a
在 C# 中,若 Player 是 class,b = a 通常复制对象引用;若是 struct 才复制值。C++ 的 class 和 struct 都可以有值语义,区别主要在默认访问权限和默认继承权限。
“复制成员”不等于递归深拷贝一切:复制 std::string 会得到独立字符串值;复制裸指针只复制地址;复制 shared_ptr 共享对象;含 unique_ptr 的类默认不能复制。读一个类时,需要看它的成员和复制操作。
1.2 不靠 GC 决定普通对象何时销毁
void work() {
std::string name{"Alice"};
std::vector<int> values{1, 2, 3};
} // values、name 按构造的逆序析构,内部资源自动释放
现代 C++ 并不要求在业务代码中到处写 delete。通常把资源放进有析构函数的对象,通过对象生命周期自动释放。资源包括内存、文件、锁、数据库事务和 GPU 句柄。
1.3 C++ 的“引用”不是 C# 对象引用的同义词
C++ 的 T& 是现有对象的别名,不负责延长普通被引用对象的生命周期,也不负责回收对象。C++ 的 T* 是指针值,可以为空、改指向、参与受限的指针运算。二者都可能在目标销毁后悬空。
1.4 模板不只是换一种语法的泛型
C# 泛型基于 CLR 的类型系统。C++ 模板通常在编译期根据实参生成并检查具体实现,可以接受类型、数值和其他模板,参与重载选择,甚至执行编译期计算。看模板要区分“模板定义”与“实例化后的具体代码”。
1.5 C++ 不保证越界或悬空访问会抛异常
数组越界、使用已经释放的对象等可能是未定义行为,Undefined Behavior,UB。结果可能是崩溃、数据损坏,也可能暂时看似正常。不能套用 C# 的数组边界检查或托管引用安全假设。
1.6 先掌握六个观察维度
读到一个变量或表达式,依次问:
- 静态类型是什么?是否含
const、指针或引用? - 这是对象本身,还是借用另一个对象?
- 谁拥有资源,谁会释放?
- 什么时候失效,容器操作是否使它失效?
- 这里发生复制、移动,还是只建立别名?
- 这段代码在哪个翻译单元编译,需要什么库和标准版本?
2. 一个 C++ 程序如何变成可执行文件
2.1 最小程序
#include <iostream>
#include <string>
int main() {
const std::string name{"C++"};
std::cout << "Hello, " << name << '\n';
return 0;
}
#include让编译器看到声明等内容,不等于 C# 的using。std::表示标准库命名空间;::是作用域解析运算符。main是普通控制台程序的入口;返回 0 通常表示成功。<<在这里调用流插入运算符的重载,并非位移。\n换行;std::endl还要求刷新缓冲区,不必每行都使用。
2.2 编译与链接是两个阶段
main.cpp + 它包含的头文件 ──编译──> main.o / main.obj
store.cpp + 它包含的头文件 ─编译──> store.o / store.obj
目标文件 + 静态库/导入库 ────链接──> 可执行文件 / 共享库
预处理负责宏展开、条件编译和头文件包含;编译检查类型并生成目标代码;链接解析跨文件的符号引用。一个源文件经过预处理形成的内容通常称为翻译单元,translation unit。
// math.hpp:声明
int add(int a, int b);
// math.cpp:定义
int add(int a, int b) { return a + b; }
只有声明而未把定义链接进程序,会产生 undefined reference 或 unresolved external symbol;不是补一个 #include 就能解决。
2.3 文件后缀与常见目录
| 项目内容 | 常见名字 | 阅读意义 |
|---|---|---|
| 头文件 | .h、.hpp、.hxx |
声明、模板定义、内联定义 |
| 源文件 | .cpp、.cc、.cxx |
实现,通常各自编译 |
| 公共接口 | include/ |
提供给调用方的边界 |
| 实现 | src/ |
业务逻辑和内部类型 |
| 测试与示例 | tests/、examples/ |
了解 API 的最快入口 |
| 构建描述 | CMakeLists.txt、.vcxproj、meson.build |
目标、标准、依赖、宏 |
| 依赖与配置 | vcpkg.json、conanfile.py、presets |
外部库与工具链约束 |
文件后缀本身不决定头文件能不能包含实现;项目约定和 C++ 的定义规则才决定应该怎么组织。
3. 基础类型、初始化与表达式
3.1 不要把 C# 的类型宽度直接搬过来
| C++ 类型 | 需要知道 |
|---|---|
bool |
布尔值;不要依赖它在外部协议中的原始布局 |
char |
一个字节的字符类型;是否有符号由实现决定 |
short / int / long / long long |
标准规定最低范围及大小关系,具体宽度与平台有关 |
float / double |
浮点类型;表示和计算有舍入误差 |
std::int32_t / std::uint64_t |
<cstdint> 的定宽整数;只有实现提供对应精确宽度时才存在 |
std::size_t |
sizeof 的结果类型,通常也是容器大小类型,无符号 |
std::ptrdiff_t |
指针差值的有符号整数类型 |
std::byte[17] |
表示原始字节,不是小整数的直接替代品 |
在常见 64 位 Windows ABI 下 long 仍为 32 位;常见 64 位 Linux ABI 下为 64 位。sizeof(T) 的单位是 C++ 字节,sizeof(char) 永远为 1;字节的位数用 CHAR_BIT 查看,主流环境为 8。协议和文件格式应明确编码、字节序与宽度,不能直接写入一个对象的内存就假定可移植。
3.2 初始化有多种语法,而且有语义差异
int a; // 局部自动变量:没有初始化,不可直接读取
int b{}; // 零初始化结果:0
int c = 3;
int d(3);
int e{3};
// int bad{3.8}; // 错误:列表初始化拒绝这种窄化
std::vector<int> x(3, 7); // 3 个 7
std::vector<int> y{3, 7}; // 2 个元素:3 和 7
局部内置类型不默认归零;静态存储期对象有零初始化阶段。类对象如何初始化还取决于构造函数和成员默认值。优先显式初始化,避免靠复杂规则猜测。
花括号不是在所有场合都能替换圆括号:存在 std::initializer_list 构造函数时,花括号可能优先选择它。
std::string s(); // 声明函数:无参数,返回 string!
std::string t{}; // 定义空字符串对象
这类歧义称为 most vexing parse。C++20 聚合的指定成员初始化可写 Options{.width = 800, .height = 600},但仅适用于满足条件的聚合,而且指定顺序须与成员声明顺序一致,不是 C# 任意对象初始化器。
3.3 auto、类型推导和 decltype
const int n = 10;
auto a = n; // int,复制值,丢弃顶层 const
auto& b = n; // const int&,别名
const auto& c = n; // const int&
auto* p = &n; // const int*
auto 有些像 C# 的 var,但引用与 const 的推导规则需要特别留意。auto value = container[i] 常常复制元素;想借用时通常写 const auto& 或 auto&。
decltype(expr) 获取表达式对应的类型:
int x = 0;
decltype(x) a = 1; // int
decltype((x)) b = x; // int&;括号使它按表达式值类别处理
decltype(auto)[14]保留这种精确推导能力,也可能意外返回悬空引用。初读项目时把它当成“必须继续检查返回表达式”的信号。
3.4 数值转换与比较
int count = static_cast<int>(3.9); // 得到 3;明确表达转换意图
static_cast 不自动检查所有范围问题。把巨大 size_t 转成 int 前仍需验证范围。-1 与无符号数比较时可能先被转换成很大的无符号值,结果与直觉相反。
C++20 的 std::ssize(container) 返回有符号大小类型;std::cmp_less 等函数帮助比较不同符号的整数。对需要反向遍历的无符号索引,i >= 0 永远成立,优先使用反向迭代器。
有符号整数溢出是 UB;无符号整数按其位宽取模。1 / 2 是整数除法得到 0,1.0 / 2 得到浮点数。不要依赖浮点数 0.1 + 0.2 == 0.3 恒真。
3.5 控制流和枚举
if / switch / while / for / break / continue 与 C# 基本接近。范围循环的元素声明决定复制还是引用:
for (auto value : values) { /* 复制每个元素 */ }
for (auto& value : values) { ++value; }
for (const auto& value : values) { /* 只读借用 */ }
if (auto it = table.find(key); it != table.end()) { // C++17
use(it->second);
}
switch 的 case 可能继续执行下一个分支,通常需要 break;有意贯穿时可用 [[fallthrough]][17]。C# 对这方面的约束不同。
enum class State { Idle, Running, Stopped };
State state = State::Idle;
优先识别 enum class[11]:枚举项具有自己的作用域,通常不隐式转换成整数。老式 enum 常用于旧项目、C 接口或位标志;枚举做位运算往往需要项目定义的运算符。
4. 指针、引用、const 与声明阅读
4.1 同一个符号,出现位置不同含义不同
int n = 10;
int* p = &n; // 声明中的 *:指针;表达式中的 &:取地址
int& r = n; // 声明中的 &:左值引用
*p = 20; // 表达式中的 *:解引用,访问 n
r = 30; // 给 n 赋值,不是让 r 改为引用另一个对象
| 写法 | 含义 |
|---|---|
T value |
一个 T 对象 |
T* p |
指向 T 的指针,可为空 |
T& r |
对 T 的左值引用,必须绑定有效对象;正常接口不表达“空” |
const T& r |
通过此引用不可修改目标 |
T&& r |
右值引用,或在特定推导上下文中是转发引用 |
p->member |
通常等价于 (*p).member;类还可以重载 operator-> |
引用变量初始化后不能重新绑定。对悬空引用或通过空指针伪造出来的引用,不存在“引用会自动检查”的保障。
4.2 const 修饰谁
const int* a = &n; // 可改 a 指向,不可通过 a 修改 n
int const* b = &n; // 与上面相同
int* const c = &n; // c 不可改指向,可通过 c 修改 n
const int* const d = &n; // 两者都受限
从变量名往外读:c 是 const 指针,指向 int。const 只限制某个访问路径,不会自动让整张对象图不可变;另一个非 const 引用仍可能修改原本非 const 的对象。
void inspect(const Player& p); // 借用,只读访问
void heal(Player& p); // 借用,可能修改
成员函数尾部的 const 限制它通过 this 修改对象的普通成员;mutable 成员例外。指针成员指向的外部对象也不会因此自动变成 const。
4.3 nullptr 和空值判断
nullptr[11]是空指针字面量,应取代把 0 或 NULL 当空指针的现代代码写法。if (p) 只检查指针非空,不能检查其目标是否已销毁。
4.4 复杂声明分层拆开
using Callback = void (*)(int); // 指向函数的指针类型
Callback cb = nullptr;
int (*array_ptr)[4]; // 指向“4 个 int 的数组”的指针
int* pointer_array[4]{}; // 包含 4 个 int* 的数组
T a[N] 中 N 是数组长度;T (&a)[N] 是数组引用,保留长度;普通函数参数 T a[] 会调整成 T*,长度不会自动传递。现代接口常用 std::span<T>[20]同时表达指针和元素数量。
声明 int* a, b; 只有 a 是指针,b 是 int。阅读时按每一个声明项分别解析。
5. 对象生命周期、RAII 与所有权
5.1 存储期、作用域和生命周期是不同概念
| 概念 | 问的问题 |
|---|---|
| 作用域 scope | 哪些位置能用这个名字? |
| 存储期 storage duration | 存储空间存在多久? |
| 生命周期 lifetime | 对象何时完成相应初始化、何时开始销毁或失效? |
| 所有权 ownership | 谁负责最终释放资源? |
常见存储期:局部变量的自动存储期、全局和 static 对象的静态存储期、thread_local 的线程存储期,以及动态分配的动态存储期。
“栈对象”和“堆对象”是理解实现的常见术语,不能直接替代 C++ 语言规则。局部 vector 对象常放在栈上,它管理的元素通常在动态分配区;编译器也可能消除或改变实际存储。
5.2 RAII:让资源跟着对象释放
RAII 全称 Resource Acquisition Is Initialization。核心是把资源所有权装进一个对象:构造或工厂获得资源,析构释放资源。
void save() {
std::ofstream file{"result.txt"}; // 需 <fstream>
if (!file) {
throw std::runtime_error{"cannot open file"};
}
file << "hello\n";
} // file 析构时关闭文件;正常返回和通常的异常栈展开均适用
需要确认写入成功时,必须检查流状态;有的错误在刷新或关闭时才发生,不能只依赖析构并期待它报告失败。
C# 的 using / IDisposable 是很好的类比,但 C++ 析构是普通对象模型的一部分,不是等 GC 调用的终结器。正常离开作用域时,已构造的局部对象按逆序析构;进程被强行终止、std::abort、部分 terminate 路径等不提供同样保障。
5.3 构造、析构顺序
类对象通常先构造基类,再按成员声明顺序构造成员,最后执行构造函数体;析构反过来。虚基类有额外顺序规则。
class Pair {
int first_;
int second_;
public:
Pair() : first_(1), second_(first_ + 1) {}
};
即使初始化列表把 second_ 写在前面,也不会改变实际顺序。构造函数体开始时,成员已经完成各自的初始化,不是此时才“创建”。若构造中途抛异常,已完成构造的基类和成员会清理;该完整对象自身的析构函数不会运行。
5.4 悬空:看起来还有地址,对象却不在了
const std::string& bad() {
std::string local{"temporary"};
return local; // 错误:返回局部对象引用
}
同类问题包括:返回局部对象地址、保存指向临时字符串的 string_view、异步回调引用局部变量、容器扩容后继续用旧元素指针。
const std::string& s = std::string{"ok"}; // 此直接绑定延长临时对象生命周期
不要把这条规则推广到所有引用传播:把临时对象传给函数后再保存它的引用,通常不会进一步延长生命周期;返回局部引用也不会因此变安全。
5.5 先问“谁拥有”,再问“是否需要指针”
默认优先直接存对象;确实需要独立生命周期或动态多态时再考虑拥有型指针。借用参数通常用 T& / const T&,可选借用可用 T*。共享所有权必须有真实需求,不能因为“不知道谁释放”就一律用 shared_ptr。
这一设计方向与 C++ Core Guidelines 的资源管理建议 一致:利用自动管理资源的类型,把获取与释放配对落实在对象里。
6. 函数、参数与返回值
6.1 从签名判断调用成本和责任
| 参数形式 | 常见意图 | 需要核实 |
|---|---|---|
int value |
复制一个小值 | 是否发生窄化 |
T value |
收到独立值,可能复制或移动构造 | T 的复制成本和语义 |
const T& value |
只读借用 | 被调用方是否把引用长期保存 |
T& value |
可修改借用 | 哪些成员会改变 |
T* value |
可选借用,或旧接口 | 可否为空,是否转移所有权 |
std::unique_ptr<T> value |
接管唯一所有权 | 调用方通常要 move |
std::shared_ptr<T> value |
参与共享生命周期 | 是否真的需要增加共享所有权 |
std::span<const T> values[20] |
借用一段连续只读元素 | 底层存储寿命和长度 |
这些是常见约定,不是裸指针层面的语言保证。老项目会用裸指针表达所有权,需要结合文档和释放位置判断。
6.2 值传递、引用传递与“指针按值传递”
void increment(int value) { ++value; } // 不修改实参
void increment_ref(int& value) { ++value; }
void reset(int* p) { p = nullptr; } // 仅修改指针副本
void reset_ref(int*& p) { p = nullptr; } // 修改调用方的指针变量
指针参数可以通过 *p 修改目标,但给 p 本身赋新地址不会自动修改调用方保存的指针。这一点类似 C# 把对象引用按值传给函数,但 C++ 没有托管生命周期保障。
6.3 重载、默认实参与返回值
C++ 支持按参数类型和数量重载,不能仅靠返回类型区分普通函数重载。默认实参通常写在调用方能看到的声明里;修改默认实参后,旧调用方二进制不会自动获得新值。
std::string make_name() {
std::string result{"Alice"};
return result;
}
按值返回局部对象通常是正确且高效的接口。编译器可执行 NRVO;未进行 NRVO 时,满足条件的返回路径可移动。return std::move(result); 可能阻碍 NRVO,不能当成固定“优化”。
返回多个值可用自定义结构体或 std::tuple,并以结构化绑定[17]解包。优先选能表达业务含义的结构体。
6.4 explicit、noexcept 与属性
explicit构造函数限制意外隐式转换;explicit operator bool()常用于状态检查。noexcept表示异常不得传播出此函数;若违反,调用std::terminate,不代表“错误都被忽略”。[[nodiscard]][17]提醒调用方不要丢弃有意义的结果。[[maybe_unused]][17]表明未使用是有意的。[[deprecated]][14]标识不再推荐的接口。
noexcept(expr) 还可以按条件声明不抛异常;noexcept(call()) 作为表达式则查询某个表达式是否可能抛异常,两种语法需要按位置分辨。
7. class、struct 与特殊成员函数
7.1 一个完整的值类型
class Player {
public:
explicit Player(std::string name)
: name_(std::move(name)) {} // 需 <string>、<utility>
const std::string& name() const noexcept { return name_; }
int hp() const noexcept { return hp_; }
void damage(int amount) { hp_ -= amount; } // 示例简化,未处理业务边界
private:
std::string name_;
int hp_{100};
};
class默认 private,struct默认 public;继承默认权限也相应不同。- 类定义末尾有分号。
public / protected / private管理访问权限。this是指向当前对象的指针,成员访问中常隐式使用。name()返回成员引用;只有对象仍在且字符串引用未失效时才可使用。- C++ 没有 C# 那样的通用 property 语法,getter/setter 通常是函数。
friend允许指定函数或类访问私有成员;不表示继承或所有权。
7.2 六类特殊成员函数
| 形式 | 含义 |
|---|---|
T() |
默认构造 |
~T() |
析构 |
T(const T&) |
拷贝构造,创建新对象 |
T& operator=(const T&) |
拷贝赋值,修改已有对象 |
T(T&&) |
移动构造[11] |
T& operator=(T&&) |
移动赋值[11] |
Player a{"Alice"};
Player b = a; // 拷贝构造,虽然写了等号
b = a; // 拷贝赋值
编译器是否隐式生成某个函数、生成后是否被删除,与用户声明和成员类型有关。尤其是用户声明析构函数可能抑制隐式移动函数生成,不能看到 = default 就认为全部行为都自动存在。
7.3 Rule of Zero / Three / Five
- 零法则:用
string、vector、智能指针等管理成员资源,通常无需自己写析构、拷贝和移动函数。 - 三法则:旧式手动管理资源的类,一旦需要析构、拷贝构造或拷贝赋值之一,就应检查另外两个。
- 五法则:现代 C++ 还要一起考虑移动构造与移动赋值。
class Owner {
public:
Owner() = default;
Owner(const Owner&) = delete;
Owner& operator=(const Owner&) = delete;
Owner(Owner&&) noexcept = default;
Owner& operator=(Owner&&) noexcept = default;
private:
std::unique_ptr<int> data_;
};
= default 请求按默认规则生成,= delete 禁止该操作。成员本身不可移动时,默认移动也可能被定义为删除。
7.4 运算符重载
operator+、operator==、operator[]、operator() 等允许用户类型定义运算行为。重载不能改变运算符优先级。某些重载运算符也不保留内置运算的所有特性,例如不要假定重载 && 和 || 有内置逻辑运算的短路行为。
struct Point {
int x{};
int y{};
bool operator==(const Point&) const = default; // C++20
};
<=>[20]是三路比较运算符,可与默认化比较配合生成比较行为。它可能产生强序、弱序或偏序结果;含 NaN 的浮点比较不适合无条件当作全序。
8. 值类别、移动语义与完美转发
8.1 类型和“表达式值类别”要分开看
初读项目先掌握:
| 值类别 | 直觉 | 例子 |
|---|---|---|
| lvalue 左值 | 指明一个有身份的对象,常可再次使用名字找到 | x、*p |
| prvalue 纯右值 | 计算出的值,可能用于初始化新对象 | 42、T{} |
| xvalue 将亡值 | 对象仍有身份,但允许从中转移资源 | std::move(x) |
glvalue 包含 lvalue 和 xvalue;rvalue 包含 prvalue 和 xvalue。看到高级文档时知道这些集合关系即可,不必第一遍背完所有表达式规则。
8.2 std::move 是转换,不是搬运指令
std::string a{"a long enough string"};
std::string b = std::move(a);
std::move(a) 把表达式转换成通常可绑定右值引用的形式;是否真的转移资源,由最后被调用的构造/赋值函数决定。对 const 对象使用 move 常常仍然复制,因为典型移动函数要求可修改的 T&&。
对于标准库类型,移动后源对象通常处于有效但值未指定的状态,除非该类型给出更强保证。可以销毁、重新赋值,或执行前置条件满足的操作;不能普遍假定字符串必为空。unique_ptr 的移动则保证源指针变空。
用户类型的移动后约定要看其实现。只写 std::move(x); 丢弃结果不会自动清空 x。
8.3 命名的右值引用表达式仍是左值
void consume(std::string&& text) {
auto copy = text; // text 是有名字的表达式:复制
auto moved = std::move(text); // 允许移动
}
右值引用参数不意味着对象已经被移动。它通常表达“调用者允许消费”,函数体还要真的执行移动操作。
8.4 转发引用与 std::forward
template<class T>
void relay(T&& value) {
target(std::forward<T>(value));
}
当 T 是被推导的、无额外 cv 限定的模板类型参数时,这里的 T&& 可以是转发引用:传入左值时 T 可推导为引用类型,传入右值时保留右值语义。普通 Widget&&、const T&&、或已固定类模板参数的 T&& 不能一概这样理解。
引用折叠:& 与任何引用组合折叠为 &,只有 && 与 && 得到 &&。std::forward<T> 据此恢复调用者的值类别;无条件 std::move 会把左值也当可消费对象。
auto&& 在很多推导场景也具有类似能力,例如泛型范围循环;初始化列表等存在特殊规则。阅读时先看 T/auto 是否真的发生推导。
8.5 复制消除、移动成本与容器
C++17 对部分纯右值初始化/返回场景保证直接构造最终对象;对命名局部变量的 NRVO 则仍不是一概强制。
移动不必总是 O(1):小字符串优化、固定长度数组、分配器差异、用户实现都可能影响成本。vector 扩容时可能为了异常保证选择复制元素而非潜在抛异常的移动;所以资源类的正确 noexcept 移动很重要。
9. 继承、多态与常见对象设计
9.1 C++ 默认不做虚调用
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
};
struct Circle final : public Shape {
explicit Circle(double radius) : radius_(radius) {}
double area() const override { return 3.141592653589793 * radius_ * radius_; }
private:
double radius_;
};
virtual启用运行时多态;= 0声明纯虚函数,使类成为抽象类。override要求编译器验证确实覆盖基类虚函数,能发现漏写 const 等错误。final阻止继续继承类或继续覆盖某虚函数。- 若接口支持通过基类指针删除派生对象,应使用虚析构函数;否则普通
delete base_ptr可能是 UB。
C# 的 interface 常对应“主要由纯虚函数组成的抽象基类”,但二者不是完全相同的类型机制。C++ 支持多重继承,也存在菱形继承和虚继承;初读时画出基类关系,区分“共享基类子对象”与“虚函数派发”。
9.2 对象切片
struct Base { int id{}; };
struct Derived : Base { int extra{}; };
Derived d;
Base b = d; // 只复制 Base 部分,派生部分被切掉
传 Base 值也会出现类似问题。要保留运行时多态,应通过基类引用或适当的智能指针持有对象,例如 std::vector<std::unique_ptr<Shape>>。
9.3 构造/析构期间的虚调用
在基类构造或析构过程中调用虚函数,不会像调用一个已经完整构造好的最派生对象那样派发。不要依赖基类构造函数调用派生实现来初始化派生成员。
9.4 常见设计模式在代码中的样子
| 形式 | 目的 | 阅读重点 |
|---|---|---|
工厂返回 unique_ptr<Interface> |
隐藏具体实现并交付所有权 | 工厂失败路径和析构策略 |
PImpl,成员 unique_ptr<Impl> |
隐藏实现、减少头文件依赖、稳定部分二进制边界 | 析构定义所在源文件和完整类型要求 |
CRTP,Derived : Base<Derived> |
编译期复用或静态多态 | 基类如何转换到派生类;不等价于虚调用 |
type erasure,如 std::function |
以统一接口存储不同具体类型 | 是否分配内存、能否复制、间接调用成本 |
| composition,成员包含其他对象 | 组合职责 | 成员初始化顺序和生命周期 |
PImpl 的典型写法是在头文件前置声明 Impl,把拥有 unique_ptr<Impl> 的类析构函数定义到 Impl 已完整的 .cpp 中。不是“所有遇到不完整类型的地方都不能使用指针”。
10. 智能指针与借用接口
10.1 unique_ptr:一个明确的所有者
auto p = std::make_unique<Player>("Alice"); // make_unique:C++14
auto q = std::move(p); // 所有权给 q,p 变空
Player* observer = q.get(); // 借用,不获得所有权
reset() 释放当前对象并可接管新指针;release() 放弃所有权并返回裸指针,不会删除对象。后者若没有及时被接管,容易泄漏。
不得用 q.get() 再构造一个独立的拥有型智能指针;这样可能形成两个独立释放者并重复删除同一对象。
10.2 shared_ptr:共享生命周期,不等于共享线程安全
auto a = std::make_shared<Player>("Alice");
auto b = a; // 两个 shared_ptr 共享所有权
最后一个共享所有者消失时销毁被管理对象。控制块还可能因 weak_ptr 存在而继续存活。引用计数管理不保护 Player::hp 等对象内容;并发访问共享对象仍需同步。
多个不同的 shared_ptr 实例共享同一控制块时,引用计数管理支持相应并发;多个线程同时修改同一个 shared_ptr 变量仍需同步,或在适用工具链使用 std::atomic<std::shared_ptr<T>>[20]。
10.3 weak_ptr:观察共享对象,不阻止它销毁
std::weak_ptr<Player> weak = a;
if (auto alive = weak.lock()) {
use(*alive); // alive 在这段作用域中维持生命周期
}
双向 shared_ptr 引用可能形成环,造成对象一直不释放。父子、观察者、缓存等关系常用 weak_ptr 表达其中一个不拥有的方向。不要先 expired() 再假定下一步对象仍在,使用 lock() 原子地尝试获得共享所有权。
enable_shared_from_this 允许已被正确 shared_ptr 管理的对象获取同一控制块的共享指针;不要在构造函数里假定它已经可用,也不要写 shared_ptr<T>(this) 新建另一个控制块。
10.4 span 和 string_view:便宜,但不拥有数据
void draw(std::span<const Point> points); // C++20
void log(std::string_view text); // C++17
view 对象自身活着,不代表它观察的数据还活着。span<const T> 使元素只读;const span<T> 只限制这个视图对象,元素通常仍可修改。span 也不是普遍具备运行时越界检查的安全数组。
如果函数要把数据留到返回之后,必须证明调用方存储足够长寿,或复制到拥有型容器中。把 view 用于只在调用期间读取的参数通常更容易管理。
11. 容器、迭代器与算法
11.1 先识别容器的存储与查找特征
| 容器 | 类比/用途 | 读项目时注意 |
|---|---|---|
std::array<T, N>[11] |
固定长度数组,大小是类型的一部分 | 元素直接包含在对象中 |
std::vector<T> |
类似 List<T>,连续动态数组 |
增长可能重新分配,元素通常按值存储 |
std::deque<T> |
两端高效增删 | 不保证整体连续,失效规则不同于 vector |
std::list<T> |
双向链表 | 单次节点插删方便,但局部性和分配成本较差 |
std::forward_list<T>[11] |
单向链表 | 只有前向遍历;接口不是 list 的完整子集 |
std::map<K,V> / set<K> |
按比较器排序的关联容器 | 查找通常 O(log N),常用树实现但标准不指定必须是哪种树 |
std::unordered_map<K,V> / unordered_set<K>[11] |
哈希容器 | 平均 O(1),最坏 O(N);遍历次序无保证 |
std::stack / queue / priority_queue |
容器适配器 | 接口受限,通常不能像普通容器一样直接遍历 |
通常先考虑 vector,但应依据访问模式决定。C++ vector 存放对象值,不是统一存放托管对象引用;vector<Player> 和 vector<unique_ptr<Player>> 的布局与生命周期差别很大。
11.2 size、capacity、reserve、resize
std::vector<int> v;
v.reserve(100); // 预留容量,size 仍为 0
// v[0] = 1; // 错误:尚无第 0 个元素
v.push_back(1);
v.resize(5); // size 变为 5,新添的 int 元素为 0
operator[] 一般不做有保障的越界异常检查;at() 越界会抛 std::out_of_range。reserve() 减少重新分配次数,但超过容量后仍可能重分配。
emplace_back(args...) 直接用参数构造元素;push_back(value) 接收已有对象。emplace 不保证总更快,也不会消除增长时搬移旧元素的成本。
11.3 迭代器与半开区间
auto it = std::find(v.begin(), v.end(), 1); // 需 <algorithm>
if (it != v.end()) {
std::cout << *it;
}
[begin, end) 包含 begin,不包含 end。end() 是尾后位置,不能解引用。迭代器像“统一遍历位置”,不一定是裸指针,也不一定支持 it + n;list 的迭代器就不支持随机访问运算。
std::sort 需要随机访问迭代器,因此不能直接 std::sort(list.begin(), list.end());list 有自己的 sort()。
11.4 失效规则比容器 API 名字更重要
| 操作 | 主要规则 |
|---|---|
| vector 重分配 | 指向元素的所有指针、引用和迭代器失效 |
| vector 无重分配的末尾插入 | 既有元素引用/指针保持有效,原 end 失效 |
| vector 中间插入且无重分配 | 插入点及之后的迭代器/引用失效 |
| vector erase | 删除点及之后的迭代器/引用失效 |
| map/set 插入 | 通常不使已有迭代器/引用失效 |
| map/set 删除 | 仅被删除元素的迭代器/引用失效 |
| unordered 容器 rehash | 迭代器失效;既有元素的引用/指针通常仍有效 |
| unordered 容器删除 | 被删元素的引用/指针/迭代器失效 |
这是常见操作摘要。swap、赋值、allocator、deque 等还有各自规则;遇到边界操作需要查对应 API,而不是记一句“插入都失效”。
for (auto it = v.begin(); it != v.end(); ) {
if (*it < 0) {
it = v.erase(it); // 使用 erase 返回的下一个有效位置
} else {
++it;
}
}
不要在范围 for 中随意删除同一个 vector 的元素,也不要扩容后继续使用循环已经取得的引用。
11.5 算法表达意图
std::sort(v.begin(), v.end());
auto total = std::accumulate(v.begin(), v.end(), 0LL); // <numeric>,以 long long 累加
auto found = std::any_of(v.begin(), v.end(), [](int x) { return x > 10; });
accumulate 的初始值决定累加类型;写 0 可能让中间结果按 int 累加。std::sort 比较器需满足严格弱序;用 a <= b 当“排在前面”会违反要求。
旧项目常见 erase-remove 惯用法:
v.erase(std::remove_if(v.begin(), v.end(),
[](int x) { return x < 0; }),
v.end());
// C++20 可简化为:std::erase_if(v, [](int x) { return x < 0; });
remove_if 只是把保留元素向前整理,返回新的逻辑末尾;不改变 vector 的 size,实际删掉尾段靠 erase。
11.6 map 的 operator[] 会插入
std::unordered_map<std::string, int> counts;
++counts["apple"]; // 不存在时先插入值初始化的 int,即 0
只想检查存在时,C++20 可用 contains,旧版本用 find。at 查不存在的键会抛异常,不会插入。C++17 的 try_emplace、insert_or_assign 表达不同插入策略;读缓存代码时尤其要分清。
11.7 ranges 与 views[C++20]
auto selected = values
| std::views::filter([](int x) { return x > 0; })
| std::views::transform([](int x) { return x * 2; });
for (int x : selected) {
std::cout << x << '\n';
}
它在外观上类似 LINQ 管道,但要确认惰性求值和借用生命周期。这里 selected 间接依赖 values;它不是自动生成一个独立 vector。构建 view 时也未必已经运行过滤函数,实际遍历才会求值。
并非所有 view 都不拥有数据:某些组合可拥有底层范围;但不能根据“view 是一个值”就断定底层对象被复制。需要看它从左值还是右值构造,以及具体 view 的规则。
std::ranges::sort(values) 可直接接收范围;还支持投影,例如按结构体成员排序。C++23 的 std::ranges::to 可把范围物化成容器,是否可用取决于标准库支持。
12. 字符串、视图、I/O 与错误处理
12.1 string 与 C 字符串
std::string s{"hello"};
const char* c = s.c_str(); // 指向以零结尾的内部缓冲区,不拥有它
std::string_view view{s}; // 借用字符区间,含长度
c_str() 的结果在字符串销毁或某些修改后可能失效,不能长期缓存。string_view::data() 不保证该视图区间末尾有 \0;不能直接把任意子视图交给只接受零结尾字符串的 C API。
std::string 可以含内嵌零字节,size() 仍计数;strlen 会在第一个零字节停止。字符串字面量具有静态存储期,内容不可修改;用 const char* 指向字面量本身通常不会因作用域结束而悬空。
12.2 Unicode 与编码
C# 的 string 使用 UTF-16 代码单元;C++ 的 std::string 是 char 序列,类型本身不声明编码。它可能装 UTF-8、当前代码页文本或任意字节。
size()计数代码单元,不等于用户看到的字符数。wchar_t宽度因平台而异,不能当作跨平台 UTF-16 的同义词。- C++20 起
u8"文本"的元素类型是char8_t,不能无条件当作const char*。 - UTF-8/UTF-16 转换、大小写、规范化和用户可见字符分割,通常需要专门库或平台 API。
- 源文件编码、编译器执行字符集、终端编码是三个不同环节;乱码不一定是语言语法问题。
12.3 I/O、filesystem 和格式化
iostream/fstream 使用流状态表示很多错误,默认并非每个 I/O 失败都抛异常。std::getline 读取整行;它接在 operator>> 后时,要注意前一次提取可能留下换行符。不要用 while (!file.eof()) 作为读循环,使用读取操作本身的成功状态。
std::string line;
while (std::getline(file, line)) {
process(line);
}
std::filesystem[17]提供路径、目录和文件操作,很多 API 有抛异常与 std::error_code 两种重载。path 不负责保证文件存在。检查后再使用之间,文件系统仍可能变化。
std::format[20]提供类型安全的格式化字符串构造;std::print/println[23]用于格式化输出。它们是标准库特性,单纯接受 -std=c++20 的旧编译器未必提供 <format>。fmt::format 来自第三方 {fmt},不是 std::format 的另一命名空间写法。
12.4 optional、variant、expected
| 类型 | 表达的业务含义 | 常见用途 |
|---|---|---|
std::optional<T>[17] |
有一个 T,或没有值 | 查找结果、可选配置;不说明“为什么没值” |
std::variant<A,B,...>[17] |
当前是有限候选类型之一 | 状态、事件、语法节点 |
std::any[17] |
类型擦除的单个可复制对象值 | 开放式扩展容器,需要运行时类型检查 |
std::expected<T,E>[23] |
成功值 T,或错误 E | 可恢复、应显式处理的业务失败 |
std::optional<int> find_id(std::string_view name);
if (auto id = find_id("Alice")) {
use(*id);
}
读取 optional 前应检查是否有值;value() 无值时抛异常,operator* 不是等价的检查接口。
std::variant<int, std::string> item = std::string{"ready"};
std::visit([](const auto& value) { std::cout << value; }, item);
std::visit 的访问器需要能处理各候选情况。variant 某些异常路径可能进入 valueless_by_exception 状态;不要把它想成永远无法失败的模式匹配。
12.5 异常和 RAII 配合
try {
load();
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}
通常按 const& 捕获,避免复制和切片。throw; 重新抛出当前异常;throw e; 会按 e 的静态类型抛一个新对象,可能切片。
常见异常保证:
- 不抛保证:操作不会向外传播异常。
- 强保证:失败时可观察状态保持原样。
- 基本保证:失败后不泄漏资源、对象不变量仍成立,但状态可能改变。
析构函数通常应不抛异常;异常栈展开期间又抛出异常可能导致终止。不要把析构当作主要错误报告通道。
部分游戏、嵌入式或基础设施项目禁用异常/RTTI,改用状态码、expected 类结果或断言。是否允许 throw、dynamic_cast、typeid 要先看编译选项和项目约定。
12.6 assert 与业务校验
assert(condition) 用于验证开发者假设,可能在定义 NDEBUG 后被移除。不能写 assert(save_file()) 来承担必要副作用,也不能仅靠 assert 检查来自用户或网络的不可信输入。
12.7 项目里还常见的标准库组件
| 头文件/组件 | 阅读重点 |
|---|---|
<chrono>[11,20 扩展日期/时区等] |
区分时间点 time_point、时间段 duration、时钟 clock;别丢失单位 |
<random>[11] |
随机引擎负责产生序列,分布负责映射到范围;种子决定可复现性 |
<charconv>[17] |
from_chars/to_chars 面向文本与数值转换,检查错误码和消费位置;早期实现的浮点支持可能不完整 |
<tuple> / <utility> |
tuple/pair 组合多个值;std::get、结构化绑定用于访问 |
<bit>[20] |
bit_cast、endian、位计数/旋转等,常见于底层格式处理 |
<regex>[11] |
正则表达式;具体性能不能仅凭“标准库”推断 |
using namespace std::chrono_literals; // C++14 时间字面量
const auto delay = 20ms; // 是毫秒 duration,不是无单位整数
const auto begin = std::chrono::steady_clock::now();
do_work();
const auto elapsed = std::chrono::steady_clock::now() - begin;
const auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed);
std::cout << millis.count();
测量耗时通常用单调的 steady_clock;system_clock 面向墙上时间,可能因校时而调整。count() 只返回当前 duration 单位下的数值,转换单位后截断小数部分等行为需要留意。类型后缀 20ms、"text"s、"text"sv 来自相应字面量运算符与命名空间,不是任意单位字符串;s 字符串后缀产生 string,sv[17]产生 string_view。
std::mt19937 可用于可复现的模拟随机序列,但不是密码学随机数生成器;random_device 的实际熵来源也依实现。C++ 没有把“标准随机库”统一定义为安全随机服务。
原始字符串字面量如 R"(C:\data\file.txt)" 可减少转义,常用于路径、正则或嵌入文本。普通 & | ^ ~ << >> 在整数表达式中常是位运算,在流或自定义类型上则可能调用重载;先确认操作数类型。
13. Lambda、回调与函数对象
13.1 捕获列表就是依赖与生命周期声明
int factor = 2;
auto twice = [factor](int x) { return x * factor; }; // 按值捕获
auto change = [&factor] { ++factor; }; // 按引用捕获
| 捕获写法 | 含义与风险 |
|---|---|
[] |
不捕获局部状态 |
[x] |
保存 x 的副本,复制语义由 x 的类型决定 |
[&x] |
借用 x,闭包不能在 x 失效后再访问它 |
[=] |
默认按值捕获实际使用的可捕获局部对象 |
[&] |
默认按引用捕获,异步/长期保存时需特别检查 |
[this] |
保存 this 指针,通常不延长对象寿命 |
[*this][17] |
捕获当前对象的副本,不是整个对象图的自动深拷贝 |
[p = std::move(ptr)][14] |
初始化捕获,可把唯一所有权放进闭包 |
[=] 不意味着复制当前对象;涉及成员访问时,它可能捕获 this 指针,C++20 已弃用通过 [=] 隐式捕获 this 的这种写法,优先明确表达。
13.2 mutable 与泛型 Lambda
auto counter = [n = 0]() mutable { return ++n; };
auto identity = [](auto value) { return value; }; // C++14 泛型 Lambda
闭包的调用运算符默认是 const;mutable 允许修改按值捕获的副本,不会因此修改原来的局部变量。C++20 还支持显式模板参数的 Lambda,如 []<class T>(T value) { ... }。
13.3 保存回调的三种方式
- 函数指针:适合无状态函数;无捕获 Lambda 在满足条件时可转换成函数指针。
- 模板参数:保留具体闭包类型,优化空间大,但实现一般需对实例化位置可见。
std::function<Signature>:统一保存可复制可调用对象,可能分配内存并产生间接调用。
捕获 unique_ptr 的 Lambda 通常不可复制,不能直接装进要求可复制目标的 std::function;C++23 的 std::move_only_function 支持仅可移动的目标。
回调生命周期阅读步骤:找到注册位置 → 找到存储位置 → 找到触发线程/时机 → 检查捕获对象届时是否仍活着 → 找到取消订阅或销毁流程。C# 事件中的订阅问题在 C++ 中可能进一步演化为悬空访问。
14. 模板、Concepts 与编译期计算
14.1 最基本的函数模板与类模板
template<class T>
T larger(T a, T b) {
return a < b ? b : a;
}
template<class T, std::size_t N>
struct Buffer {
std::array<T, N> values{};
};
模板参数声明中的 class T 与 typename T 通常等价,T 不限于类类型。N 是非类型模板参数,属于类型身份的一部分:Buffer<int, 4> 和 Buffer<int, 8> 是不同类型。
larger(1, 2) 推导出 T=int;larger(1, 2.0) 可能因为两个参数推导出不同的 T 而失败,模板实参推导并不是先做任意隐式转换再找统一类型。可显式写 larger<double>(1, 2.0),或者设计接受不同类型的模板。
14.2 模板约束来自实际使用
老式模板里 a < b 隐含要求 T 支持合适的比较操作。C++20 可通过 Concept 明确表达:
#include <concepts>
template<std::integral T>
T twice(T value) {
return value + value; // 仍需调用方避免有符号溢出
}
Concept 检查编译期约束,不保证运行时值符合业务规则。它不是运行时接口,也不会自动产生虚函数表。
template<class T>
concept Drawable = requires(const T& value) {
value.draw();
};
template<Drawable T>
void render(const T& value) {
value.draw();
}
requires { ... } 表达式检查操作是否成立,不实际运行 draw()。requires 子句则限制某个模板何时可用。复杂库常将两者组合。
14.3 特化、偏特化与重载
template<class T> struct Traits;
template<> struct Traits<int> { /* int 的完全特化 */ };
template<class T> struct Traits<T*> { /* 指针类型的偏特化 */ };
类模板可偏特化;函数模板通常通过重载表达类似需求,不能直接做函数模板偏特化。阅读时先找“主模板”,再找当前实参匹配哪个特化/重载。
14.4 typename、template 和依赖名
template<class Container>
void inspect(const Container& c) {
using Value = typename Container::value_type;
// Value 的确切含义取决于 Container
}
typename 在这里告诉编译器,依赖模板参数的 Container::value_type 是类型。某些调用写成 object.template get<T>(),template 同样是消歧义标记。它们通常不是额外运行时动作。
模板查找有定义期与实例化期等规则;依赖基类成员有时需要写 this->member。遇到“明明继承了却找不到成员”,要想到这个方向。
14.5 type traits、SFINAE 与旧模板代码
std::is_integral<T>::value // 老写法
std::is_integral_v<T> // C++17 便利变量模板
std::remove_reference_t<T> // C++14 类型别名,移除引用
std::enable_if_t<condition, T> 在条件成立时提供类型,否则使某些模板候选被排除。相关机制叫 SFINAE:Substitution Failure Is Not An Error,即某些直接上下文中的替换失败不会立刻让整个程序报错,而是让候选退出选择。函数体里的任意错误并不都会被吞掉。
阅读旧模板报错时,先把冗长的 traits/enable_if 还原成一句中文约束,例如“只有当 T 可复制时此构造函数才参加重载”。现代 Concepts 让这类要求更直接,但旧项目依然很多。
14.6 参数包与折叠表达式
template<class... Ts>
auto sum(Ts... values) {
return (0 + ... + values); // C++17 折叠表达式
}
Ts... 表示一组类型;values... 表示参数包;sizeof...(Ts) 得到数量。看到 std::forward<Args>(args)... 时,应理解为“对每个实参保留其值类别后展开”。
14.7 if constexpr 与编译期求值
template<class T>
void print_kind(const T& value) {
if constexpr (std::is_integral_v<T>) { // C++17
std::cout << "integer: " << value;
} else {
std::cout << "other: " << value;
}
}
模板实例化时,if constexpr 可丢弃不适用分支,常用于按类型生成不同代码。被丢弃分支仍需满足相关语法及非依赖语义规则,不是任意错误都能藏进去;在非模板上下文更不能把它当作预处理器。
| 关键字 | 作用 |
|---|---|
const |
限制通过该对象/访问路径修改,不保证编译期已知 |
constexpr[11] |
变量要求常量表达式初始化;函数在满足条件时可编译期求值,也可运行期调用 |
consteval[20] |
即时函数,受规则约束的调用必须在编译期求值 |
constinit[20] |
要求静态/线程存储期变量进行静态初始化,不代表变量不可修改 |
static_assert[11] |
编译期断言 |
constexpr int square(int x) { return x * x; }
static_assert(square(4) == 16);
14.8 CTAD、ADL 与定制点
CTAD[17]是类模板实参推导:std::pair p{1, 2.0}; 可推导出 pair<int, double>。有些库提供 deduction guide 调整推导规则。
ADL 是实参相关查找:未限定名的函数调用还可能在参数类型关联的命名空间里查找。常见写法:
using std::swap;
swap(a, b); // 允许找到 a/b 类型命名空间里的合适 swap
这并非作者忘写 std::。现代库的定制点对象、隐藏友元等常利用这些规则;初读时先找参数类型所在命名空间中的同名函数。
15. 头文件、链接、宏与模块
15.1 include guards 与 #pragma once
#ifndef DEMO_PLAYER_HPP
#define DEMO_PLAYER_HPP
// declarations
#endif
头文件保护避免同一翻译单元重复处理同一内容。#pragma once 是主流编译器广泛支持的扩展,但不属于 ISO C++ 标准指令。
保护宏不能自动解决“多个翻译单元各定义一个相同外部符号”的链接问题。
15.2 ODR:一个定义规则
普通非 inline 函数/变量一般需要在程序中按规则提供唯一的定义。头文件若被多个源文件包含,里面随意定义普通外部函数或全局变量,可能触发重复定义。
// config.hpp
extern int global_count; // 声明
inline constexpr int default_limit = 100; // C++17 内联变量
// config.cpp
int global_count = 0; // 定义
模板、inline 函数/变量等允许在满足 ODR 条件时出现在多个翻译单元中;通常要求相同定义及一致的相关名称查找。宏导致不同源文件看到不同类定义,会产生难查的 ODR 问题,链接器也未必诊断。
inline 的主要语言意义不是强迫优化器内联调用。优化器可内联没有该关键字的函数,也可不内联带该关键字的函数。
15.3 前置声明与完整类型
class Player;
void inspect(const Player& player); // 声明不需要 Player 的完整定义
通常声明指针/引用可以只知道类型存在;按值定义对象、访问成员、计算 sizeof 等需要完整定义。前置声明有助于减少依赖,但不要随意为标准库类型自己写声明,应包含相应标准头文件。
模板实现通常放头文件是因为调用方实例化时需要定义;也可以在 .cpp 里配合显式实例化,只支持预先列出的类型。看到 .tpp/.inl,常是被头文件包含的实现片段,不一定独立编译。
15.4 static 在不同位置含义不同
| 位置 | 常见含义 |
|---|---|
函数内 static T x |
跨调用保留的对象;若需要动态初始化,则首次控制流经过声明时执行 |
命名空间作用域 static 函数/变量 |
内部链接,通常只在本翻译单元可见 |
类内 static 数据成员 |
不属于某一个实例,类共享 |
类内 static 成员函数 |
无 this 指针 |
函数内静态对象的动态初始化从 C++11 起有相应线程安全保证,但初始化完成后的普通读写仍需自行同步。零初始化和常量初始化属于静态初始化,不应一概理解成“首次调用函数时才发生”。跨翻译单元的全局动态初始化顺序可能造成问题,不能依赖源码文件的排列顺序。
匿名命名空间 namespace { ... } 常用于 .cpp 的内部实现名;头文件中的匿名命名空间会给每个翻译单元产生各自的实体,需理解后果。
15.5 宏与条件编译
#if defined(_WIN32)
// Windows-specific declarations
#endif
宏是预处理文本机制,不遵循普通函数的类型检查和作用域规则。例如 #define SQUARE(x) ((x) * (x)) 对 SQUARE(i++) 重复展开参数,并可能触发未定义行为。现代代码优先用函数、模板、constexpr 常量,但宏仍用于平台适配、导出符号、生成代码和诊断。
项目中的 API、EXPORT、UCLASS、Q_OBJECT 等不能仅靠标准 C++ 解释。要查宏定义、构建参数和代码生成器;其中一部分来自特定框架。
15.6 modules[C++20]与 import std[C++23]
// 准确构建命令依赖编译器与构建系统;此处仅展示语法
export module demo.math;
export int add(int a, int b) { return a + b; }
使用方可写 import demo.math;。模块改善接口组织并减少传统文本包含带来的部分问题,但不会取消类型检查、链接和二进制兼容约束。
import std; 是 C++23 的标准库模块方向,与 #include <vector> 并非可在任意工具链中直接互换。模块扫描、BMI/IFC、编译器版本和构建系统必须配套;它们也不是类似 .NET assembly 的通用可移植二进制包。MSVC 的逐项支持表 与 Clang 支持表 可用于核对具体实现。
16. 并发、原子与协程
16.1 线程对象自身也有生命周期
std::thread worker([] { do_work(); }); // C++11,需 <thread>
worker.join();
仍处于 joinable 状态的 std::thread 被析构会导致 std::terminate,不是自动等待。C++20 的 std::jthread 在适用情况下析构会请求停止并 join,但停止是合作式的:函数必须实际响应 stop token,阻塞调用也不一定因停止请求而解除。
std::jthread worker([](std::stop_token token) {
while (!token.stop_requested()) {
do_one_unit();
}
});
析构等待可能阻塞。线程捕获外部对象引用时,要安排销毁顺序,保证线程真正停止后才释放其依赖;随意 detach() 会失去这种直接的生命周期管理。
16.2 mutex 和锁对象
std::mutex mutex;
int count = 0;
void increment() {
std::lock_guard<std::mutex> lock(mutex);
++count;
} // 自动解锁
lock_guard 适合单作用域持锁;unique_lock 可解锁/重新加锁并用于条件变量;scoped_lock[17]适合同时获取多个锁。线程共享的普通数据若存在未同步的冲突访问,且至少一方写入,可能构成数据竞争并导致 UB。
条件变量要结合谓词循环等待,处理虚假唤醒及条件再次变化:
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [&] { return ready; });
16.3 atomic 不是给整个算法自动加锁
std::atomic<int> count{0};
count.fetch_add(1, std::memory_order_relaxed);
这里保证单次原子更新,relaxed 不发布其他普通内存写入。默认顺序一致性更容易推理,但多个原子操作组合起来仍不一定是一个不可分割事务。
acquire/release 用于建立线程间的可见性和 happens-before 关系,只有正确配对等条件满足才成立;不了解算法证明时不要为性能随意降低内存序。
volatile 不等于 C# 中 volatile 的同步语义,也不替代 C++ atomic 或 mutex。它主要用于特定外部可观察访问等场合。
16.4 future、async 与协程不是同一层
future/promise[11]传递稍后完成的结果。std::async的默认策略允许实现选择异步或延迟执行;不能默认认为一定新开线程。- C++20 协程提供
co_await / co_yield / co_return语言机制;不会自动给你线程池、调度器或一个与 C#Task完全相同的标准类型。 - C++23
std::generator面向同步惰性生成,不是通用异步任务系统。
// 示意:Task、read_async 来自项目或第三方库,不是 C++20 标准库默认提供
Task<int> load() {
auto data = co_await read_async();
co_return data.size();
}
读协程项目先找返回类型的 promise_type、awaitable/awaiter、调度器以及取消策略。检查跨挂起点保存的引用和 this:协程状态存在,不代表它引用的对象也存在。co_await 不必总挂起,也不必切换线程。
17. 内存布局、未定义行为与底层边界
17.1 对象占用的内存可能多于成员大小之和
struct Record {
char kind;
int value;
};
由于对齐和填充,sizeof(Record) 不一定等于 sizeof(char) + sizeof(int)。alignof(T) 查询对齐要求,alignas 指定对齐。含虚函数的类通常有虚表相关实现成本,但具体布局由 ABI 决定,不能把某种编译器布局当作标准保证。
sizeof(pointer) 只是指针自身大小,不是指向的数组大小。sizeof 大多数使用场景不会执行表达式,但不要用它猜测字符串文本长度或动态分配长度。
17.2 四种显式转换
| 转换 | 意图 | 限制 |
|---|---|---|
static_cast |
已知规则的显式转换 | 不做通用运行时类型或范围检查 |
dynamic_cast |
对多态类型进行受规则约束的运行时类型转换 | 依赖 RTTI 等条件;失败时指针形式为空,引用形式抛 bad_cast |
const_cast |
调整 const/volatile 限定 | 若原对象确实是 const,强行写入是 UB |
reinterpret_cast |
低层表示/指针解释转换 | 不能免除对齐、生命周期和别名规则 |
C 风格 (T)value 可能隐藏多种转换语义,现代代码中优先识别并使用意图明确的命名转换。std::bit_cast[20]在满足大小相同、可平凡复制等条件时进行位表示转换;不是任意对象序列化工具。
17.3 new/delete 与 malloc/free
new T(args) 通常分配存储并构造 T;delete 调用析构并释放存储。new[] 对应 delete[]。malloc/free 不负责调用 C++ 构造/析构;两组分配释放协议不能混用。
placement new 在指定存储上构造对象,是内存池等底层设施常用技术;其销毁、重新使用存储与指针有效性有额外规则。业务代码应优先依赖成熟的拥有型容器或资源包装器。
对 std::string、vector 等非平凡对象做 memcpy 不能实现正确复制;对任意对象 memset(0) 也不能假定等价于构造。只有满足相应对象模型要求的场景才可按字节操作。
17.4 高频 UB 与误判
| 错误 | 为什么危险 |
|---|---|
| 数组/容器越界 | 访问不属于有效元素的存储 |
| 解引用悬空指针或引用 | 目标生命周期已经结束 |
| 重复 delete、分配释放不匹配 | 破坏分配器和对象管理协议 |
| 有符号整数溢出 | 优化器可假定正常执行不发生这种情况 |
| 未同步数据竞争 | C++ 内存模型不允许该类访问 |
| 返回局部变量引用 | 返回后目标已经销毁 |
| 读取未初始化的普通局部 int | 在本文 C++20 主线下属于典型 UB;C++26 部分情形另有 erroneous behavior 规则,但仍是错误代码 |
| 违反别名/对齐/对象生命周期规则 | 有地址不等于在那里存在一个可合法访问的 T |
UB 不等于“一定崩溃”,也不等于“编译器必须报警”。Debug 正常而 Release 出错,经常是程序错误被优化暴露,不应先怪优化器。
还需区分:implementation-defined 是实现需说明的选择;unspecified 是标准允许多个结果且不要求每次固定选择;ill-formed 是程序不满足语言规则,其中某些跨翻译单元问题不要求诊断。不要把所有平台差异都叫 UB。
17.5 二进制边界与 C 接口
extern "C" int plugin_version();
这指定 C 语言链接,用于与 C ABI 或插件约定配合;不是把函数体按 C 语言重新编译,也不自动解决结构体布局、调用约定、异常跨边界或内存由谁释放的问题。
跨 DLL、编译器或语言边界时,明确创建/销毁配对、字符串编码、结构体布局、异常策略和 ABI 版本。不同编译器、运行库、Debug/Release 设置生成的 C++ 库不应想当然混用。
18. 构建、依赖、调试与测试
18.1 编译器、标准库、构建系统、IDE 各管什么
| 部件 | 常见选择 | 作用 |
|---|---|---|
| 编译器前端/工具链 | MSVC、GCC、Clang | 语法、类型、代码生成、相关链接工具 |
| 标准库实现 | MSVC STL、libstdc++、libc++ | vector、format、ranges 等 |
| 构建描述/生成器 | CMake、Meson 等 | 描述目标依赖并生成构建规则 |
| 实际构建后端 | Ninja、Make、MSBuild | 执行编译链接任务 |
| 编辑器/IDE | Visual Studio、VS Code、CLion | 编辑、索引、调试和构建集成 |
| 包管理 | vcpkg、Conan 等 | 获取、配置和构建第三方依赖 |
安装 VS Code 的 C++ 扩展不等于安装编译器。Clang 也可能使用 libstdc++ 或 MSVC STL,不能只根据编译器名字判断库特性。
18.2 阅读 CMake 的核心:target
cmake_minimum_required(VERSION 3.16)
project(Demo LANGUAGES CXX)
add_library(core src/core.cpp)
target_include_directories(core PUBLIC include)
target_compile_features(core PUBLIC cxx_std_17)
set_target_properties(core PROPERTIES CXX_EXTENSIONS OFF)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE core)
add_library/add_executable定义构建目标。target_include_directories决定头文件查找路径;不是链接库。target_link_libraries描述链接依赖,并可传递依赖目标的使用要求。target_compile_features(... cxx_std_17)要求至少相应标准模式,不保证每个标准库功能都存在。PRIVATE用于该目标本身;INTERFACE用于调用方;PUBLIC兼具两者。
例如公共头文件含 C++20 语法,应把需要的语言级别传给包含它的调用方。接口传播规则见 CMake 官方 target_compile_features 文档。
cmake -S . -B build
cmake --build build --config Debug
多配置生成器通常在构建时用 --config Debug/Release;单配置生成器通常在配置时用 -DCMAKE_BUILD_TYPE=Debug。不要默认认为两者配置方式和输出目录完全一致。
已有 CMakePresets.json 时,优先阅读项目提供的 preset,它可能固定工具链、triplet、依赖路径和选项。未了解前不要直接覆盖项目的 CMake 配置。
18.3 静态库和动态库
静态库通常是目标代码集合,链接时选择需要的部分;动态库在运行时加载。Windows 常见 .lib/.dll,Linux 常见 .a/.so,macOS 常见 .a/.dylib。Windows 的 .lib 既可能是静态库,也可能是 DLL 的导入库。
“编译成功但启动缺 DLL”属于运行时加载问题;“链接时找不到函数”属于符号解析问题。加入 include 路径不能修复缺少库文件或缺少导出符号。
18.4 调试顺序
- 看第一条与自己代码直接相关的编译错误,后面很多错误可能只是级联。
- 查看完整编译命令:标准版本、包含路径、宏、架构、编译器是否与预期一致。
- 链接错误检查目标源文件是否纳入构建、命名空间与签名是否一致、依赖是否链接。
- 运行错误从调用栈和对象生命周期查起,记录输入与触发操作。
- 怀疑悬空/越界时启用可用的 AddressSanitizer;怀疑 UB 或竞争时按工具链选择 UBSan/TSan。
Sanitizer 的平台与工具链支持不同,尤其 Windows 不应复制所有 Linux 参数就认定可用。它们能发现很多实际执行路径的问题,不能证明没有错误。
常见 GCC/Clang 编译诊断参数是 -Wall -Wextra -Wpedantic;MSVC 常见 /W4 /permissive-。调试构建通常保留符号并降低优化;不要因“能编译”忽略悬空返回、转换或重排初始化警告。
18.5 阅读测试与工具配置
Catch2、GoogleTest、doctest 等属于第三方测试框架;CTest 是测试运行组织工具之一,不是它们的同义词。阅读测试先看 Arrange/Act/Assert:构造什么对象、调用什么接口、预期什么状态。
compile_commands.json 记录每个源文件的编译命令,可帮助 clangd/clang-tidy 使用真实宏和头文件路径。IDE 的红线不一定代表真实构建失败,先确认索引配置是否与构建一致。
版本文档提供 Windows 命令和本机 GCC 8.1.0 的限制;本资料没有替你升级或安装系统工具链。
19. 从零阅读一个陌生仓库
19.1 第一轮:先回答五个工程问题
- 产出是什么:可执行程序、静态库、动态库、插件还是头文件库?
- 标准和工具链是什么:C++17/20/23,哪种编译器、哪种标准库?
- 入口在哪里:main、平台入口、插件注册、测试入口或框架回调?
- 外部依赖是什么:网络、图形、序列化、引擎、GUI、任务系统?
- 哪个最小示例或测试能体现最典型的调用方式?
优先看 README、构建文件、依赖清单、CI,再看 examples/tests 和公共头文件。头文件库可能完全没有对应 .cpp,不要为此误判“实现丢了”。
19.2 第二轮:追一条数据流,不要顺序通读所有文件
选择一个具体行为,例如“加载配置并创建任务”:
输入来源 → 解析函数 → 数据结构 → 校验/转换 → 业务对象 → 输出或副作用
每一步记下输入输出类型、所有权、可能失败的方式和调用线程。这样能把宏、模板与容器放回具体业务上下文。
19.3 第三轮:给核心对象画生命周期关系
Application
└─ unique_ptr<Service> Application 拥有 Service
├─ vector<Job> Service 按值拥有 Job
└─ Logger& Service 借用 Logger,Logger 必须活得足够久
尤其检查:返回成员引用、缓存 view、回调 this、跨线程借用、容器扩容、引用计数环。对象关系图比背 API 列表更有助于发现危险。
19.4 第四轮:识别框架层与标准层
| 看到的名字 | 如何判断 |
|---|---|
std::vector |
标准库;仍需查项目标准版本 |
boost::...、fmt::... |
第三方库 |
QObject、signals、slots |
Qt 体系,需要查其对象与元对象规则 |
UCLASS、UPROPERTY、TArray |
常见于 Unreal,需理解生成代码与引擎对象管理 |
Task<T>、Result<T> |
可能是项目自定义,不能仅凭名字推断行为 |
HANDLE、HRESULT |
Windows API 类型与约定 |
框架可能在 C++ 上建立自己的反射、GC、智能指针或协程层。先掌握本文的标准对象模型,再查框架对哪些对象增加了规则。
19.5 适合开局的搜索
在仓库根目录执行这些只读搜索;结果很多时限制到 src/include:
rg --files -g 'CMakeLists.txt' -g 'CMakePresets.json' -g '*.vcxproj' -g 'README*'
rg -n 'CXX_STANDARD|cxx_std_|/std:|-std=' .
rg -n 'int main|wWinMain|WinMain' src examples tests
rg -n 'unique_ptr|shared_ptr|weak_ptr|string_view|span<' include src
rg -n 'TODO|FIXME|throw |noexcept|co_await' include src
目录不存在时调整参数;搜索模式只是帮助定位,不是语法解析器。宏入口、模板入口、框架生成入口可能需要 IDE 的符号导航或实际预处理结果。
19.6 遇到复杂模板的止损方法
先确认最终调用签名、约束与返回类型;然后用具体类型代入模板参数,折叠掉与当前类型无关的分支。若只是调用成熟库,不必为了理解一次 push_back 就读完分配器实现。只有行为、性能或错误要求你深入时,再进入实现细节。
20. 配套项目逐层解读
配套项目是任务统计小程序,不涉及 GUI 或外部依赖。源码分布:
examples/project-tour/
CMakeLists.txt
include/tour/task.hpp
include/tour/task_store.hpp
src/task_store.cpp
src/main.cpp
阅读顺序如下:
CMakeLists.txt:识别 task_core 库、project_tour 可执行目标、C++17 要求和公开 include 路径。task.hpp:Task 是普通值类型,成员std::string自己管理文本,复制 Task 会复制标题值。task_store.hpp:TaskStore 按值拥有 vector;返回const Task*的 find 是可空借用;快照函数按值返回 vector。task_store.cpp:构造函数把名称移入成员;add 把值移入 vector;find 用 find_if;open_tasks 返回独立快照。main.cpp:Logger 是纯虚接口,ConsoleLogger 是实现;TaskStore 借用 Logger;main 的声明顺序保证它活到 TaskStore 之后。
特别注意 TaskStore::find 返回的指针:后续 add 可能使 vector 重分配,因此示例不跨 add 保存这个指针。与之相对,快照的 Task 对象是独立值,可在 store 后续修改时继续使用。
从 std::make_unique<ConsoleLogger>() 到 store 结束作用域,可以完整追踪:拥有 Logger → 借用 Logger → 调用虚函数 → 释放 TaskStore 的成员 → 最后释放 Logger。这条生命周期链正是项目阅读的基本能力。
21. C# → C++ 速查表
| C# 概念/习惯 | C++ 对应或常见写法 | 不可忽略的差异 |
|---|---|---|
var |
auto |
auto 默认不保留引用/顶层 const |
class / struct |
class / struct |
C++ 不以这两个关键字区分引用语义和值语义 |
| 对象引用 | 指针、引用、智能指针等 | 必须分别考虑所有权和生命周期 |
null |
nullptr |
主要针对指针,不是通用可空对象 |
new |
T{}、make_unique/make_shared 或 new |
普通对象定义不必使用 new |
| GC | RAII 和确定性析构 | 不可依赖自动发现引用环或回收任意裸 new |
using 语句 |
作用域资源对象 | 析构不同于托管终结器 |
using Namespace |
using 声明/指令 | 不负责提供声明;include/import 是另一个问题 |
readonly |
常可用 const 表达部分意图 | const 的对象和访问路径语义不同 |
const 编译期常量 |
constexpr 常用于此用途 |
C++ const 本身未必是编译期常量 |
List<T> |
std::vector<T> |
容器操作可使引用/指针/迭代器失效 |
Dictionary<K,V> |
unordered_map 或 map |
前者无序,后者按比较器排序;[] 可插入 |
string |
std::string / 其他文本类型 |
默认没有固定 Unicode 编码语义 |
Span<T> |
std::span<T> |
都是视图,但 C++ 不提供相同的托管生命周期限制 |
Nullable<T> |
std::optional<T> |
optional 通常包含一个 T 的存储,不只是空指针 |
interface |
纯虚抽象基类 | 多继承、虚析构、对象布局需额外理解 |
where T : ... |
Concepts / requires | 编译期表达式约束更广,不是 CLR 泛型约束 |
| 委托/事件 | Lambda、函数对象、std::function、框架信号 | 捕获和退订可能直接影响内存安全 |
| LINQ | 算法和 ranges/views | 惰性求值与借用可能导致悬空 |
| async/await、Task | 协程 + 库的 Task/调度器 | C++20 不内建完整 C# 式异步运行时 |
| reflection | 框架反射;C++26 编译期反射方向 | 不等同于 CLR 运行时元数据系统 |
| assembly/NuGet | 目标库 + 构建系统 + 包管理器 | ABI、工具链和编译选项兼容更重要 |
lock |
mutex + lock_guard/scoped_lock | 锁和被保护对象的生命周期由程序负责 |
checked/unchecked |
显式范围检查、合适数值类型 | C++ 有符号溢出不能当作普遍保证的回绕 |
22. 高频陌生写法与报错词典
22.1 看到这些写法,先这样解读
| 写法 | 第一层解释 |
|---|---|
using Name = SomeType; |
类型别名,不创建新类型 |
using Base::foo; |
引入基类的 foo 重载集,避免被派生成员隐藏 |
auto [a, b] = value;[17] |
结构化绑定,可能复制;auto& 才明确借用 |
if (auto x = f(); x)[17] |
初始化语句 + 条件,x 的作用域包括相关分支 |
auto f() -> T |
尾置返回类型 |
T::type、T::value_type |
嵌套类型,模板中可能要 typename |
T::value、trait_v<T> |
常见编译期属性值 |
operator bool() const |
状态转换操作,常用于 if 判断 |
operator()(...) |
让对象像函数一样调用 |
f() & / f() && |
成员函数引用限定,根据调用对象的值类别选择 |
const char* const* |
指向 const 指针的指针,指针最终指向 const char |
std::exchange(x, new_value)[14] |
给 x 赋新值并返回旧值 |
std::move(x) |
允许后续操作把 x 当可转移资源的表达式 |
std::forward<T>(x) |
在泛型转发中保留原实参值类别 |
std::as_const(x)[17] |
得到对 x 的 const 引用,常用于选择只读重载 |
std::reference_wrapper<T> |
可复制的引用包装;仍不拥有 T |
std::pmr::vector<T>[17] |
使用多态内存资源的容器别名,检查资源寿命 |
std::in_place、std::piecewise_construct |
控制包装/组合对象如何原地构造 |
[[likely]] / [[unlikely]][20] |
分支概率提示,不改变逻辑正确性要求 |
22.2 报错按层分类
| 报错或现象 | 优先检查 |
|---|---|
No such file or directory / cannot open include file |
头文件实际位置、include 路径、依赖是否获取/生成 |
not declared in this scope |
声明可见性、命名空间、条件编译、拼写 |
incomplete type |
只有前置声明,却执行了需要完整定义的操作 |
no matching function |
参数类型、const、引用绑定、模板约束 |
use of deleted function |
unique_ptr 等不可复制成员、被禁止的操作、特殊成员生成规则 |
discards qualifiers |
试图通过 const 对象调用非 const 接口或丢弃 const |
cannot bind ... reference |
把临时对象绑定给非 const 左值引用等值类别问题 |
constraints not satisfied |
Concept 中哪一个表达式不成立 |
| 很长的模板实例化堆栈 | 先看最底层实际失败,再回到自己的调用位置和实参 |
undefined reference / LNK2019 |
缺定义、漏编译源文件、漏链接库、签名或 ABI 不一致 |
multiple definition / LNK2005 |
头文件定义普通外部符号、重复纳入源文件、ODR 问题 |
| Debug 正常、Release 异常 | 未初始化、越界、悬空、数据竞争等 UB,以及断言副作用 |
| 启动缺 DLL | 运行时依赖搜索路径、架构、运行库匹配 |
23. 阅读能力检查与继续深入的方向
23.1 能否用自己的话回答这些问题
这不是要求额外做练习,而是读完后判断哪些章节还需要回看:
- 为什么
auto b = a有时复制对象,有时共享资源?——看 a 的类型及其复制语义,第 1、7、10 章。 - 为什么
const T&不等于“永远安全的只读对象”?——它只约束访问路径,也可能悬空,第 4、5 章。 std::move后为什么不一定变空?——转换与实际移动分离,类型有各自保证,第 8 章。- 为什么
reserve(100)后仍不能访问v[0]?——capacity 不等于 size,第 11 章。 - view 的长度正确,为什么仍会崩溃?——底层数据可能已销毁或失效,第 10—12 章。
- 为什么 include 了头文件仍然链接失败?——声明不等于定义已链接,第 2、15、18 章。
- 为什么 C++20 模式不能保证
<format>存在?——语言与标准库实现独立演进,版本文档。 - 为什么
co_await不必产生一个新线程?——它是挂起机制,调度由具体库决定,第 16 章。 - 为什么 template 实现经常放头文件?——实例化通常需要定义可见,第 14、15 章。
- 如何判断一个裸指针可不可以 delete?——追踪所有权和获取/释放协议,不凭指针类型猜,第 5、10、17 章。
23.2 第一阶段不必深入实现的主题
自定义 allocator、无锁算法证明、表达式模板库内部、标准级名称查找细节、自定义 coroutine promise、ABI 兼容设计、复杂多重继承布局、编译器优化理论。它们重要,但不能替代先把普通代码的数据流和生命周期看清楚。
当项目确实用到这些技术,再带着具体问题查阅:它解决什么限制,公开接口承诺什么,调用方承担什么责任,哪些行为由标准保证,哪些只是特定实现。
23.3 建议保留的阅读笔记模板
项目 / 模块:
语言标准 / 编译器 / 标准库:
构建目标与入口:
一条典型调用链:
核心数据类型:
所有者与借用关系:
资源释放时机:
错误返回 / 异常边界:
线程 / 调度约束:
依赖的框架特性:
仍需核实的问题:
能据此解释配套项目,再把同样方法应用到陌生仓库,就已经具备实用的 C++ 项目阅读起点。语法查阅会越来越快,但生命周期、边界和版本核对仍应保留。