约 97 分钟技术学习

从 C# 到 C++:以读懂真实项目为目标

从 C# 到 C++:以读懂真实项目为目标

面向有 C# 基础的读者。主线为现代 C++,默认讨论 C++20,新增特性注明版本。

本文中的短代码用于解释局部语义,可能省略头文件、外围类或函数;请勿将所有代码块拼接成一个程序。

目录

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 先掌握六个观察维度

读到一个变量或表达式,依次问:

  1. 静态类型是什么?是否含 const、指针或引用?
  2. 这是对象本身,还是借用另一个对象?
  3. 谁拥有资源,谁会释放?
  4. 什么时候失效,容器操作是否使它失效?
  5. 这里发生复制、移动,还是只建立别名?
  6. 这段代码在哪个翻译单元编译,需要什么库和标准版本?

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 的静态类型抛一个新对象,可能切片。

常见异常保证:

  1. 不抛保证:操作不会向外传播异常。
  2. 强保证:失败时可观察状态保持原样。
  3. 基本保证:失败后不泄漏资源、对象不变量仍成立,但状态可能改变。

析构函数通常应不抛异常;异常栈展开期间又抛出异常可能导致终止。不要把析构当作主要错误报告通道。

部分游戏、嵌入式或基础设施项目禁用异常/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 调试顺序

  1. 看第一条与自己代码直接相关的编译错误,后面很多错误可能只是级联。
  2. 查看完整编译命令:标准版本、包含路径、宏、架构、编译器是否与预期一致。
  3. 链接错误检查目标源文件是否纳入构建、命名空间与签名是否一致、依赖是否链接。
  4. 运行错误从调用栈和对象生命周期查起,记录输入与触发操作。
  5. 怀疑悬空/越界时启用可用的 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 第一轮:先回答五个工程问题

  1. 产出是什么:可执行程序、静态库、动态库、插件还是头文件库?
  2. 标准和工具链是什么:C++17/20/23,哪种编译器、哪种标准库?
  3. 入口在哪里:main、平台入口、插件注册、测试入口或框架回调?
  4. 外部依赖是什么:网络、图形、序列化、引擎、GUI、任务系统?
  5. 哪个最小示例或测试能体现最典型的调用方式?

优先看 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

阅读顺序如下:

  1. CMakeLists.txt:识别 task_core 库、project_tour 可执行目标、C++17 要求和公开 include 路径。
  2. task.hpp:Task 是普通值类型,成员 std::string 自己管理文本,复制 Task 会复制标题值。
  3. task_store.hpp:TaskStore 按值拥有 vector;返回 const Task* 的 find 是可空借用;快照函数按值返回 vector。
  4. task_store.cpp:构造函数把名称移入成员;add 把值移入 vector;find 用 find_if;open_tasks 返回独立快照。
  5. 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 能否用自己的话回答这些问题

这不是要求额外做练习,而是读完后判断哪些章节还需要回看:

  1. 为什么 auto b = a 有时复制对象,有时共享资源?——看 a 的类型及其复制语义,第 1、7、10 章。
  2. 为什么 const T& 不等于“永远安全的只读对象”?——它只约束访问路径,也可能悬空,第 4、5 章。
  3. std::move 后为什么不一定变空?——转换与实际移动分离,类型有各自保证,第 8 章。
  4. 为什么 reserve(100) 后仍不能访问 v[0]?——capacity 不等于 size,第 11 章。
  5. view 的长度正确,为什么仍会崩溃?——底层数据可能已销毁或失效,第 10—12 章。
  6. 为什么 include 了头文件仍然链接失败?——声明不等于定义已链接,第 2、15、18 章。
  7. 为什么 C++20 模式不能保证 <format> 存在?——语言与标准库实现独立演进,版本文档。
  8. 为什么 co_await 不必产生一个新线程?——它是挂起机制,调度由具体库决定,第 16 章。
  9. 为什么 template 实现经常放头文件?——实例化通常需要定义可见,第 14、15 章。
  10. 如何判断一个裸指针可不可以 delete?——追踪所有权和获取/释放协议,不凭指针类型猜,第 5、10、17 章。

23.2 第一阶段不必深入实现的主题

自定义 allocator、无锁算法证明、表达式模板库内部、标准级名称查找细节、自定义 coroutine promise、ABI 兼容设计、复杂多重继承布局、编译器优化理论。它们重要,但不能替代先把普通代码的数据流和生命周期看清楚。

当项目确实用到这些技术,再带着具体问题查阅:它解决什么限制,公开接口承诺什么,调用方承担什么责任,哪些行为由标准保证,哪些只是特定实现。

23.3 建议保留的阅读笔记模板

项目 / 模块:
语言标准 / 编译器 / 标准库:
构建目标与入口:
一条典型调用链:
核心数据类型:
所有者与借用关系:
资源释放时机:
错误返回 / 异常边界:
线程 / 调度约束:
依赖的框架特性:
仍需核实的问题:

能据此解释配套项目,再把同样方法应用到陌生仓库,就已经具备实用的 C++ 项目阅读起点。语法查阅会越来越快,但生命周期、边界和版本核对仍应保留。