代码重构:十年 C++ 工程师的经验与原则

安全网、时机、暗礁:C++ 重构的纪律与实践清单。

一、先说清”重构”是什么

很多人把”重构”当”清理代码”的统称,这很危险。重构有严格定义:

在不改变软件可观察行为的前提下,调整其内部结构,使之更易理解、更易修改。

这个定义里藏着两条硬约束:

  1. 可观察行为不变——输出、接口、副作用、性能数量级,都不能变。
  2. “不变”由测试判定,不由自信判定。 没有测试网的重构不是重构,是盲改。

C++ 与其他语言最大的不同在于:编译通过 ≠ 行为正确。指针、生命周期、隐式转换、重载解析、未定义行为——任何一处都可能让”结构变好了、行为悄悄变坏了”。这就是为什么 C++ 重构的纪律必须比其他语言更严。

二、原则一:安全网先行,没有网不许走钢丝

重构的第一件事不是改代码,是布置安全网。我的清单按顺序执行:

1. 编译告警基线:-Wall -Wextra(尽量 -Wpedantic),基线必须清零或书面豁免
2. 静态分析:clang-tidy(保底开 bugprone / performance / modernize 系列)
3. 动态检测:测试跑在 -fsanitize=address,undefined 下;多线程代码加 TSan
4. 测试网:重构目标的关键路径必须有可断言用例——哪怕是后补的最小回归集
5. 版本纪律:小步提交,每一步都可编译、可运行、可回滚

我特别强调第 5 条。小步提交的价值不在”看起来整洁”,而在于:

  • git bisect 能精确定位行为改变的那一步;
  • 每一步出问题,回滚成本是五分钟而不是五天;
  • Code Review 粒度可控——一次 3000 行的”重构 PR”没有人会真审。

经验法则:一个提交只做一类变换。“重命名 + 提取函数 + 换容器”三件事混在一个 commit 里,等于放弃了 bisect 的全部价值。

三、原则二:认准三种合法的重构时机

1. Rule of Three(三次法则)

第一次写重复代码,忍;第二次,皱眉;第三次出现同样结构——此时收敛。过早抽象是负债,因为你还不知道真实的变化轴在哪里。

2. 预备性重构(Preparatory Refactoring)

要加一个新功能,但发现”加在那个结构上很别扭”——先把现有代码整形成”新功能能自然落地”的形状,再写新功能。让未来的写法,决定现在的改法。

3. 随手重构(Followup Refactoring)

路过一段烂代码,而你恰好完全理解它:顺手收拾。但有个下限——不理解不顺手改。带着侥幸的”微调”,在 C++ 里经常是段错误的种子。

什么时候该推倒重写而不是重构? 三问:

  • 接口与实现是否已无法分离?(重构会辐射到无法承受的范围)
  • 是否存在可靠的测试网可以迁移?(没有网的重写比没有网的重构更危险)
  • 领域知识是否还在?(没有人懂的重写 = 把十年前踩过的坑重新踩一遍)

三项里两项”否”,老老实实继续重构。

四、原则三:绕开 C++ 的六大暗礁

以下每一条都是真金白银换来的教训。

暗礁一:所有权与析构——最先动,收益最大

裸 new / delete 是重构的第一优先目标,手法固定:

// Before:所有权悬空,泄漏与 double-delete 双重风险
Widget* w = new Widget(...);
delete w;

// After:所有权写进了类型系统
auto w = std::make_unique<Widget>(/* ... */);

把所有权从”注释里约定的默契”变成”类型系统强制的契约”,是 C++ 重构里性价比最高的单一动作。所有权形态错了,后面所有重构都在流沙上盖楼。 顺带落实两条:

  • Rule of Zero / Rule of Five:有自定义析构、拷贝、移动其一,五个都要想清楚;
  • 多态基类必须有 virtual 析构——delete basePtr 一路查到顶。

暗礁二:string_view / span——重构引入的新悬垂源

违反直觉但真实:std::string_view 和 gsl::span 是现代化利器,也是新悬垂源。

std::string_view getName() {
    std::string tmp = buildName();   // 局部临时
    return tmp;                      // 悬垂:返回的 view 指向已析构的缓冲
}

