跳到正文

重构与自动修复 ​

重构是在不改变外部行为的前提下调整内部结构。判断标准很硬:调用方不用改,测试仍然全绿。

顺序 ​

测试通过小步改动再跑测试提交

先让测试绿,再动结构。节奏是:

  • 一次只做一类改动,这一提交只改名,下一提交只抽函数,再下一个才换实现
  • 每步跑一次测试,绿了再继续
  • 混在一起,出问题时无法定位是哪一步引入的

没有测试覆盖的模块,先补特征测试。做法是把当前输出原样记录下来当基线,包括那些看起来像 bug 的行为。它不判断对错,只保证改完之后行为没变。等结构稳定,再把基线换成真正的断言。

大重构要能回滚。把「删除旧路径」留到最后一步,前面的提交都保持两条路径可用,出问题直接 revert。对线上服务,还可以用开关控制新实现是否生效,先灰度一小部分流量。

重构的常见动作 ​

动作做什么依赖
rename改符号名符号表
extract抽函数或变量语法与作用域
inline展开调用语法与作用域
move换文件或模块模块解析
change signature改参数与返回值符号表,部分支持

这些都是机械变换:规则明确,结果可验证,工具做完就能跑测试。真正的难点不在变换,而在判断边界画在哪——一个函数该拆成几个、模块之间的依赖方向对不对。工具只负责把你的决定执行到位。

从符号表到 AST ​

IDE 的 rename、extract function、inline、move 走的是语言服务器的符号表,不是文本匹配。改一个变量名,它知道哪些是同名但无关的字段,也知道字符串里的同名词不该动。

bash
ruff check --fix .
eslint . --fix
cargo clippy --fix --allow-dirty
semgrep --config rules.yml --autofix .
ast-grep -p 'requests.get($URL)' -l python -r 'http_get($URL)'

linter 的 --fix 只处理能保证等价的问题:删未使用导入、补尾逗号、简化布尔表达式。语义不确定的规则不会自动改。

编辑器之外还有两类批量工具,都在语法层工作:

  • semgrep --autofix 读取规则里的 fix: 字段,按 AST 匹配后替换
  • ast-grep 的 $X 是单节点元变量,$$$ARGS 匹配任意多个参数,改写时能把捕获的内容重新拼进去
  • jscodeshift 用 AST 写转换脚本,专治 JS 生态的 API 改名;comby 用结构化模式匹配多语言,不需要完整语法树也能跳过注释和字符串

jscodeshift 与 comby 适合跨文件迁移,比正则稳,因为它们在语法层工作。

纯文本替换的风险在于上下文:foo 出现在注释、字符串、另一个模块的同名字段里时,sed 一视同仁。搜到 200 处不代表 200 处都该改。

自动修复后要检查什么 ​

  1. git diff --stat 看改动文件数是否符合预期,多出来的通常是误伤
  2. 通读 diff,重点看注释、字符串、配置有没有被改写
  3. 跑测试,确认没有静默的行为变化
  4. 看 git diff 里有没有整文件重排,混进来的格式化改动要拆出去

自动修复的 bug 很少,但一旦发生就没有报错,只在运行时表现异常。这也是为什么 --fix 应该由人执行并检查,而不是让 CI 在后台改代码。

让 diff 可读 ​

  • 格式化提交单独发一次,hash 写进 .git-blame-ignore-revs,否则每个函数的历史都指向「全仓库格式化」那次提交。
  • 重命名和逻辑改动分开。同一提交里既有改名又有行为变化,reviewer 无法判断哪部分是机械操作。
  • 删除死代码前先确认没有引用:IDE 的 find usages、rg 全仓库搜索、编译器的未使用告警,三选二。
  • 兼容层用完就删。留着「以防万一」的旧接口,会在半年后变成没人敢动的第二个实现。
  • 提交信息写清楚做了什么变换,比如「把 X 模块的错误处理抽成装饰器」,而不是「refactor」。

改动是否值得,可以看两个数:diff 的行数,以及下一次改同类功能要动几个文件。前者太大说明步子迈得太狠,后者不变说明只是搬了位置。

什么时候不该重构 ​

没有测试覆盖的模块,先补测试再动结构。外部依赖即将下线、代码本身要整体废弃时,重构是浪费。改动只是为了让自己写得舒服、却扩大了 diff 范围,也不值得。

重构的目标是降低下一次修改的成本,不是让代码看起来更整齐。整齐是格式化的活。