编译期防御
# 第31章:编译期防御编程
# 目录介绍
- 1. 案例引入:两条主线
- 2. 编译期能拦住的九种 bug
- 3. 编译期防御机制总图
- 4. 五大编译期武器详解
- 5. 警告标志的层次
- 6. 类型与模板防御
- 7. 属性与注解
- 8. 静态分析与 Lint
- 9. 五步加固方法论
- 10. 典型场景速查
- 11. 工程化最佳实践
- 12. 综合案例串讲
- 13. 章节小结
# 1. 案例引入:两条主线
讲编译期防御,最忌讳的就是"先甩出 static_assert、[[nodiscard]] 几个关键字然后说自己讲完了"。事实是:这些武器单独看都很简单,但 90% 的工程师从不在自己的代码里用。本章先用两条真实主线说服你"为什么必须用",再讲"怎么用"。
# 1.1 主线一:被忽略的返回值
某支付网关上线后一周,财务对账每天差几百块。复现极慢,因为 99.9% 的请求都对。最后定位到这段代码:
// payment.cpp —— 支付路由
bool TryDeduct(Account& acc, long n); // 扣款,成功返回 true
void OnPayment(uint64_t uid, long amount) {
auto& acc = GetAccount(uid);
TryDeduct(acc, amount); // ⚠ 返回值被忽略
MarkPaid(uid); // 不管 TryDeduct 成功与否,都标记已付
PushReceipt(uid, amount);
}
2
3
4
5
6
7
8
9
TryDeduct 内部对余额不足会返回 false,但不会抛异常——按团队约定这是个"软失败",调用方应该检查返回值。但 OnPayment 漏掉了这次检查,导致:
余额不足 → TryDeduct 返回 false → 调用方忽略 →
MarkPaid 仍然执行 → 用户没扣到钱却显示"已付款"
2
症状:每天财务总额少几百,且分布看似随机。
为什么测试没发现:测试用例的账户都开了大额度,从未触发"余额不足"分支。
code review 也没发现:因为这种"忘了看返回值"在 C++ 里太常见了——编译器一声不吭。
修法当然是补一个 if。但真正的问题不是这一行,是"整个团队约定但编译器不强制"。事故第二周,团队给所有形如 TryXxx 的函数加了 [[nodiscard]],立刻在编译期暴露出 7 处类似漏洞。这就是 [[nodiscard]] 价值的最直白证据——一行属性,挡掉一类全公司同类 bug。
┌──────────────────────────────────────────────────────────┐
│ 修复前:约定靠人记 │
│ ───────────────────────── │
│ 注释 ─ "请检查返回值" │
│ review ─ "你忘了 if" │
│ 事故 ─ "对账少 ¥X,XXX" │
│ │
│ 修复后:[[nodiscard]] 编译器替你检查 │
│ ───────────────────────── │
│ error: ignoring return value of 'TryDeduct', │
│ declared with attribute 'nodiscard' │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
# 1.2 主线二:12 行的隐式拷贝
第二条主线短得多。某 RPC 客户端的连接池:
// pool.cc —— 12 行 MCVE
#include <vector>
#include <mutex>
class Connection {
public:
Connection() { /* 三次握手,建立 socket */ }
~Connection() { /* close(fd_) */ }
int fd_ = -1;
std::mutex mu_;
};
std::vector<Connection> pool; // ① 用 vector 装 Connection
void Init() { pool.resize(16); } // ② resize 触发拷贝/移动
2
3
4
5
6
7
8
9
10
11
12
13
14
这段代码在大多数项目里直接编不过——因为 std::mutex 不可拷贝、不可移动,所以 vector::resize 编译失败。但如果 Connection 不带 std::mutex,只带 int fd_:
class Connection {
public:
Connection() { /* connect */ }
~Connection() { close(fd_); } // 析构关闭 fd
int fd_ = -1;
};
std::vector<Connection> pool;
void Init() {
pool.push_back(Connection{}); // ① 临时对象的 fd 被复用
pool.push_back(Connection{}); // ② vector 扩容,老元素被拷贝构造到新位置
// 老元素析构,close 了 fd!
}
2
3
4
5
6
7
8
9
10
11
12
跑起来程序看似正常,但所有连接的 fd 都早早被 close 过一次——发包时返回 EBADF,业务表现是"偶发偶发再偶发的请求失败"。这种 bug 在 ASan 下能看到 double-close,但在编译期就该挡住:
class Connection {
public:
Connection() = default;
~Connection() { if (fd_ >= 0) close(fd_); }
Connection(const Connection&) = delete; // ← 这一行
Connection& operator=(const Connection&) = delete; // ← 这一行
private:
int fd_ = -1;
};
2
3
4
5
6
7
8
9
加上之后 pool.push_back(Connection{}) 在 vector 扩容时编译报错:"copy constructor is deleted"。这个错本来在第一次写代码时就该被编译器揪出来,而不是等线上 EBADF 抓瞎。
class Connection { int fd_; }; (允许拷贝)
│
▼
vector<Connection>.push_back(...)
│
▼
vector 扩容 ─ realloc ─ 拷贝旧元素
│
▼
旧元素析构 ─ close(fd_)
│
▼
新元素 fd_ 指向已关闭 fd
│
▼
send/recv ─ EBADF ─ 业务故障
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
22 行→编译期 1 行就解决。
# 1.3 顺藤摸到根因
两条主线根因都是同一句话:"团队约定 / 程序员意图"没有被翻译成编译器能验证的形式。
- 主线一:约定"
TryXxx必须检查返回值",但语言不强制。[[nodiscard]]是翻译器。 - 主线二:约定"Connection 不该被拷贝",但语言默认生成拷贝构造。
= delete是翻译器。
把这句话抽象成一个判断框架:
┌──────────────────────────────────────────────┐
│ 任何"团队约定" → 是否能让编译器验证? │
│ ─ 能:写进类型 / 属性 / static_assert │
│ ─ 不能:写进 Lint / 单元测试 / Code Review │
└──────────────────────────────────────────────┘
2
3
4
5
能编译期验证的,就不要留到运行期。这是本章最核心的一句。
# 1.4 本篇要回答什么
| 层次 | 你将学到 |
|---|---|
| 原因层 | 9 种"约定靠人记"的 bug 类别,分别对应什么编译期武器 |
| 武器层 | static_assert / [[nodiscard]] / = delete / constexpr / explicit 五件套的本质和边界 |
| 警告层 | -Wall / -Wextra / -Wpedantic / -Werror 该怎么配合 |
| 类型层 | 强类型、enum class、concept 怎么把"约定"编进类型 |
| 工具层 | clang-tidy、cppcheck、IWYU、clang-format 在 CI 里怎么落 |
| 方法层 | 项目从零开始 / 老项目逐步加固的五步走 |
📌 本篇定位:是排查篇的"反方向"。前几章都在讲"出了 bug 怎么排查",这一章讲"怎么让 bug 进不来"。读完后再看任何 C++ 代码,你都能立刻判断:"这块代码能不能让编译器替我检查?"
# 2. 编译期能拦住的九种 bug
进入武器讲解之前,先把"编译期就能识别的 bug 类别"列清楚。下面 9 种,每一种都有现成的编译期武器对应。
# 2.1 返回值被忽略
主线一的故事,再典型不过:
bool TryParse(std::string_view s, int& out);
void Use(const std::string& s) {
int v;
TryParse(s, v); // ⚠ 忘了看返回值
Process(v); // 解析失败时 v 是垃圾
}
2
3
4
5
6
C++ 默认允许"丢弃返回值",因为某些函数(如 printf)的返回值确实没人在意。但**"成功才用 v"的函数必须强制检查**——这就是 [[nodiscard]] 干的事:
[[nodiscard]] bool TryParse(std::string_view s, int& out);
之后 TryParse(s, v); 直接编译警告(-Werror 下直接报错)。
# 2.2 隐式拷贝重对象
主线二的故事。C++ 默认会生成拷贝构造和拷贝赋值,对"管理资源的类"是灾难:
class FileHandle {
FILE* f_;
public:
FileHandle(const char* p) : f_(fopen(p, "r")) {}
~FileHandle() { if (f_) fclose(f_); }
// ⚠ 默认生成的拷贝构造会复制 f_ 指针,两个对象关同一个文件
};
2
3
4
5
6
7
修法("Rule of Five"):要么明确实现五个特殊成员函数,要么 = delete 禁掉。
# 2.3 隐式窄化转换
void SetSize(int n);
SetSize(3.14); // 隐式 double → int,丢了 0.14
SetSize(1L << 40); // long → int 截断
2
3
C++11 起的列表初始化禁止窄化:
int x{3.14}; // 编译错误:narrowing conversion
int y = 3.14; // 仍允许(兼容 C 习惯)
2
更进一步可以打开 -Wconversion -Wnarrowing 把所有窄化都列出来。
# 2.4 函数重载错配
void Send(int code); // 错误码
void Send(double level); // 噪音电平
Send(true); // 调到哪个?bool→int 优于 bool→double,但你真的想这样?
2
3
修法:禁掉不想要的重载
void Send(int);
void Send(double);
void Send(bool) = delete; // 显式禁掉 bool 调用
2
3
# 2.5 未初始化使用
int x;
if (cond) x = 1;
Print(x); // 如果 !cond,x 是未定义
2
3
-Wuninitialized + -O2 多数情况下能识别。彻底点的做法:用类型禁止"无初始值的变量"——std::optional<int> 显式区分"有值/没值"。
# 2.6 越界访问能编译期推断
int a[4];
a[10] = 0; // 字面量越界,-Warray-bounds 直接报
constexpr int b[] = {1,2,3};
static_assert(std::size(b) >= 5); // 编译期断言
2
3
4
不是所有越界都能在编译期识别,但字面量级别的越界 100% 能。
# 2.7 类型大小假设错误
struct Packet {
uint32_t magic;
uint16_t version;
uint16_t flags;
uint64_t payload_len;
};
static_assert(sizeof(Packet) == 16, "Packet layout broken");
2
3
4
5
6
7
二进制协议、SIMD 优化、cache line 对齐——但凡假设了"某结构体大小"的地方,都该有 static_assert 守着。编译器升级、字段顺序调整、padding 变化时第一时间报警。
# 2.8 弱类型 ID 混用
void Transfer(int from_uid, int to_uid, int amount);
Transfer(123, 100, 456); // 哪个是 uid 哪个是金额?
2
C++ 默认是"弱类型 ID"——所有 int 都能互换。修法:强类型别名
struct Uid { int v; };
struct Money { long v; };
void Transfer(Uid from, Uid to, Money amount);
Transfer(Uid{123}, Money{100}, Uid{456}); // 编译错误!
2
3
4
5
6.1 节会详谈。
# 2.9 比较有符号无符号
for (int i = 0; i < v.size(); ++i) // -Wsign-compare 警告
v.size() 是 size_t,i 是 int。这种比较在 i 变负时会出错。修法:
for (size_t i = 0; i < v.size(); ++i)
// 或
for (auto& x : v)
2
3
九种翻车里,(2.1, 2.2) 是主线案例正面命中,其它七种后续章节会逐一映射到具体武器。核心立场:能在编译期拦住的 bug,永远不要留给运行期。
# 3. 编译期防御机制总图
# 3.1 从语法到语义的四道关卡
C++ 编译器对源码的检查可以分四层:
┌────────────────────────────────────────┐
│ 1. 词法/语法 拼写错、漏分号、括号 │
├────────────────────────────────────────┤
│ 2. 类型检查 参数类型、重载解析 │
├────────────────────────────────────────┤
│ 3. 语义警告 可疑但合法(-Wall …) │
├────────────────────────────────────────┤
│ 4. 流程分析 未初始化、不可达分支 │
└────────────────────────────────────────┘
2
3
4
5
6
7
8
9
第 1、2 层是必过的硬关卡——不过就连 .o 都没有;第 3、4 层是可选的软关卡——默认很多警告是关闭的,需要你主动打开。
编译期防御的核心策略:把第 3、4 层的能力"激活到最大",再用语言特性 (static_assert / 属性 / = delete) 把"团队约定"翻译成第 1、2 层能识别的形式。
# 3.2 编译器、警告、Lint、Sanitizer 的分工
| 工具 | 在哪一阶段 | 能挡住什么 | 代价 |
|---|---|---|---|
| 编译器(语法/类型) | 编译 | 拼写错、类型不匹配 | 0(必过) |
| 编译器警告(-Wall…) | 编译 | 可疑但合法的代码 | 0 |
[[nodiscard]] 等属性 | 编译 | 忘检查返回值、未用变量 | 0 |
static_assert | 编译 | 编译期可判定的不变量 | 0 |
| Lint(clang-tidy) | 编译前/后 | 风格、潜在 bug、命名 | 秒级 |
| Sanitizer(ASan/UBSan/TSan) | 运行 | 实际运行触发的 UB | 几倍慢 |
| 单元测试 | 运行 | 业务行为 | 几秒~几分钟 |
最佳成本结构:先用前 5 行(全是编译期 + 零代价)挡掉 80% 的低级错误,再用 Sanitizer / 测试挡剩下的 20%。
# 3.3 编译期 vs 运行期的成本曲线
发现一个 bug 的成本,随阶段呈指数曲线:
开发期 编译时 1x ← 编译报错,5 秒修
开发期 单测 5x ← CI 红,5 分钟修
预发期 集成测试 50x ← QA 提单,半天追溯
生产期 用户反馈 500x ← 上线事故,团队过夜回滚
生产期 漏到对账/审计 5000x ← 财务损失,监管介入
2
3
4
5
主线一就是从 1x 阶段(编译报错)被推迟到了 5000x 阶段(财务事故)。编译期防御本质是把 bug 拦在最便宜的阶段。
# 3.4 一段代码的编译流水线
源码 .cpp ──┬──▶ 预处理 (cpp) ──▶ 展开宏、include
│
▼
clang-format 检查格式
│
▼
clang-tidy 静态分析(可选)
│
▼
front-end: 词法 → 语法 → AST
│
▼
语义分析(重载、模板实例化、属性检查)
│
├─ static_assert 在这里执行
├─ [[nodiscard]] 警告在这里发出
├─ constexpr 求值在这里完成
│
▼
IR 生成(中间表示)
│
▼
优化器 ←─ -O2 才打开的部分警告(如 -Wmaybe-uninitialized)
│
▼
目标代码 .o
│
▼
链接器:libtbd / 多重定义 / ODR 违反
│
▼
可执行文件
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
注意一个反直觉的点:-O2 才能开出全部警告。-O0 时优化器没做数据流分析,-Wuninitialized 无法识别"有些路径下变量没初始化"。所以 CI 应该至少跑两轮:-O0 -g 给调试用,-O2 -g 用于完整告警检查。
# 3.5 防御机制总览图
┌──────────────────────────────────────────────────────────────┐
│ 写代码时的每个决策都对应一道防御 │
├──────────────────────────────────────────────────────────────┤
│ 决策 武器 关联节 │
│ ──────────────────────────────────────────────────────────── │
│ 函数返回值能丢吗 [[nodiscard]] 4.2 / 7.1 │
│ 类能不能拷贝 = delete / Rule of Five 4.3 / 10.2 │
│ 类型大小要稳定 static_assert(sizeof…) 4.1 / 10.3 │
│ ID 类型能否混用 强类型 / enum class 6.1 / 6.2 │
│ 单参构造能否被隐式 explicit 4.5 / 10.4 │
│ 模板参数有要求 concept / requires 6.4 │
│ 编译期常量传入 constexpr 4.4 │
│ 函数会抛吗 noexcept 12.3 │
│ 函数不返回 [[noreturn]] 7.5 │
│ switch 故意穿透 [[fallthrough]] 7.4 │
│ 未使用是故意的 [[maybe_unused]] 7.2 │
│ API 要废弃了 [[deprecated]] 7.3 │
└──────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
下面第 4 章正式逐个拆解最常用的五件套。
# 4. 五大编译期武器详解
# 4.1 static_assert 编译期断言
定义(C++11):编译期判定一个 constexpr bool,为 false 就编译失败并打印消息。
API:
static_assert(cond); // C++17 起消息可省
static_assert(cond, "explanation"); // C++11 必须带消息
2
典型用法:
// 1. 类型大小假设
static_assert(sizeof(Packet) == 16, "wire format changed");
// 2. 平台/字长假设
static_assert(sizeof(void*) == 8, "64-bit only");
// 3. 模板参数约束
template<class T>
class Buffer {
static_assert(std::is_trivially_copyable_v<T>,
"Buffer requires trivially copyable T");
T data_[256];
};
// 4. 编译期常量校验
constexpr int kMax = 1 << 16;
static_assert(kMax % 64 == 0, "alignment lost");
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
核心要点:
- 零运行期开销——它根本不进二进制。
- 只能用编译期可求值的表达式:
constexpr变量、sizeof、type_traits、constexpr函数。 - 错误消息要"说人话":写出"为什么必须满足",而不是复述条件。
反例:
static_assert(sizeof(T) == 4); // 失败时只能看到 cond,不知道为何
static_assert(sizeof(T) == 4,
"T must be uint32_t-sized: wire format depends on it"); // ✅
2
3
坑点:
- C++11 版本必须有消息,C++17 起可省。如果项目要兼容老编译器,统一带消息更稳。
- 在模板里,
static_assert(false, "...")在类模板被实例化前不会触发。要让它"未实例化也触发"得借助dependent_false技巧:
template<class T>
struct always_false : std::false_type {};
template<class T>
void f() {
static_assert(always_false<T>::value, "must specialize");
}
2
3
4
5
6
7
# 4.2 nodiscard 返回值警卫
定义(C++17 引入,C++20 加强):标记一个函数的返回值"不该被丢弃",否则编译警告。
API:
[[nodiscard]] int Compute(); // C++17
[[nodiscard("explain why")]] int Compute(); // C++20,附原因
class [[nodiscard]] Error { /*...*/ }; // 类型上,所有返回 Error 的函数都警告
2
3
4
典型用法:
// 1. 工厂函数:丢了等于内存泄漏
[[nodiscard]] std::unique_ptr<Conn> CreateConn();
// 2. Try 系列:丢了等于忽略错误(主线一)
[[nodiscard]] bool TryParse(std::string_view s, int& out);
// 3. 重要状态:忽略它没意义
[[nodiscard]] bool empty() const; // 标准库 C++20 已加
// 4. Status / Result 类型:核心错误传递机制
class [[nodiscard]] Status {
public:
bool ok() const;
/* ... */
};
Status DoSomething(); // 丢弃 Status 自动警告
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
何时该加:
| 类型 | 加不加 | 理由 |
|---|---|---|
| 工厂函数(返回新资源) | 加 | 丢了就泄漏 |
TryXxx / IsXxx | 加 | 丢了就跳过错误处理 |
| 错误码 / Status | 加在类型上 | 一次性覆盖所有返回 Status 的函数 |
printf 之类 | 不加 | 大部分调用方真的不关心 |
operator new | 已经加(C++20) | 丢了就泄漏 |
| 纯计算且无副作用 | 加 | 丢了等于白算 |
升级到 -Werror:在团队 CI 里,-Werror=unused-result(GCC)或 -Werror=ignored-attributes(Clang)能把警告升级成错误。主线一的事故就这样彻底消失。
避坑:
- C++17 之前可用
__attribute__((warn_unused_result))(GCC/Clang 扩展)。 - 在 C 接口和老代码上加
[[nodiscard]]会引起大量警告——分批加。
# 4.3 = delete 删除函数
定义(C++11):把一个函数显式标记为"不存在"。调用它编译期失败。
API:
class NonCopyable {
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};
2
3
4
5
6
核心用途:
(1) 禁拷贝/禁移动(主线二):
class FileHandle {
int fd_;
public:
FileHandle(const char* p);
~FileHandle();
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
// 移动可保留
FileHandle(FileHandle&& o) noexcept : fd_(o.fd_) { o.fd_ = -1; }
FileHandle& operator=(FileHandle&& o) noexcept;
};
2
3
4
5
6
7
8
9
10
11
(2) 禁某种隐式转换:
void Log(const std::string&);
void Log(int) = delete; // 禁止 int 调用
Log(42); // 编译错误,避免 0/'A' 被当字符串
2
3
(3) 禁某些重载的隐式提升:
class Color {
public:
Color(uint32_t rgb); // 允许 RGB 整数构造
Color(double) = delete; // 但禁掉浮点意外调用
};
Color c(0xff00ff); // OK
Color d(3.14); // 编译错误
2
3
4
5
6
7
(4) 禁默认构造:
class NoDefault {
public:
NoDefault() = delete;
NoDefault(int);
};
NoDefault x; // 编译错误
NoDefault y(42); // OK
2
3
4
5
6
7
Rule of Five 简述:定义了析构函数 / 拷贝构造 / 拷贝赋值 / 移动构造 / 移动赋值 中任意一个,就要明确处理另外四个。要么实现要么 = delete。这是 C++ 资源类的黄金法则。
反例:
class Bad {
~Bad() { close(fd_); } // 只写了析构,没禁拷贝
}; // → 默认拷贝构造导致 double-close
2
3
# 4.4 constexpr 编译期求值
定义(C++11 起,C++14/17/20 不断增强):让函数 / 变量可以在编译期求值。
API 演进:
constexpr int Sqr(int x) { return x * x; } // C++11,受限严重
constexpr int Sqr(int x) { // C++14,可以写循环/分支
int r = 1; while (--x > 0) r *= x; return r;
}
consteval int Sqr(int x) { return x * x; } // C++20,必须编译期求值
constinit int g = Sqr(10); // C++20,必须编译期初始化
2
3
4
5
6
典型用法:
// 1. 编译期常量计算
constexpr size_t kBufSize = 1024 * 16;
char buffer[kBufSize];
// 2. 模板非类型参数
template<size_t N> struct Array { int data[N]; };
Array<kBufSize> a;
// 3. switch 的 case 标签
switch (x) { case kBufSize: ...; }
// 4. 替代宏(类型安全)
// ❌ 老式:#define MAX_USERS 1024
// ✅ 新式:constexpr int kMaxUsers = 1024;
2
3
4
5
6
7
8
9
10
11
12
13
14
与防御编程的关系:
- 能编译期完成的计算永远不要拖到运行期——例如查表、Hash 种子、size 校验。
constexpr函数可以在static_assert里用,把"运行时函数"变成"编译期校验"。
constexpr bool IsPowerOfTwo(int x) { return x > 0 && (x & (x-1)) == 0; }
static_assert(IsPowerOfTwo(kBufSize), "kBufSize must be power of 2");
2
坑点:
constexpr不保证编译期求值,只是"可以"。强制用consteval(C++20)。- C++11/14 的
constexpr函数限制不同,写得通用要小心。
# 4.5 explicit 阻止隐式转换
定义:标记单参构造函数 / 转换运算符为"不能用于隐式转换"。
API:
class String {
public:
explicit String(size_t n); // 不允许 String s = 100;
explicit operator bool() const; // 不允许 int x = string_obj;
};
2
3
4
5
典型场景:
// 1. 单参构造防止意外类型提升
class Duration {
public:
explicit Duration(int seconds);
};
void Sleep(Duration d);
Sleep(30); // ❌ 编译错误,30 不会被悄悄当成 30 秒
Sleep(Duration{30}); // ✅ 明确
// 2. 防止 bool 转换被滥用
class FileHandle {
public:
explicit operator bool() const { return fd_ != -1; }
};
FileHandle f("a.txt");
if (f) { /* OK */ } // ✅ 上下文转换允许
int x = f + 1; // ❌ 不允许悄悄当 int
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Best practice:所有单参构造默认加 explicit,确实需要隐式转换才去掉。这是 Google C++ Style、Core Guidelines、Sutter & Alexandrescu 都建议的默认值。
# 4.6 五件套对照表
┌────────────────┬──────────┬──────────────────┬──────────────────────┐
│ 武器 │ C++ 版本 │ 防御对象 │ 一句话 │
├────────────────┼──────────┼──────────────────┼──────────────────────┤
│ static_assert │ C++11 │ 编译期不变量 │ 大小/特性的硬约束 │
│ [[nodiscard]] │ C++17 │ 被忽略的返回值 │ Try/工厂/错误码必查 │
│ = delete │ C++11 │ 默认生成的函数 │ 资源类禁拷贝 │
│ constexpr │ C++11+ │ 运行期才算的常量 │ 能编译期完成就早算 │
│ explicit │ C++98 │ 隐式构造/转换 │ 单参构造默认就加 │
└────────────────┴──────────┴──────────────────┴──────────────────────┘
2
3
4
5
6
7
8
9
五件套不重叠、不可替换——每个对应一类完全不同的"约定"。
# 5. 警告标志的层次
武器讲完,回到编译器自己内置的告警系统。多数项目的低级 bug 不是因为没用 [[nodiscard]],而是因为根本没开 -Wall。
# 5.1 -Wall 默认套餐
-Wall 不是"所有警告"(这是个历史误称),而是"多数情况下都该打开的、误报率较低的警告组"。包含约 60 项检查:
-Wuninitialized 变量可能未初始化
-Wmaybe-uninitialized 可能未初始化(数据流分析)
-Wunused-variable 变量声明了没用
-Wunused-function 函数定义了没调用
-Wreturn-type 函数声明返回但有路径无 return
-Wswitch switch 漏 enum 值
-Wparentheses 括号疑似漏写
-Wformat printf 格式串与参数不匹配
-Wstring-compare 字符串字面量比较
... 等约 50+ 项
2
3
4
5
6
7
8
9
10
任何新项目第一行 CMakeLists 都该加:
target_compile_options(myapp PRIVATE -Wall)
# 5.2 -Wextra 加强组
-Wall 之上再加约 20 项:
-Wempty-body if/while/for 后跟空 ;
-Wmissing-field-initializers 聚合初始化漏字段
-Wsign-compare 有符号/无符号比较
-Wunused-parameter 函数参数没用
-Wshift-negative-value
-Wmissing-braces
...
2
3
4
5
6
7
-Wextra 的误报率比 -Wall 高一些,但拦到的 bug 也更多。推荐所有新项目默认开 -Wall -Wextra。
# 5.3 -Wpedantic 标准党
-Wpedantic 强调"严格符合 ISO C++ 标准,不允许任何编译器扩展"。打开后:
-Wpedantic
-Wlanguage-extension-token 使用了非标语法(如 GCC 的 typeof)
2
这一档主要给跨编译器项目用——同一份代码要在 GCC、Clang、MSVC 上都过。普通业务项目不强求。
# 5.4 -Werror 升级为错误
g++ -Wall -Wextra -Werror file.cc
所有警告变成错误,CI 直接红。这是把"警告"真正落地的关键一步——否则警告永远被人忽略。
但 -Werror 也有坑:
- 编译器升级会引入新警告,老代码突然编不过。
- 第三方库的头文件可能触发警告,污染你的项目。
更精细的做法:
# 全开 -Werror,但放过特定项
g++ -Wall -Wextra -Werror -Wno-error=deprecated-declarations file.cc
# 仅几项升级为 error,其它仍是 warning
g++ -Wall -Werror=return-type -Werror=unused-result file.cc
2
3
4
5
推荐策略:
- 自己代码:
-Wall -Wextra -Werror,全部当错。 - 第三方代码:包含时
#pragma GCC diagnostic push/ignored/pop,或-isystem把它当系统头文件(系统头不参与警告)。
# 5.5 单条警告精细控制
某段代码故意要做"看起来可疑但合法"的事,又不想动全局开关:
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-variable"
int x = something_with_side_effect_only();
#pragma GCC diagnostic pop
2
3
4
Clang 等价:
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wunused-variable"
/* ... */
#pragma clang diagnostic pop
2
3
4
注意:用 pragma 抑制警告必须就近,避免大段代码失去保护。最好附一行注释说明为什么。
# 5.6 GCC 与 Clang 差异
虽然 90% 的警告同名,但有一些差异:
| 警告 | GCC | Clang | 备注 |
|---|---|---|---|
-Wmaybe-uninitialized | ✅ | ❌ | Clang 没这一项 |
-Wthread-safety | ❌ | ✅ | Clang 的锁注解检查 |
-Wlogical-op | ✅ | ❌ | |
-Wlifetime | ❌ | ✅(实验) | Lifetime profile |
结论:CI 里同时跑 GCC 和 Clang 比只跑一种多挡 20% 的 bug。详见第 11.2 节。
# 6. 类型与模板防御
第 4 章的武器是"打补丁",第 6 章是"换地基"——把约束编进类型系统,让编译器把"违反约定"识别为"类型错误"。
# 6.1 强类型别名 strong typedef
回到 2.8 的例子:
void Transfer(int from_uid, int to_uid, int amount);
Transfer(123, 100, 456); // 哪个是 uid 哪个是金额?
2
C++ 的 using 和 typedef 只是别名,不是新类型:
using Uid = int;
using Money = int;
void Transfer(Uid from, Uid to, Money n);
Transfer(123, 100, 456); // 仍然合法,没区分
2
3
4
真正的强类型:用一个最薄的 struct 把基本类型包起来:
struct Uid { int v; explicit Uid(int x) : v(x) {} };
struct Money { long v; explicit Money(long x) : v(x) {} };
void Transfer(Uid from, Uid to, Money n);
Transfer(Uid{123}, Uid{100}, Money{456}); // ✅
Transfer(123, 100, 456); // ❌ 编译错误
Transfer(Uid{123}, Money{100}, Uid{456}); // ❌ 编译错误(参数顺序错)
2
3
4
5
6
7
8
进阶:开源的 boost::strong_typedef 或 NamedType 库一行就能定义带运算符的强类型:
using Meters = NamedType<double, struct MetersTag, Arithmetic>;
using Seconds = NamedType<double, struct SecondsTag, Arithmetic>;
Meters d{100};
Seconds t{10};
auto bad = d + t; // ❌ 编译错误:不同强类型不能加
auto ok = d + Meters{20}; // ✅
2
3
4
5
6
7
强类型的核心思想:"只有名字一样的才能互换"——和"鸭子类型"反着来。
# 6.2 enum class 强枚举
C++98 的 enum 是弱类型,会隐式转 int,并且常量名污染外层作用域:
enum Status { OK, ERROR };
enum Color { RED, OK }; // ❌ OK 重定义
int s = OK + 1; // 隐式转 int
2
3
enum class(C++11)修复了这两点:
enum class Status { OK, ERROR };
enum class Color { RED, OK }; // ✅ 各自命名空间
Status s = Status::OK;
int x = s; // ❌ 不允许隐式转
int y = static_cast<int>(s); // ✅ 必须显式
2
3
4
5
额外功能:可以指定底层类型,便于二进制协议:
enum class Opcode : uint8_t { READ = 1, WRITE = 2 };
static_assert(sizeof(Opcode) == 1);
2
何时该用:所有新代码的枚举都该是 enum class,没有任何理由用老式 enum。
# 6.3 模板里的 static_assert
模板写起来灵活,错起来报错刺眼。static_assert 把"用户的类型必须满足的约束"摆在最上面,错误信息友好:
template<class T>
class FastQueue {
static_assert(std::is_trivially_copyable_v<T>,
"FastQueue requires trivially copyable T (uses memcpy internally)");
static_assert(sizeof(T) <= 64,
"FastQueue is optimized for small elements (<= 64 bytes)");
public:
/* ... */
};
2
3
4
5
6
7
8
9
用户传了不合规的类型,错误信息直接指向 static_assert 那一行,而不是几十层模板深处的 memcpy 调用。模板库作者必备技能。
# 6.4 concept 与 requires
C++20 把"模板参数约束"从 static_assert 升级到了一等公民——concept:
template<class T>
concept Hashable = requires(T x) {
{ std::hash<T>{}(x) } -> std::convertible_to<size_t>;
};
template<Hashable K, class V>
class HashMap { /* ... */ };
HashMap<std::string, int> ok; // ✅
struct NotHashable {};
HashMap<NotHashable, int> bad; // ❌ 错误明确:NotHashable 不满足 Hashable
2
3
4
5
6
7
8
9
10
11
concept 比 static_assert 强的地方:
- 错误信息更友好:直接说"不满足 Hashable concept"。
- 可以用于函数重载:根据 concept 选择重载。
- 可以约束模板参数:写在
template<>里。
template<std::integral T> T abs(T x) { return x < 0 ? -x : x; } // 仅整数
template<std::floating_point T> T abs(T x) { return std::fabs(x); } // 仅浮点
2
前 C++20 用 SFINAE + enable_if 实现同样效果,但语法极其难看。concept 是这十年 C++ 模板进化的最大成果之一。
# 6.5 SFINAE 与 if constexpr
模板写代码常常需要"根据类型不同走不同分支"。两条路:
(1) SFINAE(C++11+):
template<class T>
std::enable_if_t<std::is_integral_v<T>, T>
Process(T x) { return x * 2; }
template<class T>
std::enable_if_t<std::is_floating_point_v<T>, T>
Process(T x) { return x * 2.0; }
2
3
4
5
6
7
(2) if constexpr(C++17):
template<class T>
T Process(T x) {
if constexpr (std::is_integral_v<T>) {
return x * 2;
} else if constexpr (std::is_floating_point_v<T>) {
return x * 2.0;
} else {
static_assert(always_false<T>::value, "unsupported type");
}
}
2
3
4
5
6
7
8
9
10
if constexpr 更直观,新代码优先用它。SFINAE 主要在需要"重载分发"或"老编译器兼容"时还用。
# 7. 属性与注解
C++11 引入的 [[...]] 属性是标准化的注解机制,弥补了"语言本身表达不出某些约束"的空缺。
# 7.1 [[nodiscard]] 进阶用法
除了 4.2 节讲过的基本用法,还有几招进阶:
(1) 加在类型上:所有返回该类型的函数都受影响。
class [[nodiscard]] Status {
public:
bool ok() const;
};
Status Save();
Save(); // ⚠ 警告:丢弃 Status
2
3
4
5
6
7
(2) 带原因(C++20):
[[nodiscard("must release with Free()")]] void* Alloc(size_t n);
Alloc(100); // 警告:"must release with Free()"
2
(3) 加在构造函数上:
class Mutex {
public:
[[nodiscard]] std::unique_lock<std::mutex> Lock();
};
Mutex m;
m.Lock(); // ⚠ 警告:unique_lock 立刻析构等于没锁
{ auto g = m.Lock(); ... } // ✅
2
3
4
5
6
7
8
这一招正好挡住第 30 章里反复强调的"忘了把 lock_guard 赋给变量"。
# 7.2 [[maybe_unused]] 与未用变量
-Wunused-* 警告很有用,但有些"故意未用"——比如调试期才用、assert 里才用:
[[maybe_unused]] int sum = std::accumulate(v.begin(), v.end(), 0);
assert(sum == 100); // Release 模式 assert 是空,sum 看似未用
2
或者函数参数因接口约束必须留着但当前实现不用:
void OnEvent([[maybe_unused]] EventId id, const Payload& p) { /* ... */ }
vs (void)x 老套路:
void f(int x) { (void)x; /* mark as used */ }
(void)x 是 C 时代的做法,能用但不优雅。[[maybe_unused]] 是表意更明确的现代写法。
# 7.3 [[deprecated]] 优雅废弃
接口要废弃但又不能立刻删——给它打标签,让调用方逐步迁移:
[[deprecated("use NewApi() instead")]]
int OldApi();
OldApi(); // ⚠ 警告:"'OldApi' is deprecated: use NewApi() instead"
2
3
4
可以加在:函数、类、变量、enumerator、typedef、namespace。
比直接 #pragma message 优雅得多——警告附在调用点,迁移路径明确。
# 7.4 [[fallthrough]] switch 防误穿
C 风格 switch 默认会"穿透",但 90% 的穿透是 bug:
switch (op) {
case READ: buf = Read(); // ⚠ 漏 break?
case WRITE: Write(buf); // 故意穿透还是 bug?
break;
}
2
3
4
5
-Wimplicit-fallthrough 会把所有疑似穿透都警告。故意穿透用 [[fallthrough]] 显式声明:
switch (op) {
case READ:
buf = Read();
[[fallthrough]]; // 故意,编译器不警告
case WRITE:
Write(buf);
break;
}
2
3
4
5
6
7
8
任何没标 [[fallthrough]] 的穿透 → 编译警告 → 不漏 break 也不被误以为是 bug。
# 7.5 [[noreturn]] 与流程分析
某些函数永不返回(终止进程、抛异常、长跳转)。告诉编译器:
[[noreturn]] void Fatal(const char* msg);
int GetValue() {
if (!ok) Fatal("nope");
return 42; // ✅ 编译器知道 if 分支不返回,不警告"缺 return"
// 没有 [[noreturn]] 时编译器会担心"if 分支也没返回"
}
2
3
4
5
6
7
[[noreturn]] 同时帮优化——编译器知道后续代码不可达,省一段。自定义的 panic 函数、abort 包装、抛异常的 helper 都该加上。
# 8. 静态分析与 Lint
警告 + 属性还不够——还有一类"风格 / 潜在 bug / 现代化建议",靠专门的 Lint 工具。
# 8.1 clang-tidy 速览
clang-tidy 是 LLVM 自带的最强静态分析工具,当代 C++ 项目几乎都该用。
安装与运行:
brew install llvm # macOS
sudo apt-get install clang-tidy # Linux
clang-tidy file.cc -- -std=c++17 -I./include
2
3
4
Checker 分组:
bugprone-* 潜在 bug,如 use-after-move
cert-* CERT C++ Coding Standard
clang-analyzer-* 深度数据流分析
cppcoreguidelines-* Bjarne 的 C++ Core Guidelines
google-* Google Style
hicpp-* High Integrity C++
misc-* 杂项
modernize-* C++11/14/17/20 现代化建议
performance-* 性能相关
portability-* 可移植性
readability-* 可读性
2
3
4
5
6
7
8
9
10
11
配置文件 .clang-tidy:
Checks: >
-*,
bugprone-*,
cppcoreguidelines-*,
modernize-*,
performance-*,
readability-*,
-modernize-use-trailing-return-type,
-readability-magic-numbers
WarningsAsErrors: 'bugprone-*'
HeaderFilterRegex: '.*'
2
3
4
5
6
7
8
9
10
11
Checker 示例:
| Checker | 抓什么 |
|---|---|
bugprone-use-after-move | 移动后还用 |
bugprone-integer-division | int 除法误以为是浮点 |
modernize-use-nullptr | 0 / NULL 替换为 nullptr |
modernize-use-override | 虚函数加 override |
performance-unnecessary-copy-initialization | 不必要的拷贝 |
cppcoreguidelines-init-variables | 未初始化的局部变量 |
readability-else-after-return | if (...) return; else ... |
# 8.2 cppcheck 互补
cppcheck 是另一款独立工具,误报率比 clang-tidy 更低,但深度浅一些。两者互补使用最好:
cppcheck --enable=all --inconclusive --std=c++17 src/
擅长抓的:
- 内存泄漏(简单模式)
- 数组越界(字面量级别)
- 未使用函数
- 复制粘贴遗漏的修改
# 8.3 include-what-you-use
C++ 编译慢 70% 来自头文件膨胀。IWYU 检查"你 include 了什么,但实际没用":
iwyu_tool.py -p build/ -- -std=c++17
输出建议:
foo.cc should add these lines:
#include <vector> // for std::vector
foo.cc should remove these lines:
- #include "big_header.h" // lines 4-4
2
3
4
5
跟着提示改,编译速度可能提高 30-50%。
# 8.4 clang-format 与一致性
不是抓 bug,是抓"风格不一致"。一致的代码风格降低 review 成本、减少 merge 冲突。
.clang-format:
BasedOnStyle: Google
IndentWidth: 4
ColumnLimit: 100
PointerAlignment: Left
AllowShortFunctionsOnASingleLine: Empty
2
3
4
5
CI 加一步:
clang-format --dry-run --Werror $(git ls-files '*.cc' '*.h')
代码风格不对直接 CI 红。
# 8.5 IDE 集成与 CI 落地
IDE 侧(CLion / VS Code + clangd):保存时自动跑 clang-tidy,问题即时高亮。
CI 侧 推荐顺序:
1. clang-format 格式不通过 → 直接 fail
2. -Wall -Wextra -Werror 多编译器矩阵
3. clang-tidy(PR diff 上)
4. cppcheck 作为补充
5. ASan/UBSan 跑单元测试
2
3
4
5
关键经验:clang-tidy 只对改动行报错,不对全量历史报错。否则老项目接入第一天就有上千告警,团队心态崩了。详见 11.3 节。
# 9. 五步加固方法论
把前面所有武器压缩成一个可操作的清单:
# 9.1 第一步:把警告调到最严
CMake / build 脚本里直接写死:
target_compile_options(myapp PRIVATE
-Wall -Wextra -Wpedantic
-Wshadow -Wnon-virtual-dtor
-Wold-style-cast -Wcast-align
-Wunused -Woverloaded-virtual
-Wconversion -Wsign-conversion
-Wnull-dereference -Wdouble-promotion
-Wformat=2
-Werror
)
2
3
4
5
6
7
8
9
10
清单出处:Jason Turner 的 "cppbestpractices"。
老项目:一次开太多会爆几千警告,按章节顺序逐步开 + 配合 11.3 节的"只对新代码报错"策略。
# 9.2 第二步:用类型替代约定
任何形如"这个 int 表示 XX,那个 int 表示 YY"的约定,全部换强类型。
// before
void Transfer(int from, int to, int amount);
// after
void Transfer(Uid from, Uid to, Money amount);
2
3
4
5
enum class 替代魔法数字、std::chrono::milliseconds 替代裸 int 时间——一切能用类型区分的,都用类型区分。
# 9.3 第三步:在 API 边界加属性
公共头文件里的每个函数过一遍:
- 返回值能丢吗?不能 →
[[nodiscard]] - 会抛异常吗?不抛 →
noexcept - 永不返回吗?是 →
[[noreturn]] - 单参构造想隐式吗?不想 →
explicit - 该被禁用吗?是 →
= delete
class FileHandle {
public:
[[nodiscard]] static std::optional<FileHandle> Open(std::string_view path);
[[nodiscard]] bool IsValid() const noexcept;
[[nodiscard]] ssize_t Read(void* buf, size_t n) noexcept;
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
FileHandle(FileHandle&&) noexcept;
FileHandle& operator=(FileHandle&&) noexcept;
};
2
3
4
5
6
7
8
9
10
11
# 9.4 第四步:把约束编进模板
模板代码用 static_assert 或 concept 把"用户必须满足的类型条件"明说:
template<class T>
requires std::is_trivially_copyable_v<T>
class FastBuffer { /* ... */ };
2
3
而不是写一大段文档说"T 必须可平凡拷贝,否则 UB"。
# 9.5 第五步:上 Lint 守门
CI 里加 clang-tidy(限于 diff),加 cppcheck,开 ASan + UBSan 跑测试。
PR → CI:
├─ clang-format check
├─ build with GCC + -Werror
├─ build with Clang + -Werror
├─ clang-tidy on diff
├─ cppcheck
└─ unit tests with ASan + UBSan
2
3
4
5
6
7
任一红 → PR 不能合。
# 10. 典型场景速查
# 10.1 工厂函数返回值不能丢
[[nodiscard]] std::unique_ptr<Conn> CreateConn();
[[nodiscard]] std::optional<Config> LoadConfig(std::string_view path);
[[nodiscard("must release with Free()")]] void* Alloc(size_t n);
2
3
# 10.2 资源类禁止拷贝
class TcpSocket {
public:
explicit TcpSocket(int fd);
~TcpSocket();
TcpSocket(const TcpSocket&) = delete;
TcpSocket& operator=(const TcpSocket&) = delete;
TcpSocket(TcpSocket&&) noexcept;
TcpSocket& operator=(TcpSocket&&) noexcept;
};
2
3
4
5
6
7
8
9
主线二的根治写法。
# 10.3 容器大小的编译期保证
template<class T, size_t N>
class CircularBuffer {
static_assert(N > 0, "buffer must not be empty");
static_assert((N & (N - 1)) == 0, "N must be power of 2 (for fast modulo)");
T data_[N];
};
2
3
4
5
6
# 10.4 单位混淆(秒与毫秒)
C++ 标准库已经替你做好了:
#include <chrono>
using namespace std::chrono_literals;
void Sleep(std::chrono::milliseconds ms);
Sleep(500ms); // ✅
Sleep(1s); // ✅ 自动转毫秒
Sleep(500); // ❌ 编译错误,避免"是秒还是毫秒"
2
3
4
5
6
7
8
所有时间相关 API 都该用 std::chrono——这是 STL 给的免费强类型。
# 10.5 状态机非法迁移
用 enum class + switch 全枚举:
enum class State { Init, Connecting, Connected, Closed };
State Next(State s, Event e) {
switch (s) {
case State::Init: return e == Event::Open ? State::Connecting : s;
case State::Connecting: return e == Event::Ack ? State::Connected : s;
case State::Connected: return e == Event::Close? State::Closed : s;
case State::Closed: return s;
}
// -Wswitch 配合 enum class 会强制覆盖所有枚举值,遗漏即警告
}
2
3
4
5
6
7
8
9
10
11
新加 State::Failed?所有 switch (State) 立刻警告"漏了 case"。
# 10.6 API 版本演进
// v1.0:发布
int OldFormat(const std::string& s);
// v1.5:建议迁移
[[deprecated("use NewFormat() since v1.5")]]
int OldFormat(const std::string& s);
// v2.0:移除前最后一个版本
[[deprecated("OldFormat will be removed in v2.0; use NewFormat()")]]
int OldFormat(const std::string& s);
// v2.0:删除(或保留 = delete 让调用方明确报错)
int OldFormat(const std::string&) = delete;
2
3
4
5
6
7
8
9
10
11
12
13
逐步收紧,避免一夜之间所有调用方报错。
# 11. 工程化最佳实践
# 11.1 项目级警告基线
把警告组写成一个 CMake interface target,全项目共享:
add_library(project_warnings INTERFACE)
target_compile_options(project_warnings INTERFACE
-Wall -Wextra -Wpedantic
-Wshadow -Wnon-virtual-dtor
-Wold-style-cast -Wcast-align
-Wunused -Woverloaded-virtual
-Wconversion -Wnull-dereference
-Wdouble-promotion -Wformat=2
$<$<CXX_COMPILER_ID:GNU>:-Wmaybe-uninitialized -Wlogical-op>
$<$<CXX_COMPILER_ID:Clang>:-Wthread-safety>
-Werror
)
# 每个 target 引用它
target_link_libraries(myapp PRIVATE project_warnings)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
好处:
- 警告策略一处定义。
- 调整时只改一个文件,全项目生效。
- 测试 target / 第三方包装 target 可以选择不引用,降低耦合。
# 11.2 CI 多编译器矩阵
# GitHub Actions 示例
strategy:
matrix:
compiler:
- { cc: gcc-11, cxx: g++-11 }
- { cc: gcc-13, cxx: g++-13 }
- { cc: clang-15, cxx: clang++-15 }
- { cc: clang-17, cxx: clang++-17 }
build_type: [Debug, RelWithDebInfo]
2
3
4
5
6
7
8
9
两个编译器 × 两个 build type = 4 个矩阵任务。不同编译器抓的 bug 完全不同。
- GCC 抓得到
-Wmaybe-uninitialized、-Wlogical-op; - Clang 抓得到 thread safety、lifetime(实验);
- 不同版本的语义分析持续在进化。
CI 通过 = 所有矩阵都过。
# 11.3 Lint 不能阻塞主干
老项目接入 clang-tidy / 严格警告,全量跑会有几千条告警,团队心态崩塌。正确策略:
只对 PR diff 检查:
# git diff 出改动行,clang-tidy 只看这些行
git diff origin/main...HEAD --name-only | \
xargs clang-tidy --line-filter='[...]' -p build/
2
3
- 历史问题不动 → 不会突然红一片。
- 新代码必过 → 增量提升质量。
- 配合"每周修 10 个历史问题"的迁移计划,三个月后整库干净。
# 11.4 把约束写进类型
回到第 9.2 的口号——"任何团队约定都先想想能不能用类型表达"。下面是几条具体例子:
| 团队约定 | 类型实现 |
|---|---|
| "Open 后必须 Close" | 用 RAII 类,Close 是析构 |
| "ID 不能直接相加" | 强类型 + 不重载 operator+ |
| "回调必须线程安全" | 把回调签名声明为 Callback noexcept |
| "这个函数不会修改入参" | 参数加 const |
| "返回的指针必须释放" | 返回 std::unique_ptr |
| "Status 必须检查" | class [[nodiscard]] Status |
写在类型里,编译器替你检查;写在文档里,靠人记。
# 11.5 让新人也能加防御
最后一条最重要——前面所有武器,不是高级工程师专属。新人加入项目第一周就应该会写 [[nodiscard]]、= delete、static_assert。所以:
- 写一份内部 wiki:列出 5-10 个常见场景的 before/after。
- PR 模板里加 checklist:是否考虑了
[[nodiscard]]?是否考虑了= delete? - Code review 时主动提示:每次 review 都问一句"这个 API 能编译期保证更多吗"。
文化一旦建立,防御就成了肌肉记忆。
# 12. 综合案例串讲
# 12.1 重新审视 1.1 的事故
把主线一完整地修一遍:
第一步:给所有"软失败函数"加 [[nodiscard]]
// account.h
[[nodiscard]] bool TryDeduct(Account& acc, long n);
[[nodiscard]] bool TryReserve(Account& acc, long n);
[[nodiscard]] bool TryCommit(Account& acc, long n);
2
3
4
立刻有 7 处编译警告。修第一处:
void OnPayment(uint64_t uid, long amount) {
auto& acc = GetAccount(uid);
if (!TryDeduct(acc, amount)) { // ← 强制检查
OnPaymentFailed(uid, "insufficient balance");
return;
}
MarkPaid(uid);
PushReceipt(uid, amount);
}
2
3
4
5
6
7
8
9
第二步:把"错误状态"提升为 Status 类型
更彻底的修法:把 bool 返回换成 Status:
class [[nodiscard]] Status {
public:
static Status OK();
static Status InsufficientBalance();
static Status NetworkError();
bool ok() const;
std::string_view message() const;
private:
int code_;
const char* msg_;
};
Status TryDeduct(Account& acc, long n);
2
3
4
5
6
7
8
9
10
11
12
13
由于 class [[nodiscard]],所有返回 Status 的函数都自动获得"必须检查"语义——不需要每个函数声明都重复写属性。
第三步:用 absl::StatusOr 或 std::expected 把"结果与错误绑一起"
// C++23
std::expected<Receipt, Status> Pay(uint64_t uid, long amount);
auto r = Pay(uid, 100);
if (!r) {
LogError(r.error().message());
return;
}
SendReceipt(*r);
2
3
4
5
6
7
8
9
收益:
原约定(靠人记) 新约定(编译器强制)
───────────────────── ──────────────────────────
看见 TryXxx 要检查返回值 返回 Status / expected,丢弃即编译错误
小心余额不足的分支 类型层强制处理 error 分支
财务对账每天差几百 编译期挡住,事故消失
2
3
4
5
主线一最终结论:一次事故 → 加 [[nodiscard]] → 升级 Status 类型 → CI 强制 -Werror=unused-result → 同类 bug 永远不会复现。整个修复没有 if 检查,全部靠类型和属性。
# 12.2 编译期防御速查表
┌──────────────────────────────────────────────────────────────┐
│ 场景 武器 备注 │
├──────────────────────────────────────────────────────────────┤
│ 类型大小约定 static_assert(sizeof…) │
│ Try/工厂返回值 [[nodiscard]] │
│ 错误类型 class [[nodiscard]] Status │
│ 禁拷贝 = delete + Rule of Five │
│ 禁某种重载 void f(bool) = delete │
│ 禁默认构造 T() = delete │
│ 单参构造防隐式 explicit │
│ 弱类型 ID 混用 struct Uid { int v; } │
│ 非整型常量集合 enum class │
│ switch 故意穿透 [[fallthrough]] │
│ 故意未用 [[maybe_unused]] │
│ 接口废弃 [[deprecated("...")]] │
│ 不返回的函数 [[noreturn]] │
│ 函数不抛 noexcept │
│ 模板类型约束 concept / requires / static_assert │
│ 编译期计算 constexpr / consteval │
│ 时间单位防混 std::chrono::milliseconds 等 │
│ 容器大小约束 template<size_t N>+static_assert │
│ 风格一致 clang-format │
│ 全静态分析 clang-tidy + cppcheck │
│ 头文件膨胀 include-what-you-use │
│ 全告警当错 -Wall -Wextra -Werror │
└──────────────────────────────────────────────────────────────┘
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
# 12.3 易错点清单
| # | 反模式 | 正解 | 关联节 |
|---|---|---|---|
| 1 | 函数返回 bool 失败靠注释提醒 | [[nodiscard]] | 4.2 / 12.1 |
| 2 | 类只写析构没禁拷贝 | Rule of Five + = delete | 4.3 / 10.2 |
| 3 | 单参构造没 explicit | 默认就加 explicit | 4.5 |
| 4 | using 当强类型用 | 用 struct 包裹 | 6.1 |
| 5 | 老式 enum 暴露常量 | enum class | 6.2 |
| 6 | 模板报错刷屏 | static_assert 或 concept | 6.3 / 6.4 |
| 7 | if/else 替代 if constexpr | C++17 用 if constexpr | 6.5 |
| 8 | switch 漏 enum 值 | enum class + -Wswitch | 5.1 / 10.5 |
| 9 | 时间用裸 int 表示 | std::chrono | 10.4 |
| 10 | CI 只有一个编译器 | GCC + Clang 矩阵 | 11.2 |
# 12.4 思考题
- 公共库的
class Result { bool ok; std::string msg; }经常被忽略 ok 字段,怎么改? - 团队约定"所有 ID 类型不能互相比较",用什么方式编译期保证?
- 已有项目几千条
-Wsign-compare警告,怎么开-Werror而不影响主干? - 模板
template<class T> class Pool要求 T 可平凡拷贝且 size ≤ 64 字节,写法? - 一个
Mutex::Lock()返回lock_guard,用户经常写m.Lock();(没接住),怎么阻止? - 单参构造
class Money { Money(double); },怎么让Money m = 100;仍合法但Sleep(money)报错?
参考答案散落在各章节,回去翻一遍就能找到。
# 13. 章节小结
# 13.1 八条铁律
- 所有警告默认全开 +
-Werror——这是最便宜的防御。 Try/工厂/错误码函数加[[nodiscard]]——主线一同款。- 资源类遵循 Rule of Five——析构、拷贝、移动一起想清楚。
- 单参构造默认
explicit——除非真的想隐式。 - 能用类型表达的约定永远不要用注释——
enum class替代魔法数字,强类型替代裸 int。 - 模板加
static_assert或 concept——给用户友好的错误信息。 - CI 跑多编译器 + clang-tidy + ASan/UBSan——多重防线最稳。
- 能编译期完成的检查永远不要拖到运行期——成本差 1000 倍。
# 13.2 与上下章关系
- 上承第 30 章《多线程锁选型》:那里用
GUARDED_BY注解 +-Wthread-safety把"锁约定"编进类型。本章把这种思想推广到所有约定。 - 上承第 29 章《异常安全与 RAII》:RAII 本质也是"把资源管理编进类型"——和本章的"把约定编进类型"一脉相承。
- 前后呼应第 20 章《信号崩溃快速排查》、第 22 章《Sanitizer》:那两章讲"运行期抓 bug",本章讲"编译期堵漏"。两端配合才能形成完整防线。
# 13.3 进阶阅读
- 《C++ Core Guidelines》Bjarne Stroustrup, Herb Sutter——本章很多建议的官方出处。
- 《Effective Modern C++》Scott Meyers——
= delete、noexcept、explicit的深度讨论。 - 《C++ Templates: The Complete Guide》Vandevoorde / Josuttis——concept、SFINAE、
static_assert的全面手册。 - 《CppCon talk: Practical Type Erasure》Klaus Iglberger——把约束编进类型的高级技巧。
- LLVM 文档 "Writing a clang-tidy Check"——想为团队定制 Lint。
- Jason Turner 的 cppbestpractices GitHub 仓库——CMake 警告基线模板。
编译期防御不是"高级技巧",而是"基本卫生"。每一个
[[nodiscard]]、每一个= delete、每一个static_assert,都是在替未来的你拦一个 bug。今天五分钟加上去,明天可能省下十小时排查。回到第 9 章的五步,从你最熟悉的那个项目开始,今晚就能落第一刀。