铁律:view 类参数只进不出。 原本返回 std::string 的接口,不要”顺手优化”成返回 string_view——那不是重构,是接口语义变更(见暗礁五的定义)。

暗礁三:头文件与 ABI——先算成本再动接口

C++ 里改一个头文件,重编辐射常常出乎意料。动接口之前先回答三个问题:

  1. 这是不是外部依赖的 ABI 边界? 是则只能加不能改:新增重载、inline namespace 版本化、PImpl 隔离内存布局。
  2. 依赖了多少下游编译单元?(用构建系统的重编耗时验证,不要拍脑袋)
  3. 能否用前置声明把 #include 从头文件挪进 .cpp?

其中 include 整理是低成本高收益的重构,优先做。

暗礁四:宏——重构的天花板

宏不做语义分析,所有工具(IDE、clang-tidy、重构器)对它都失明。策略是窒息法:

  • 不再产生新的宏;
  • 旧宏每次路过时,在同一翻译单元内用 constexpr / 模板 / inline 函数替换;
  • 完全消解需要跨文件的,单独开任务并全量回归。

永远不要在”顺手”提交里动宏——那本质上是文本手术。

暗礁五:隐式行为断层——编译沉默的语义漂移

C++ 重构里最阴险的一类问题:下面这些变换看着等价,实际会改变行为,而编译器一声不吭:

看似无害的”重构”实际后果
把传值参数改为 const&若该类型可隐式转换,重载决议结果会变,调用漂移到别的函数
给签名加/去 noexceptstd::vector 等容器的扩容搬移策略改变,异常路径行为不同
函数默认实参挪动位置默认实参是编译期调用点注入,库场景新旧库混用各取默认值
热循环里把前缀 ++it 写成后缀std::set/map 迭代器后缀要构造副本,性能级差异
返回成员引用改为返回拷贝多线程场景丢失一致性快照语义

对策只有一条:改签名前,把所有调用点拉出来看一遍。IDE 的 Find References 与 grep 双交叉验证(IDE 的死角远比你想象的多);改完签名后盯着编译器确认:接缝处的调用点是否全部显式化、是否如预期。

暗礁六:继承体系——默认往组合的方向推

遇到继承体系,依次问:

  1. 子类只是数据不同?→ 用组合 + 一个数据成员收敛(C++17 的 std::variant 是标准答案,编译期收敛、还省掉虚表开销)。
  2. 行为可枚举、在精选位置分发?→ 考虑 std::visit / 函数对象。
  3. 真正需要运行时多态、对象边界穿透?→ 才保留虚函数。
  4. 虚函数禁止默认实参——默认实参静态绑定,虚函数动态绑定,两者叠加是经典行为事故。

五、重构的手法节拍

理想的单步重构:20–40 分钟内落一次构建 + 全量测试 + 提交。常用手法按频次排序:

手法适用场景
提取函数 / 提取变量80% 场景的起点
裸 new/delete → 智能指针第一优先,先于任何结构性改动
引入参数对象参数超过 4 个,或参数成组出现
以查询取代临时变量长函数中被复用又被污染的中间计算
include 整理实现头挪进 .cpp,前置声明断依赖
数据型继承 → std::variant / 组合见暗礁六

六、如何度量重构成果

  • 可变性:下一个真实需求的代码触碰面是否变小——这是唯一真实的度量;
  • 依赖图:include 依赖是否变浅、环是否消失;
  • 构建时间:PImpl、前置声明等手法后,重编辐射是否收敛;
  • 测试插入点:目标代码是否长出了新的可测试接缝。

七、心法十条

  1. 编译通过是及格线,行为不变才是及格。
  2. 没有测试网的重构不是重构。
  3. 一个 commit 一类变换,让 git bisect 始终可用。
  4. 所有权先于其他一切结构性改动。
  5. view 类参数只进不出。
  6. 接口是合同,只加不改;必要时 PImpl 隔离布局。
  7. 宏是重构的止损线:先窒息、后消解、绝不”顺手”动。
  8. 改签名前先看全部调用点。
  9. 默认往组合的方向推继承。
  10. 重构到被理解为止——无法令人信服地解释一处改动为什么更清晰,就是改错了。