编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

    • 开发技巧

      • 信号崩溃快速排查
      • ASan内存三件套
      • GDB十命令速查
      • CoreDump破案
      • perf火焰图实战
      • 迭代器失效陷阱
      • 智能指针选型
      • 异常安全RAII
      • 多线程锁选型
      • 编译期防御
        • 1. 案例引入:两条主线
          • 1.1 主线一:被忽略的返回值
          • 1.2 主线二:12 行的隐式拷贝
          • 1.3 顺藤摸到根因
          • 1.4 本篇要回答什么
        • 2. 编译期能拦住的九种 bug
          • 2.1 返回值被忽略
          • 2.2 隐式拷贝重对象
          • 2.3 隐式窄化转换
          • 2.4 函数重载错配
          • 2.5 未初始化使用
          • 2.6 越界访问能编译期推断
          • 2.7 类型大小假设错误
          • 2.8 弱类型 ID 混用
          • 2.9 比较有符号无符号
        • 3. 编译期防御机制总图
          • 3.1 从语法到语义的四道关卡
          • 3.2 编译器、警告、Lint、Sanitizer 的分工
          • 3.3 编译期 vs 运行期的成本曲线
          • 3.4 一段代码的编译流水线
          • 3.5 防御机制总览图
        • 4. 五大编译期武器详解
          • 4.1 static_assert 编译期断言
          • 4.2 nodiscard 返回值警卫
          • 4.3 = delete 删除函数
          • 4.4 constexpr 编译期求值
          • 4.5 explicit 阻止隐式转换
          • 4.6 五件套对照表
        • 5. 警告标志的层次
          • 5.1 -Wall 默认套餐
          • 5.2 -Wextra 加强组
          • 5.3 -Wpedantic 标准党
          • 5.4 -Werror 升级为错误
          • 5.5 单条警告精细控制
          • 5.6 GCC 与 Clang 差异
        • 6. 类型与模板防御
          • 6.1 强类型别名 strong typedef
          • 6.2 enum class 强枚举
          • 6.3 模板里的 static_assert
          • 6.4 concept 与 requires
          • 6.5 SFINAE 与 if constexpr
        • 7. 属性与注解
          • 7.1 [[nodiscard]] 进阶用法
          • 7.2 [[maybe_unused]] 与未用变量
          • 7.3 [[deprecated]] 优雅废弃
          • 7.4 [[fallthrough]] switch 防误穿
          • 7.5 [[noreturn]] 与流程分析
        • 8. 静态分析与 Lint
          • 8.1 clang-tidy 速览
          • 8.2 cppcheck 互补
          • 8.3 include-what-you-use
          • 8.4 clang-format 与一致性
          • 8.5 IDE 集成与 CI 落地
        • 9. 五步加固方法论
          • 9.1 第一步:把警告调到最严
          • 9.2 第二步:用类型替代约定
          • 9.3 第三步:在 API 边界加属性
          • 9.4 第四步:把约束编进模板
          • 9.5 第五步:上 Lint 守门
        • 10. 典型场景速查
          • 10.1 工厂函数返回值不能丢
          • 10.2 资源类禁止拷贝
          • 10.3 容器大小的编译期保证
          • 10.4 单位混淆(秒与毫秒)
          • 10.5 状态机非法迁移
          • 10.6 API 版本演进
        • 11. 工程化最佳实践
          • 11.1 项目级警告基线
          • 11.2 CI 多编译器矩阵
          • 11.3 Lint 不能阻塞主干
          • 11.4 把约束写进类型
          • 11.5 让新人也能加防御
        • 12. 综合案例串讲
          • 12.1 重新审视 1.1 的事故
          • 12.2 编译期防御速查表
          • 12.3 易错点清单
          • 12.4 思考题
        • 13. 章节小结
          • 13.1 八条铁律
          • 13.2 与上下章关系
          • 13.3 进阶阅读
  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 开发技巧
杨充
2026-06-15
目录

编译期防御

# 第31章:编译期防御编程

