重构与自动修复
重构是在不改变外部行为的前提下调整内部结构。判断标准很硬:调用方不用改,测试仍然全绿。
顺序
先让测试绿,再动结构。节奏是:
- 一次只做一类改动,这一提交只改名,下一提交只抽函数,再下一个才换实现
- 每步跑一次测试,绿了再继续
- 混在一起,出问题时无法定位是哪一步引入的
没有测试覆盖的模块,先补特征测试。做法是把当前输出原样记录下来当基线,包括那些看起来像 bug 的行为。它不判断对错,只保证改完之后行为没变。等结构稳定,再把基线换成真正的断言。
大重构要能回滚。把「删除旧路径」留到最后一步,前面的提交都保持两条路径可用,出问题直接 revert。对线上服务,还可以用开关控制新实现是否生效,先灰度一小部分流量。
重构的常见动作
| 动作 | 做什么 | 依赖 |
|---|---|---|
| rename | 改符号名 | 符号表 |
| extract | 抽函数或变量 | 语法与作用域 |
| inline | 展开调用 | 语法与作用域 |
| move | 换文件或模块 | 模块解析 |
| change signature | 改参数与返回值 | 符号表,部分支持 |
这些都是机械变换:规则明确,结果可验证,工具做完就能跑测试。真正的难点不在变换,而在判断边界画在哪——一个函数该拆成几个、模块之间的依赖方向对不对。工具只负责把你的决定执行到位。
从符号表到 AST
IDE 的 rename、extract function、inline、move 走的是语言服务器的符号表,不是文本匹配。改一个变量名,它知道哪些是同名但无关的字段,也知道字符串里的同名词不该动。
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 处都该改。
自动修复后要检查什么
git diff --stat看改动文件数是否符合预期,多出来的通常是误伤- 通读 diff,重点看注释、字符串、配置有没有被改写
- 跑测试,确认没有静默的行为变化
- 看
git diff里有没有整文件重排,混进来的格式化改动要拆出去
自动修复的 bug 很少,但一旦发生就没有报错,只在运行时表现异常。这也是为什么 --fix 应该由人执行并检查,而不是让 CI 在后台改代码。
让 diff 可读
- 格式化提交单独发一次,hash 写进
.git-blame-ignore-revs,否则每个函数的历史都指向「全仓库格式化」那次提交。 - 重命名和逻辑改动分开。同一提交里既有改名又有行为变化,reviewer 无法判断哪部分是机械操作。
- 删除死代码前先确认没有引用:IDE 的 find usages、
rg全仓库搜索、编译器的未使用告警,三选二。 - 兼容层用完就删。留着「以防万一」的旧接口,会在半年后变成没人敢动的第二个实现。
- 提交信息写清楚做了什么变换,比如「把 X 模块的错误处理抽成装饰器」,而不是「refactor」。
改动是否值得,可以看两个数:diff 的行数,以及下一次改同类功能要动几个文件。前者太大说明步子迈得太狠,后者不变说明只是搬了位置。
什么时候不该重构
没有测试覆盖的模块,先补测试再动结构。外部依赖即将下线、代码本身要整体废弃时,重构是浪费。改动只是为了让自己写得舒服、却扩大了 diff 范围,也不值得。
重构的目标是降低下一次修改的成本,不是让代码看起来更整齐。整齐是格式化的活。