代码重构:十年 C++ 工程师的经验与原则
收录于 Cplusplus 浏览量
一、先说清”重构”是什么
很多人把”重构”当”清理代码”的统称,这很危险。重构有严格定义:
在不改变软件可观察行为的前提下,调整其内部结构,使之更易理解、更易修改。
这个定义里藏着两条硬约束:
- 可观察行为不变——输出、接口、副作用、性能数量级,都不能变。
- “不变”由测试判定,不由自信判定。 没有测试网的重构不是重构,是盲改。
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++ 里改一个头文件,重编辐射常常出乎意料。动接口之前先回答三个问题:
- 这是不是外部依赖的 ABI 边界? 是则只能加不能改:新增重载、
inline namespace版本化、PImpl 隔离内存布局。 - 依赖了多少下游编译单元?(用构建系统的重编耗时验证,不要拍脑袋)
- 能否用前置声明把
#include从头文件挪进.cpp?
其中 include 整理是低成本高收益的重构,优先做。
暗礁四:宏——重构的天花板
宏不做语义分析,所有工具(IDE、clang-tidy、重构器)对它都失明。策略是窒息法:
- 不再产生新的宏;
- 旧宏每次路过时,在同一翻译单元内用
constexpr/ 模板 / inline 函数替换; - 完全消解需要跨文件的,单独开任务并全量回归。
永远不要在”顺手”提交里动宏——那本质上是文本手术。
暗礁五:隐式行为断层——编译沉默的语义漂移
C++ 重构里最阴险的一类问题:下面这些变换看着等价,实际会改变行为,而编译器一声不吭:
| 看似无害的”重构” | 实际后果 |
|---|---|
把传值参数改为 const& | 若该类型可隐式转换,重载决议结果会变,调用漂移到别的函数 |
给签名加/去 noexcept | std::vector 等容器的扩容搬移策略改变,异常路径行为不同 |
| 函数默认实参挪动位置 | 默认实参是编译期调用点注入,库场景新旧库混用各取默认值 |
热循环里把前缀 ++it 写成后缀 | std::set/map 迭代器后缀要构造副本,性能级差异 |
| 返回成员引用改为返回拷贝 | 多线程场景丢失一致性快照语义 |
对策只有一条:改签名前,把所有调用点拉出来看一遍。IDE 的 Find References 与 grep 双交叉验证(IDE 的死角远比你想象的多);改完签名后盯着编译器确认:接缝处的调用点是否全部显式化、是否如预期。
暗礁六:继承体系——默认往组合的方向推
遇到继承体系,依次问:
- 子类只是数据不同?→ 用组合 + 一个数据成员收敛(C++17 的
std::variant是标准答案,编译期收敛、还省掉虚表开销)。 - 行为可枚举、在精选位置分发?→ 考虑
std::visit/ 函数对象。 - 真正需要运行时多态、对象边界穿透?→ 才保留虚函数。
- 虚函数禁止默认实参——默认实参静态绑定,虚函数动态绑定,两者叠加是经典行为事故。
五、重构的手法节拍
理想的单步重构:20–40 分钟内落一次构建 + 全量测试 + 提交。常用手法按频次排序:
| 手法 | 适用场景 |
|---|---|
| 提取函数 / 提取变量 | 80% 场景的起点 |
裸 new/delete → 智能指针 | 第一优先,先于任何结构性改动 |
| 引入参数对象 | 参数超过 4 个,或参数成组出现 |
| 以查询取代临时变量 | 长函数中被复用又被污染的中间计算 |
| include 整理 | 实现头挪进 .cpp,前置声明断依赖 |
数据型继承 → std::variant / 组合 | 见暗礁六 |
六、如何度量重构成果
- 可变性:下一个真实需求的代码触碰面是否变小——这是唯一真实的度量;
- 依赖图:include 依赖是否变浅、环是否消失;
- 构建时间:PImpl、前置声明等手法后,重编辐射是否收敛;
- 测试插入点:目标代码是否长出了新的可测试接缝。
七、心法十条
- 编译通过是及格线,行为不变才是及格。
- 没有测试网的重构不是重构。
- 一个 commit 一类变换,让
git bisect始终可用。 - 所有权先于其他一切结构性改动。
- view 类参数只进不出。
- 接口是合同,只加不改;必要时 PImpl 隔离布局。
- 宏是重构的止损线:先窒息、后消解、绝不”顺手”动。
- 改签名前先看全部调用点。
- 默认往组合的方向推继承。
- 重构到被理解为止——无法令人信服地解释一处改动为什么更清晰,就是改错了。