# 目录介绍

  • 1. 案例引入:两条主线
    • 1.1 主线一:被忽略的返回值
    • 1.2 主线二:12 行的隐式拷贝
    • 1.3 顺藤摸到根因
    • 1.4 本篇要回答什么
  • 2. 编译期能拦住的九种 bug
    • 2.1 返回值被忽略
    • 2.2 隐式拷贝重对象
    • 2.3 隐式窄化转换
    • 2.4 函数重载错配
    • 2.5 未初始化使用
    • 2.6 越界访问能编译期推断
    • 2.7 类型大小假设错误
    • 2.8 弱类型 ID 混用
    • 2.9 比较有符号无符号
  • 3. 编译期防御机制总图
    • 3.1 从语法到语义的四道关卡
    • 3.2 编译器、警告、Lint、Sanitizer 的分工
    • 3.3 编译期 vs 运行期的成本曲线
    • 3.4 一段代码的编译流水线
    • 3.5 防御机制总览图
  • 4. 五大编译期武器详解
    • 4.1 static_assert 编译期断言
    • 4.2 nodiscard 返回值警卫
    • 4.3 = delete 删除函数
    • 4.4 constexpr 编译期求值
    • 4.5 explicit 阻止隐式转换
    • 4.6 五件套对照表
  • 5. 警告标志的层次
    • 5.1 -Wall 默认套餐
    • 5.2 -Wextra 加强组
    • 5.3 -Wpedantic 标准党
    • 5.4 -Werror 升级为错误
    • 5.5 单条警告精细控制
    • 5.6 GCC 与 Clang 差异
  • 6. 类型与模板防御
    • 6.1 强类型别名 strong typedef
    • 6.2 enum class 强枚举
    • 6.3 模板里的 static_assert
    • 6.4 concept 与 requires
    • 6.5 SFINAE 与 if constexpr
  • 7. 属性与注解
    • 7.1 [[nodiscard]] 进阶用法
    • 7.2 [[maybe_unused]] 与未用变量
    • 7.3 [[deprecated]] 优雅废弃
    • 7.4 [[fallthrough]] switch 防误穿
    • 7.5 [[noreturn]] 与流程分析
  • 8. 静态分析与 Lint
    • 8.1 clang-tidy 速览
    • 8.2 cppcheck 互补
    • 8.3 include-what-you-use
    • 8.4 clang-format 与一致性
    • 8.5 IDE 集成与 CI 落地
  • 9. 五步加固方法论
    • 9.1 第一步:把警告调到最严
    • 9.2 第二步:用类型替代约定
    • 9.3 第三步:在 API 边界加属性
    • 9.4 第四步:把约束编进模板
    • 9.5 第五步:上 Lint 守门
  • 10. 典型场景速查
    • 10.1 工厂函数返回值不能丢
    • 10.2 资源类禁止拷贝
    • 10.3 容器大小的编译期保证
    • 10.4 单位混淆(秒与毫秒)
    • 10.5 状态机非法迁移
    • 10.6 API 版本演进
  • 11. 工程化最佳实践
    • 11.1 项目级警告基线
    • 11.2 CI 多编译器矩阵
    • 11.3 Lint 不能阻塞主干
    • 11.4 把约束写进类型
    • 11.5 让新人也能加防御
  • 12. 综合案例串讲
    • 12.1 重新审视 1.1 的事故
    • 12.2 编译期防御速查表
    • 12.3 易错点清单
    • 12.4 思考题
  • 13. 章节小结
    • 13.1 八条铁律
    • 13.2 与上下章关系
    • 13.3 进阶阅读

# 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);
}
1
2
3
4
5
6
7
8
9

TryDeduct 内部对余额不足会返回 false,但不会抛异常——按团队约定这是个"软失败",调用方应该检查返回值。但 OnPayment 漏掉了这次检查,导致:

余额不足 → TryDeduct 返回 false → 调用方忽略 →
MarkPaid 仍然执行 → 用户没扣到钱却显示"已付款"
1
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'           │
└──────────────────────────────────────────────────────────┘
1
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 触发拷贝/移动
1
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!
}
1
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;
};
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 ─ 业务故障
1
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 │
└──────────────────────────────────────────────┘
1
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 是垃圾
}
1
2
3
4
5
6

