翻译单元与预处理
# 48.翻译单元与预处理
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 翻译单元的定义与边界
- 4. #include 的完整机制
- 5. 宏的展开规则——预处理器的完整语义
- 6. 条件编译——#if / #ifdef / #elif 的完整体系
- 7. 预编译头(PCH)——以空间换编译时间
- 8. Unity Build——把多个 .cpp 拼成一个 TU
- 9. include-what-you-use(IWYU)——精准包含的工程实践
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 宏的嵌套展开炸弹——42 行变成 17000 行
某嵌入式框架用宏做驱动注册。一个看似简单的 REGISTER_DRIVER(UART) 展开后让单个 TU 膨胀到 17000 行——编译时间从 0.2s 跳到 3.8s:
// ====== 事故代码 V1:宏嵌套爆炸 ======
#define CONCAT_IMPL(a, b) a ## b
#define CONCAT(a, b) CONCAT_IMPL(a, b)
#define REGISTER_DRIVER_IMPL(name, id) \
static Driver CONCAT(drv_, name) { \
#name, id, \
&CONCAT(name, _init), \
&CONCAT(name, _read), \
&CONCAT(name, _write), \
}
#define REGISTER_DRIVER(name) \
REGISTER_DRIVER_IMPL(name, __COUNTER__)
// 42 个驱动注册:
REGISTER_DRIVER(UART)
REGISTER_DRIVER(SPI)
REGISTER_DRIVER(I2C)
// ... 39 more
// 每个 REGISTER_DRIVER → 展开后约 400 行(包含 Driver 构造函数的全部模板实例化)
// 42 个 × 400 = 16800 行 → 编译器看到的是 16800 行的「纯文本」
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
根因:宏在预处理阶段展开——编译器真正的输入不是 42 行,而是 16800 行。C++ 中的宏是纯文本替换——没有任何语义检查、没有任何类型收缩。展开后编译器需要逐字符处理这 16800 行——包括所有的模板实例化和函数签名。
真相还原——g++ -E 查看预处理输出:
$ g++ -E driver_registry.cpp | wc -l
16823
2
这 16823 行在编译器后续阶段(词法分析→语法分析→语义分析→代码生成)都要逐行走一遍。预处理器是第一个耗时的关卡。
# 1.2 include 顺序依赖——换个顺序就编译失败
同一框架的某个头文件在不同的 .cpp 中编译——有的通过、有的失败。排查了一天发现是 include 顺序不同:
// a.cpp —— 编译通过
#include "config.h" // config.h 里 #define ENABLE_FEATURE_X
#include "feature_x.h" // feature_x.h 里 #ifdef ENABLE_FEATURE_X → 启用特性
// b.cpp —— 编译失败!
#include "feature_x.h" // 先 include feature_x → ENABLE_FEATURE_X 未定义 → 特性被禁用
#include "config.h" // 后 include config → 定义了 ENABLE_FEATURE_X,但已经晚了
#include <optional> // feature_x.h 用了 std::optional——但 feature_x.h 没有 #include <optional>!
// a.cpp 不报错是因为 config.h 里代理包含了 <optional>
2
3
4
5
6
7
8
9
根因:
- 宏的依赖顺序:
feature_x.h的行为依赖于config.h中的宏定义——但没有通过 include 声明这个依赖 - 隐式头文件依赖:
feature_x.h用了std::optional但没有#include <optional>——而是借了config.h的 include 链
# 1.3 七个待解疑问
① 翻译单元到底是什么?一个 .cpp 加上展开后的所有 .h 就是整个 TU? → 第 3 章
② #include 的尖括号和双引号搜索路径有什么区别?系统路径和用户路径怎么定? → 第 4 章
③ 宏的展开是递归的吗?为什么宏不能递归调用自己?# 和 ## 怎么用? → 第 5 章
④ #if 和 #ifdef 的区别是什么?#if 能做哪些表达式求值、有哪些限制? → 第 6 章
⑤ 预编译头(PCH)到底是什么?怎么加速编译?失效怎么排查? → 第 7 章
⑥ Unity Build 把多个 .cpp 拼成一个 TU——为什么能加速?有什么陷阱? → 第 8 章
⑦ include-what-you-use 的原则是什么?为什么隐式依赖是定时炸弹? → 第 9 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 翻译单元的四个阶段——从字符到 AST 前的全部处理
原始 .cpp 文件 (200 行)
│
▼
┌──────────────────────────────────────────────┐
│ ① 字符映射 (Trigraph / Line Splicing) │
│ - 三字符组 ???= → # (C++17 已移除) │
│ - 行拼接: backslash-newline → 合并为一行 │
└──────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ ② 预处理 (Preprocessing) —— 本文主体 │
│ - #include 展开 (递归包含头文件) │
│ - #define 宏替换 │
│ - #if / #ifdef 条件编译 │
│ - #pragma 处理 │
│ - 注释移除 (替换为空格) │
│ │
│ 输出: 纯文本——所有的 # 指令被处理完 │
│ .cpp (200行) + 展开的 .h → TU (~20000行) │
└──────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ ③ 词法分析 (Lexing) │
│ - 将字符流切成 token (关键字/标识符/字面量)│
└──────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ ④ 语法分析 (Parsing) → AST → 后续编译 │
│ - 将 token 流构建为抽象语法树 │
└──────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
关键:预处理是纯文本操作——不涉及类型、不涉及符号表。预处理器只看到 token 前的文本——这和编译器后端完全隔离。
# 2.2 为何这么切
疑惑:为什么 C++ 不直接做语义处理,非要先过一趟文本替换?
论证——C 遗产 + 物理必然:
原因 1:C 遗产——C++ 继承了 C 的预处理器
#include / #define / #ifdef 是 C 时代的产物
C++ 为了兼容 C——保留了完整的预处理器
原因 2:头文件的根本逻辑就是文本拼接
#include 的本质:把另一个文件的内容复制到当前文件
这是文本操作——不涉及任何语义
原因 3:条件编译必须先于语法分析
#ifdef _WIN32 决定哪段代码被编译器看到
语法分析器不能看到「两段互斥的代码」同时存在
2
3
4
5
6
7
8
9
10
11
# 3. 翻译单元的定义与边界
# 3.1 一个 .cpp + 所有展开后的 .h = 一个 TU
每个 .cpp 文件独立编译为一个翻译单元 (TU):
main.cpp:
#include "a.h"
#include "b.h"
int main() { return a_func() + b_func(); }
预处理后(main.cpp 的 TU):
// a.h 的全部内容展开...
// b.h 的全部内容展开...
int main() { return a_func() + b_func(); }
// a.cpp 是另一个独立的 TU:
#include "a.h"
int a_func() { return 42; }
// 两个 TU 互相不知道对方的存在——直到链接器把它们合在一起
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
TU 边界的关键性质:
- 每个 TU 独立编译——a.cpp 的编译器不知道 b.cpp 的内容
- 命名空间不跨 TU——
static和匿名命名空间只在当前 TU 内有效 - 编译器在 TU 内做完整检查——TU 内所有使用的符号必须已声明
# 3.2 预处理后的 TU 长什么样——用 -E 看真相
# GCC: 只预处理,不编译、不汇编、不链接
g++ -E source.cpp -o source.ii
# Clang: 同上
clang++ -E source.cpp -o source.ii
# MSVC: /E 或 /P
cl /E source.cpp > source.i
2
3
4
5
6
7
8
典型输出——一个简单的 #include <iostream> 后的 TU:
$ echo '#include <iostream>' | g++ -E -xc++ - | wc -l
33198
2
3.3 万行的预处理输出——对于编译器后续的词法+语法+语义分析,这就是每个使用了 <iostream> 的 TU 的真实输入大小。
# 3.3 TU 之间的隔离——static / 匿名命名空间 / ODR 的根源
// a.cpp
static int counter = 0; // 只在 a.cpp 的 TU 内可见
namespace { int secret = 42; } // 同上
// b.cpp
static int counter = 0; // 合法的——这是另一个 counter
namespace { int secret = 100; } // 合法——不同的 TU 中的不同变量
2
3
4
5
6
7
TU 隔离是 ODR 规则的基础——ODR 只要求跨 TU 的符号唯一定义。TU 内的 static/匿名命名空间天然不受 ODR 约束。
# 4. #include 的完整机制
# 4.1 尖括号 vs 双引号——搜索路径的完整列表
#include <vector> // 尖括号——搜索系统/编译器路径
#include "my_header.h" // 双引号——先搜索当前目录,再搜索系统路径
2
完整搜索顺序(GCC/Clang):
#include "my_header.h":
① 当前源文件所在目录
② -I 指定的目录(按命令行顺序)
③ CPATH / C_INCLUDE_PATH / CPLUS_INCLUDE_PATH 环境变量
④ 系统标准路径:
/usr/include/c++/13/
/usr/include/x86_64-linux-gnu/c++/13/
/usr/include/c++/13/backward/
/usr/local/include/
/usr/include/
#include <vector>:
跳过 ①——直接到 ②→④
2
3
4
5
6
7
8
9
10
11
12
13
-I vs -isystem 的关键差异:-I 路径中的头文件会产生警告,-isystem 路径中的头文件不产生警告(和系统头文件一样处理)。
# 4.2 #pragma once vs 头文件守卫——为什么前者不能替代后者
// 传统守卫——跨所有编译器
#ifndef MY_HEADER_H
#define MY_HEADER_H
// ... header content ...
#endif
// #pragma once —— 更简洁,但非标准
#pragma once
// ... header content ...
2
3
4
5
6
7
8
9
为什么 #pragma once 不能完全替代 #ifndef 守卫:
① 非标准——虽然所有主流编译器都支持,但 C++ 标准没有规定
② 符号链接/硬链接陷阱:
/path/to/project/include/header.h
/path/to/build/symlink/header.h → 同一个文件的不同路径
→ 编译器可能认为这是两个不同的文件 → #pragma once 失效
③ 分布式文件系统的 inode 判断可能出错
最佳实践:二者结合——#pragma once 在前 + #ifndef 守卫在后
→ pragma 提供快速路径(编译器识别 pragma 后不再打开文件)
→ #ifndef 提供安全网(pragma 失效时兜底)
2
3
4
5
6
7
8
9
10
# 4.3 头文件的自给性——每个 .h 应该独立可编译
// ❌ 不自给的 header——必须由其他 header 先 include
// feature.h
class Feature {
std::string name_; // 用了 std::string——但没有 #include <string>!
// 依赖调用方先 include <string>
};
// ✅ 自给的 header
#include <string>
class Feature {
std::string name_;
};
2
3
4
5
6
7
8
9
10
11
12
验证自给性——让每个 .h 文件作为第一个 include 编译:
// feature_test.cpp
#include "feature.h" // 唯一 include——如果编译不过——feature.h 不自给
2
# 4.4 include 图与编译时间的指数关系
一个 .cpp 的编译时间 ∝ 展开后的 TU 行数
widget.cpp include 了:
widget.h (100行) → [include <string> (20000行), <vector> (18000行),
"config.h" (500行), "logger.h" (300行 → <mutex>(15000行))]
总展开:~54000 行
如果 200 个 .cpp 都 include 了 widget.h:
每个 .cpp 的预处理输出都包含完整的 widget.h 展开 → 54000 行
200 × 54000 = 1080 万行被编译器处理(大部分是重复的)
2
3
4
5
6
7
8
9
10
这就是为什么 header-only 的库在编译时间上昂贵——每个使用它的 .cpp 都要把整个库展开一遍。
# 5. 宏的展开规则——预处理器的完整语义
# 5.1 对象宏与函数宏——#define 的两种形态
#define BUFFER_SIZE 1024 // 对象宏——简单替换
#define MAX(a, b) ((a)>(b)?(a):(b)) // 函数宏——参数替换
2
函数宏的潜在陷阱——运算符优先级和多重求值:
// ❌ 危险的 MAX
#define MAX_BAD(a, b) a > b ? a : b // 没有括号
MAX_BAD(x & 0xFF, y) → x & 0xFF > y ? x & 0xFF : y
→ x & (0xFF > y ? x & 0xFF : y) // 运算符优先级错了!
// ❌ 多重求值
MAX(++x, y) → ((++x)>(y)?(++x):(y))
// ++x 可能被求值两次——副作用翻倍
// ✅ 现代 C++ 替代——模板(无副作用、类型安全)
template <typename T>
constexpr const T& max(const T& a, const T& b) { return a > b ? a : b; }
2
3
4
5
6
7
8
9
10
11
12
# 5.2 宏参数的 prescan——展开前的参数替换与蓝漆规则
疑惑:宏的参数在替换前还是替换后展开?
论证——三步规则:
宏展开三步:
① 参数 prescan:如果参数本身是宏——先展开参数
② 参数替换:把展开后的参数替换到宏体中
③ 整体再扫描:替换后对结果再次扫描——检查是否有新宏需要展开
实例:
#define FOO 42
#define BAR(x) x + 1
BAR(FOO) → ① FOO 展开为 42 → ② 替换:42 + 1 → ③ 扫描:无新宏 → 结果:42 + 1
蓝漆规则(blue paint):防止无限递归
在展开一个宏时,被展开的宏被「涂蓝」(标记为「正在展开」)
在同一个展开链中再次遇到这个宏——不再展开
2
3
4
5
6
7
8
9
10
11
12
13
14
# 5.3 # 与 ## 运算符——字符串化与粘贴的利器与陷阱
// # 运算符——字符串化
#define STRINGIFY(x) #x
STRINGIFY(hello) → "hello"
STRINGIFY(hello world) → "hello world"
STRINGIFY(42 + 1) → "42 + 1" // 表达式变成字面字符串
STRINGIFY(\n) → "\\n" // 转义字符也字符串化
// ## 运算符——token 粘贴
#define CONCAT(a, b) a ## b
int CONCAT(my, _var) = 42; → int my_var = 42;
2
3
4
5
6
7
8
9
10
11
12
## 的使用规则——两级宏:
// ❌ 直接 ## ——expanded 不被展开
#define BAD_CONCAT(a, b) a ## b
int BAD_CONCAT(FOO, BAR); // → int FOOBAR; ——FOO 没有被展开
// ✅ 两级宏——先展开参数,再拼接
#define CONCAT_IMPL(a, b) a ## b
#define CONCAT(a, b) CONCAT_IMPL(a, b)
int CONCAT(FOO, BAR); // → int 42BAR; ——FOO 先展开为 42
2
3
4
5
6
7
8
# 5.4 宏的嵌套展开与自引用终止
#define RECURSIVE RECURSIVE
RECURSIVE // → RECURSIVE(展开一次——检测到自引用,停止)
// 蓝漆规则:在展开 RECURSIVE 时,「RECURSIVE」这个名字被涂蓝
// → 在替换结果中再次扫描到 RECURSIVE——跳过
2
3
4
间接自引用——同样被终止:
#define A B
#define B A
A // → 展开 A→B → 展开 B→A(发现 A 已在展开中→停止)→ 结果:A
2
3
4
# 5.5 可变参数宏与 VA_ARGS——C++11/C++20 的三代演进
// C++11 基础——__VA_ARGS__ 为空时产生额外的逗号
#define LOG(fmt, ...) printf(fmt, __VA_ARGS__)
LOG("hello") // → printf("hello", ) // ❌ 多余逗号——编译错误
// C++20 __VA_OPT__ ——优雅的空参数处理
#define LOG(fmt, ...) printf(fmt __VA_OPT__(,) __VA_ARGS__)
LOG("hello") // → printf("hello") ✅
LOG("x=%d", 42) // → printf("x=%d", 42) ✅
// GCC 扩展——##__VA_ARGS__ (在 C++20 之前的最常用写法)
#define LOG(fmt, ...) printf(fmt, ##__VA_ARGS__)
2
3
4
5
6
7
8
9
10
11
# 6. 条件编译——#if / #ifdef / #elif 的完整体系
# 6.1 #if defined vs #ifdef 的细微差异
// 这两行等价——检查宏是否定义(不管值是什么)
#ifdef FOO
#if defined(FOO)
// #if defined 的优势——可以组合多个条件
#if defined(FOO) && defined(BAR) // ✅ 两个都定义
#if defined(FOO) || defined(BAR) // ✅ 至少一个定义
#if defined(FOO) && FOO > 10 // ✅ 定义且值大于 10
// #ifdef 做不到这些组合
2
3
4
5
6
7
8
9
10
# 6.2 #if 中的预处理器算术——表达式的类型与限制
#if 1 + 2 * 3 == 7 // ✅ 整数算术
#if 'A' == 65 // ✅ 字符字面量(转为整数值)
#if defined(FOO) && FOO // ✅ 宏的展开值参与比较
// 未定义的宏在 #if 中等价为 0
#if UNDEFINED_MACRO // → #if 0 → 跳过这段代码
2
3
4
5
6
预处理器算术的类型——intmax_t/uintmax_t(C++11 起),没有 short/long 区别。
# 6.3 常见误区——#if 不能比较字符串、不识别 sizeof、不识别 C++ 常量
// ❌ 不能比较字符串
#if NAME == "foo" // 永远为 true——指针比较,不是字符串比较
// ❌ 不能做 sizeof
#if sizeof(int) == 4 // 编译错误——预处理器不认得 sizeof
// ❌ 不能访问 C++ 常量
constexpr int VERSION = 2;
#if VERSION > 1 // 编译错误——constexpr 在预处理阶段不可见
// 正确做法:
#define VERSION 2
#if VERSION > 1 // ✅ 宏在预处理阶段可见
2
3
4
5
6
7
8
9
10
11
12
# 7. 预编译头(PCH)——以空间换编译时间
# 7.1 PCH 的工作原理——预处理结果的 dump 与快照恢复
普通编译:
.cpp → 预处理(500ms) → 编译(2000ms) → .o
PCH 编译:
① 首次编译 pch.h:
pch.h → 预处理 → 编译器内部状态 dump → pch.h.gch (~100MB)
这个 dump 包含:预处理后的 token 流 + 符号表 + 模板实例化缓存
② 后续编译依赖 pch 的 .cpp:
#include "pch.h" → 编译器识别到 .gch 文件存在
→ 跳过文件读取 + 预处理 + 词法分析 + 语法分析 + 符号表构建
→ 直接从 .gch 快照恢复编译器状态
→ 然后编译 .cpp 自身部分
2
3
4
5
6
7
8
9
10
11
12
13
关键:PCH 不是缓存编译结果——是以编译器内部状态的快照。恢复快照的速度远快于重新做预处理+语法分析。
# 7.2 GCC 的 .gch 与 MSVC 的 .pch——生成与使用
# GCC: 编译头文件
g++ -o stdafx.h.gch stdafx.h
# GCC 会自动查找与 #include "stdafx.h" 同目录的 stdafx.h.gch
# 如果有多个 .gch(不同编译选项)→ 编译器按选项匹配选择
# MSVC: /Yc 创建 PCH, /Yu 使用 PCH
cl /Ycstdafx.h source.cpp # 创建 PCH
cl /Yustdafx.h other.cpp # 使用 PCH
2
3
4
5
6
7
8
9
# 7.3 PCH 的反噬——PCH 失效的三种原因与隐性全量重编
PCH 失效的三种常见原因:
① 编译选项不匹配——PCH 和当前 TU 用了不同的 -O2/-D/-I
→ 编译器静默忽略 PCH → 全量重编 → 没有报错、没有警告——就是慢了
② PCH 头文件本身被修改
→ PCH 需要重新生成 → 所有依赖它的 TU 全部重编
③ PCH 包含了「太常用的变头」
→ wrapper.h 每改一次 → 整个项目重编
→ PCH 应该只包含极少变化的稳定头文件(如标准库、第三方 API)
2
3
4
5
6
7
8
9
10
11
# 8. Unity Build——把多个 .cpp 拼成一个 TU
# 8.1 原理——减少重复预处理 + 给链接器减负
Unity Build = 创建一个「大总管」.cpp,里面只用 #include 把多个 .cpp 串起来:
// unity_batch1.cpp
#include "a.cpp"
#include "b.cpp"
#include "c.cpp"
// ... 20 个 .cpp 并成一个 TU
传统编译:
a.cpp: 预处理<共同的.h> + 编译 a.cpp → a.o
b.cpp: 预处理<同一批.h> + 编译 b.cpp → b.o
c.cpp: 预处理<同一批.h> + 编译 c.cpp → c.o
开销: 3× 预处理 + 3× 编译
Unity Build:
unity_batch1.cpp: 预处理<共同的.h> + 编译 a+b+c → unity_batch1.o
开销: 1× 预处理 + 1× 编译(虽然编译时间更长——但预处理只做了一次)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 8.2 静态重名陷阱——匿名命名空间中的符号冲突
// a.cpp
namespace { struct Helper { int x; }; } // TU 内私有——OK
// b.cpp
namespace { struct Helper { double y; }; } // 另一个 TU 内私有——OK
// 但合并为 Unity Build 后——同一个 TU 内有两个匿名的 Helper!
// → 编译器选择第一个定义(ODR 违规——UB)
2
3
4
5
6
7
8
规避规则:Unity Build 中的所有 .cpp 的匿名命名空间符号名不可冲突。
# 8.3 适用场景——什么时候 Unity Build 有正向收益
| 场景 | 收益 | 风险 |
|---|---|---|
| CI 全量编译 | -30~50% 编译时间 | 低(全量重编——PCH 也失效) |
| 增量编译 | ❌ 无效(只改一个 .cpp——整个 unity 重编) | 高——比单独编译更慢 |
| 跨平台验证 | ✅ 所有 .cpp 一起编译——ODR 检查 | 符号冲突可能暴露 |
| Debug 构建 | -20~30% 时间 | 难定位错误到具体 .cpp |
# 9. include-what-you-use(IWYU)——精准包含的工程实践
# 9.1 原则:用到的符号必须有对应的 #include
IWYU 的核心规则:
① 使用任何函数/类型——必须直接 #include 声明它的头文件
② 不要依赖其他头文件代理包含——代理包含随时可能被重构移除
③ 不需要的不 include——减少 TU 膨胀
实例:
// ❌ 隐式依赖
#include "container.h" // container.h 内部 include 了 <string>
std::string name_; // 用了 string——但只 include 了 container.h
// ✅ 显式依赖
#include <string>
std::string name_;
2
3
4
5
6
7
8
9
10
11
12
13
14
# 9.2 常见反模式:隐式依赖——被别的头文件代理包含
重构隐形炸弹:
① module_a.h include <string> include <vector>
② user.cpp include "module_a.h"
使用 std::string 和 std::vector——不直接 include
③ 重构 module_a.h——不再需要 vector——移除 #include <vector>
④ user.cpp 编译失败——「std::vector not found」
但 module_a.h 改了——user.cpp 不应该受影响!
链式影响:
module_a.h → 20 个 .cpp 隐式依赖 std::vector(从 a.h 借)
→ 修改 a.h → 20 个 .cpp 编译失败——而它们和 a.h 没有逻辑关系
2
3
4
5
6
7
8
9
10
11
12
# 9.3 IWYU 工具的工作流程与限制
# 安装 (Clang 工具链)
apt install iwyu
# 分析一个文件
iwyu_tool.py -p build/ source.cpp
# 输出:
# source.cpp should add these lines:
# #include <memory>
#
# source.cpp should remove these lines:
# - #include "unused.h" // lines 3-3
2
3
4
5
6
7
8
9
10
11
12
IWYU 的限制:
- 基于 Clang 的 AST——必须有完整的编译数据库(
compile_commands.json) - 智能——但不完美——对模板的符号归属判断有误判率
- 需要人工审核——建议作为 CI 的警告而非硬错误
# 10. 综合案例串讲
# 10.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | TU 的边界? | 第 3 章:一个 .cpp = 一个 TU——预处理把所有 .h 展开为一个完整的文本文件 |
| ② | #include 搜索路径? | 第 4.1:引号=当前目录→-I →系统;尖括号=-I→系统 |
| ③ | 宏展开规则? | 第 5 章:prescan→替换→再扫描 + 蓝漆规则终止 + #/## 两级宏 |
| ④ | #if vs #ifdef? | 第 6.1:#if defined 支持组合条件、#ifdef 只检查单个定义 |
| ⑤ | PCH 原理? | 第 7 章:编译器内部状态快照——恢复快于重做预处理+语法分析 |
| ⑥ | Unity Build? | 第 8 章:多个 .cpp 拼成一个 TU——省预处理但增冲突风险 |
| ⑦ | IWYU 原则? | 第 9 章:显式 include 所有使用的符号——杜绝隐式依赖 |
案例①修复——宏爆炸:用 constexpr 替代宏、用 inline constexpr 变量替代表达式宏——在编译器层面收缩展开体积。
案例②修复——include 顺序依赖:
- 让
feature_x.h自身#include <optional>——实现头文件自给性 - 用
#ifndef替代对ENABLE_FEATURE_X的无保护依赖——缺少配置时给清晰的#error消息 - 对所有 .cpp 运行 IWYU——补上缺失的直接 include
# 10.2 一个 .cpp 从磁盘到 AST 的完整旅程
source.cpp (300 行) → 原始文本
① 字符映射 (~10μs):
- 三字符组检查 (C++17 已移除, 但编译器仍做兼容)
- 行拼接 (backslash-newline → 合并)
- 文件编码规范化 → 源文件字符集
② 预处理 ( ~200ms——主要是 #include 的文件 IO):
- #include <vector>: 打开文件→读 1000 行→插入当前位置→递归展开 vector 的 include
- #include <string>: 打开文件→读 800 行→插入
- #define 宏展开
- #ifdef 条件过滤
→ 输出: 约 50000 行的纯 C++ 文本(没有 # 指令、没有注释)
③ 词法分析 (~150ms):
- 50000 行字符 → token 序列
- 每个 token: 类型 (keyword/identifier/number/operator) + 位置 + 值
④ 语法分析 (~500ms——最耗时的阶段):
- token 序列 → AST (抽象语法树)
- 模板实例化 = 生成新的 AST 节点 → 大量时间消耗在此
⑤ 语义分析 (~300ms):
- 名字查找、类型推导、重载决议
- 常量表达式求值 (constexpr)
- 诊断信息生成 (错误/警告)
⑥ 代码生成 → .o 文件
共: ~1-2s 对于一个中等复杂的 .cpp (取决于模板量和 include 深度)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 10.3 设计哲学回扣
哲学 1:预处理是 C 遗产——C++ 继承了它的能力也继承了它的祸根
#include 是优雅的文本拼接——也是编译时间的首要瓶颈。#define 是便捷的符号替换——也是错误信息的首要混淆源。C++ 在 C++11/14/17/20 中不断推出替代机制(constexpr、inline、templates、modules)来逐步削弱预处理器的必要性。 Modules (第 24 篇) 是这一演进路线的终点——用「模块」替代「头文件」、用「导出」替代「宏」。
哲学 2:隐式依赖是软件工程中的负债——重构时才会被追债
IWYU 的核心价值不是「让代码更干净」——是让重构不会引发连锁编译失败。隐式的 #include <string> 链让 module_a 的修改炸掉 20 个无关的 .cpp。依赖必须显式——这是软件工程的基本原则——在 C++ 的 include 体系中尤其重要。
哲学 3:预处理器不知道 C++——这种无知是故意的也是危险的
#if FOO > 10 在预处理阶段求值——此时 constexpr int FOO = 5 还不存在。#if sizeof(int) == 4 编译失败——因为 sizeof 是编译器概念。预处理器的「无知」意味着你需要牢记两套语言:预处理器语言 + C++ 语言——它们的边界就是编译的边界。
哲学 4:编译时间的优化不是「改编译器 flag」——是「减少输入量」
PCH 用快照省量、Unity Build 用合并省量、IWYU 用精确包含省量——这三种方案的核心策略都是减少编译器需要处理的文本总量。每个 #include <iostream> 在预处理后产生 33000 行输出——省掉一个不必要的 include 就是省 33000 行的编译器处理成本。
# 10.4 速查表合集
#include 搜索路径:
| 语法 | 优先搜索 | 备选搜索 |
|---|---|---|
"header.h" | ① 当前目录 ② -I 路径 | ③ 系统路径 |
<header> | ① -I 路径 | ② 系统路径 |
宏展开三步:
| 步 | 操作 | 关键规则 |
|---|---|---|
| ① | 参数 prescan | 对参数的宏先展开 |
| ② | 参数替换 | 展开后的参数放进宏体 |
| ③ | 整体再扫描 | 蓝漆规则——自引用终止 |
预处理器运算符:
| 运算符 | 作用 | 示例 |
|---|---|---|
# | 字符串化 | #x → "x" |
## | token 粘贴 | a ## b → ab |
defined | 检查宏定义 | defined(FOO) |
条件编译速查:
| 指令 | 检查内容 | 组合条件 |
|---|---|---|
#ifdef X | X 是否定义(不管值) | ❌ |
#ifndef X | X 是否未定义 | ❌ |
#if defined(X) | 同上 + 可组合 | ✅ #if defined(X) && X > 1 |
#if X | X 展开后的值(未定义 = 0) | ✅ |
#elif / #else | — | — |
编译加速三方案:
| 方案 | 原理 | 收益 | 适用 |
|---|---|---|---|
| PCH | 编译器状态快照 | -30~50% | 增量编译 |
| Unity Build | 合并多个 .cpp | -30~50% | CI 全量 |
| IWYU | 移除不必要 include | -10~30% | 持续优化 |
本篇小结:翻译单元是 C++ 编译的最小独立单位——一个 .cpp 加上所有展开后的头文件构成一个 TU。预处理是 C 的遗产——#include 展开头文件、#define 宏替换、#if 条件编译——所有这些在语义分析之前完成。PCH 用编译器状态快照来跳过重复预处理、Unity Build 用合并来减少预处理次数、IWYU 用精准包含来消灭隐式依赖。三者的共同目标:减少编译器需要处理的文本量——这是编译加速的最有效手段。
下一篇:预处理完成了——编译器拿到的是纯净的 C++ 文本。下一篇进入 49.编译期符号生成——name mangling 规则、Itanium ABI 命名、extern "C" 边界、重载在符号层的体现、demangle 工具——从文本到符号的下一步。