C++ 默认允许"丢弃返回值",因为某些函数(如 printf)的返回值确实没人在意。但**"成功才用 v"的函数必须强制检查**——这就是 [[nodiscard]] 干的事:

[[nodiscard]] bool TryParse(std::string_view s, int& out);
1

之后 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_ 指针,两个对象关同一个文件
};
1
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 截断
1
2
3

C++11 起的列表初始化禁止窄化:

int x{3.14};            // 编译错误:narrowing conversion
int y = 3.14;           // 仍允许(兼容 C 习惯)
1
2

更进一步可以打开 -Wconversion -Wnarrowing 把所有窄化都列出来。

# 2.4 函数重载错配

void Send(int    code);     // 错误码
void Send(double level);    // 噪音电平
Send(true);                 // 调到哪个?bool→int 优于 bool→double,但你真的想这样?
1
2
3

修法:禁掉不想要的重载

void Send(int);
void Send(double);
void Send(bool) = delete;   // 显式禁掉 bool 调用
1
2
3

# 2.5 未初始化使用

int x;
if (cond) x = 1;
Print(x);                   // 如果 !cond,x 是未定义
1
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);   // 编译期断言
1
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");
1
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 哪个是金额?
1
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});   // 编译错误!
1
2
3
4
5

6.1 节会详谈。

# 2.9 比较有符号无符号

for (int i = 0; i < v.size(); ++i)    // -Wsign-compare 警告
1

v.size() 是 size_t,i 是 int。这种比较在 i 变负时会出错。修法:

for (size_t i = 0; i < v.size(); ++i)
// 或
for (auto& x : v)
1
2
3

九种翻车里,(2.1, 2.2) 是主线案例正面命中,其它七种后续章节会逐一映射到具体武器。核心立场:能在编译期拦住的 bug,永远不要留给运行期。

# 3. 编译期防御机制总图

# 3.1 从语法到语义的四道关卡

C++ 编译器对源码的检查可以分四层:

   ┌────────────────────────────────────────┐
   │ 1. 词法/语法     拼写错、漏分号、括号  │
   ├────────────────────────────────────────┤
   │ 2. 类型检查      参数类型、重载解析    │
   ├────────────────────────────────────────┤
   │ 3. 语义警告      可疑但合法(-Wall …) │
   ├────────────────────────────────────────┤
   │ 4. 流程分析      未初始化、不可达分支  │
   └────────────────────────────────────────┘
1
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    ← 财务损失,监管介入
1
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 违反
              │
              ▼
   可执行文件
1
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              │
└──────────────────────────────────────────────────────────────┘
1
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 必须带消息
1
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");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

核心要点:

  1. 零运行期开销——它根本不进二进制。
  2. 只能用编译期可求值的表达式:constexpr 变量、sizeof、type_traits、constexpr 函数。
  3. 错误消息要"说人话":写出"为什么必须满足",而不是复述条件。

反例:

static_assert(sizeof(T) == 4);                   // 失败时只能看到 cond,不知道为何
static_assert(sizeof(T) == 4,
              "T must be uint32_t-sized: wire format depends on it");  // ✅
1
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");
}
1
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 的函数都警告
1
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 自动警告
1
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;
};
1
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;
};
1
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' 被当字符串
1
2
3

(3) 禁某些重载的隐式提升:

class Color {
public:
    Color(uint32_t rgb);            // 允许 RGB 整数构造
    Color(double) = delete;          // 但禁掉浮点意外调用
};
Color c(0xff00ff);            // OK
Color d(3.14);                // 编译错误
1
2
3
4
5
6
7

(4) 禁默认构造:

class NoDefault {
public:
    NoDefault() = delete;
    NoDefault(int);
};
NoDefault x;          // 编译错误
NoDefault y(42);      // OK
1
2
3
4
5
6
7

Rule of Five 简述:定义了析构函数 / 拷贝构造 / 拷贝赋值 / 移动构造 / 移动赋值 中任意一个,就要明确处理另外四个。要么实现要么 = delete。这是 C++ 资源类的黄金法则。

反例:

class Bad {
    ~Bad() { close(fd_); }        // 只写了析构,没禁拷贝
};                                 // → 默认拷贝构造导致 double-close
1
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,必须编译期初始化
1
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;
1
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");
1
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;
};
1
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
1
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    │ 隐式构造/转换    │ 单参构造默认就加      │
└────────────────┴──────────┴──────────────────┴──────────────────────┘
1
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+ 项
1
2
3
4
5
6
7
8
9
10

任何新项目第一行 CMakeLists 都该加:

target_compile_options(myapp PRIVATE -Wall)
1

# 5.2 -Wextra 加强组

-Wall 之上再加约 20 项:

-Wempty-body            if/while/for 后跟空 ;
-Wmissing-field-initializers  聚合初始化漏字段
-Wsign-compare          有符号/无符号比较
-Wunused-parameter      函数参数没用
-Wshift-negative-value
-Wmissing-braces
...
1
2
3
4
5
6
7

-Wextra 的误报率比 -Wall 高一些,但拦到的 bug 也更多。推荐所有新项目默认开 -Wall -Wextra。

# 5.3 -Wpedantic 标准党

-Wpedantic 强调"严格符合 ISO C++ 标准,不允许任何编译器扩展"。打开后:

-Wpedantic
-Wlanguage-extension-token  使用了非标语法(如 GCC 的 typeof)
1
2

这一档主要给跨编译器项目用——同一份代码要在 GCC、Clang、MSVC 上都过。普通业务项目不强求。

# 5.4 -Werror 升级为错误

g++ -Wall -Wextra -Werror file.cc
1

所有警告变成错误,CI 直接红。这是把"警告"真正落地的关键一步——否则警告永远被人忽略。

但 -Werror 也有坑:

  1. 编译器升级会引入新警告,老代码突然编不过。
  2. 第三方库的头文件可能触发警告,污染你的项目。

更精细的做法:

# 全开 -Werror,但放过特定项
g++ -Wall -Wextra -Werror -Wno-error=deprecated-declarations file.cc

# 仅几项升级为 error,其它仍是 warning
g++ -Wall -Werror=return-type -Werror=unused-result file.cc
1
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
1
2
3
4

Clang 等价:

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wunused-variable"
/* ... */
#pragma clang diagnostic pop
1
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 哪个是金额?
1
2

C++ 的 using 和 typedef 只是别名,不是新类型:

using Uid = int;
using Money = int;
void Transfer(Uid from, Uid to, Money n);
Transfer(123, 100, 456);    // 仍然合法,没区分
1
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});     // ❌ 编译错误(参数顺序错)
1
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}; // ✅
1
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
1
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);        // ✅ 必须显式
1
2
3
4
5

额外功能:可以指定底层类型,便于二进制协议:

enum class Opcode : uint8_t { READ = 1, WRITE = 2 };
static_assert(sizeof(Opcode) == 1);
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:
    /* ... */
};
1
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
1
2
3
4
5
6
7
8
9
10
11

concept 比 static_assert 强的地方:

  1. 错误信息更友好:直接说"不满足 Hashable concept"。
  2. 可以用于函数重载:根据 concept 选择重载。
  3. 可以约束模板参数:写在 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); } // 仅浮点
1
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; }
1
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");
    }
}
1
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
1
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()"
1
2

(3) 加在构造函数上:

class Mutex {
public:
    [[nodiscard]] std::unique_lock<std::mutex> Lock();
};

Mutex m;
m.Lock();                // ⚠ 警告:unique_lock 立刻析构等于没锁
{ auto g = m.Lock(); ... }   // ✅
1
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 看似未用
1
2

或者函数参数因接口约束必须留着但当前实现不用:

void OnEvent([[maybe_unused]] EventId id, const Payload& p) { /* ... */ }
1

vs (void)x 老套路:

void f(int x) { (void)x; /* mark as used */ }
1

(void)x 是 C 时代的做法,能用但不优雅。[[maybe_unused]] 是表意更明确的现代写法。

# 7.3 [[deprecated]] 优雅废弃

接口要废弃但又不能立刻删——给它打标签,让调用方逐步迁移:

[[deprecated("use NewApi() instead")]]
int OldApi();

OldApi();    // ⚠ 警告:"'OldApi' is deprecated: use NewApi() instead"
1
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;
}
1
2
3
4
5

-Wimplicit-fallthrough 会把所有疑似穿透都警告。故意穿透用 [[fallthrough]] 显式声明:

switch (op) {
case READ:
    buf = Read();
    [[fallthrough]];        // 故意,编译器不警告
case WRITE:
    Write(buf);
    break;
}
1
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 分支也没返回"
}
1
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
1
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-*        可读性
1
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: '.*'
1
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/
1

擅长抓的:

  • 内存泄漏(简单模式)
  • 数组越界(字面量级别)
  • 未使用函数
  • 复制粘贴遗漏的修改

# 8.3 include-what-you-use

C++ 编译慢 70% 来自头文件膨胀。IWYU 检查"你 include 了什么,但实际没用":

iwyu_tool.py -p build/ -- -std=c++17
1

输出建议:

foo.cc should add these lines:
#include <vector>            // for std::vector

foo.cc should remove these lines:
- #include "big_header.h"    // lines 4-4
1
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
1
2
3
4
5

CI 加一步:

clang-format --dry-run --Werror $(git ls-files '*.cc' '*.h')
1

代码风格不对直接 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 跑单元测试
1
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
)
1
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);
1
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;
};
1
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 { /* ... */ };
1
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
1
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);
1
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;
};
1
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];
};
1
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);                // ❌ 编译错误,避免"是秒还是毫秒"
1
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 会强制覆盖所有枚举值,遗漏即警告
}
1
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;
1
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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

好处:

  1. 警告策略一处定义。
  2. 调整时只改一个文件,全项目生效。
  3. 测试 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]
1
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/
1
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。所以:

  1. 写一份内部 wiki:列出 5-10 个常见场景的 before/after。
  2. PR 模板里加 checklist:是否考虑了 [[nodiscard]]?是否考虑了 = delete?
  3. 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);
1
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);
}
1
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);
1
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);
1
2
3
4
5
6
7
8
9

收益:

原约定(靠人记)           新约定(编译器强制)
─────────────────────      ──────────────────────────
看见 TryXxx 要检查返回值   返回 Status / expected,丢弃即编译错误
小心余额不足的分支          类型层强制处理 error 分支
财务对账每天差几百          编译期挡住,事故消失
1
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              │
└──────────────────────────────────────────────────────────────┘
1
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 思考题

  1. 公共库的 class Result { bool ok; std::string msg; } 经常被忽略 ok 字段,怎么改?
  2. 团队约定"所有 ID 类型不能互相比较",用什么方式编译期保证?
  3. 已有项目几千条 -Wsign-compare 警告,怎么开 -Werror 而不影响主干?
  4. 模板 template<class T> class Pool 要求 T 可平凡拷贝且 size ≤ 64 字节,写法?
  5. 一个 Mutex::Lock() 返回 lock_guard,用户经常写 m.Lock();(没接住),怎么阻止?
  6. 单参构造 class Money { Money(double); },怎么让 Money m = 100; 仍合法但 Sleep(money) 报错?

参考答案散落在各章节,回去翻一遍就能找到。

# 13. 章节小结

# 13.1 八条铁律

  1. 所有警告默认全开 + -Werror——这是最便宜的防御。
  2. Try/工厂/错误码 函数加 [[nodiscard]]——主线一同款。
  3. 资源类遵循 Rule of Five——析构、拷贝、移动一起想清楚。
  4. 单参构造默认 explicit——除非真的想隐式。
  5. 能用类型表达的约定永远不要用注释——enum class 替代魔法数字,强类型替代裸 int。
  6. 模板加 static_assert 或 concept——给用户友好的错误信息。
  7. CI 跑多编译器 + clang-tidy + ASan/UBSan——多重防线最稳。
  8. 能编译期完成的检查永远不要拖到运行期——成本差 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 章的五步,从你最熟悉的那个项目开始,今晚就能落第一刀。

上次更新: 2026/06/16, 22:09:27
多线程锁选型
README

← 多线程锁选型 README→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式