2733 字
约 9 分钟
16
S24-A15 · AI 辅助重构:机械改动它很强,语义改动它很危险

一次"把回调改成 Promise"的重构,助手在十分钟内改了 40 个文件,编译通过、测试全绿。两周后发现有个地方的错误处理被悄悄改掉了:原来 catch 里会重试一次,改成 await 之后异常直接抛给了调用方——而那处调用方没有 try。

重构的难度不在于"改多少行",而在于哪些改动是机械的、哪些是语义的。助手在机械改动上比人快且不容易漏;在语义改动上它会做出"看起来合理"的选择,
这也决定了团队该怎么分工:人负责"这次重构的语义边界在哪",助手负责"按这个边界批量执行"。边界没划清就让它动手,等于让它替你做设计决定。

而那个选择没人复核。


1. 两类重构的分界

类型 例子 助手可靠性
机械重构 改名、提取常量、格式统一 高
结构重构 提取方法、拆分函数 中
语义重构 回调改 Promise、同步改异步 低
行为重构 改错误处理、改重试策略 极低

第三项与第四项的差别很细:回调改 Promise 属于"改变控制流的表达",而重试策略属于"改变行为"。前者可以机械验证(编译 + 测试),后者只能靠人判断。

实践上的分界线是:改动是否会改变"错误发生时发生什么"。会,就必须人工设计。

2. 机械重构为什么适合交给助手

原因 说明
规则可枚举 改名、替换都有明确模式
规模效应 40 个文件人工要一天
一致性 不会漏掉第 37 个文件
可验证 编译 + 测试即可确认

第四项(可验证)还有一个前提:验证要能覆盖改动类型。类型改动靠编译,逻辑改动靠测试;而"改了错误处理"这类改动,编译与测试都不一定覆盖,所以它落在第三、四类而不是第一类。

第三项是最实在的收益:人工批量改名最典型的错误是"漏了几处",而漏掉的那几处会在运行时才暴露(比如反射、字符串拼的类名、配置文件里的键)。

第二项的收益也要算清:十分钟 vs 一天,但前提是改完能验证。不能验证的批量改动,省下的时间会在排查时还回去。

3. 语义重构的三个风险点

风险 表现
错误处理被简化 重试、降级、日志丢失
执行顺序被改变 副作用顺序颠倒
并发语义改变 从串行变并行或反之

第二项(顺序被改变)在重构里很隐蔽:把两个独立的副作用提取成两个方法、再按字母序调用,看起来是整理,实际改变了"先写库再发消息"与"先发消息再写库"的差别。这类改动在代码评审里几乎看不出来。

第一项最常见:助手倾向于"让代码更简洁",而错误处理代码在它看来是冗余的。于是 try/catch 被合并、重试被去掉、日志被删掉——而测试通常不覆盖这些路径。

第三项最危险:把"顺序 await"改成"Promise.all" 看起来是性能优化,实际改变了并发语义(可能打爆下游)。

// 反例:并发化改变了语义(下游可能承受不了 40 个并发)
const results = await Promise.all(items.map(i => api.call(i)));

// 正例:保持串行,或显式限制并发
for (const i of items) { results.push(await api.call(i)); }
// 或:并发但限流
const results = await pLimit(4)(items.map(i => () => api.call(i)));

4. 让助手做重构的正确流程

步骤 做法
1 先写"不变量" 哪些行为不能变
2 分批改 一次 5–10 个文件
3 每批验证 编译 + 测试 + 抽查
4 人工审语义 只看错误处理与顺序

还有一个更硬的约束方式:让它先输出"改动清单"再动手。清单里逐条写"哪个文件、改了什么、为什么",人只需审清单。审清单的成本远低于审 40 个 diff。

// 先要清单,再要代码
输出格式:
1) 文件名 + 改动摘要(一行)
2) 是否触及错误处理/并发/外部接口(是/否)
3) 该文件的验证方式(编译/测试/人工)
// 只要有一条"是" → 该文件转人工

第一项是最有效的约束:明确告诉它"错误处理的语义不能变,包括重试次数与日志"。有了这条,它就不会把 catch 合并掉。

第二项的意义是把"不可验证的大改动"变成"可验证的小批次"——一旦某批出问题,回滚范围是 10 个文件而不是 40 个。

// 重构任务单的四行(写进提示里)
不变量:错误处理的重试次数、日志字段、返回值类型均不变
范围:仅 src/legacy/*.js,共 8 个文件
验证:npm test 全绿 + 抽查 3 处的 catch 分支
禁止:不得合并 catch、不得改 async 边界、不得引入新依赖

重构还有一个"提交粒度"的实践:每个批次一个提交、一次可独立回滚。这样"第 3 批出问题"时,回滚的是第 3 批而不是全部——而一次改 40 个文件只提交一次的做法,让回滚等于重做。

// 分批提交:每批一个 commit,可独立回滚
git checkout -b refactor/callback-to-promise
// 批 1:src/legacy/a*.js(8 个文件)
git commit -m "refactor(legacy): a* 回调改 async,行为不变"
// 批 2:src/legacy/b*.js
git commit -m "refactor(legacy): b* 回调改 async,行为不变"
// 出问题时:git revert <批 3 的 commit> 即可,不影响前两批

5. 验证手段的分层

手段 能发现
编译 类型与语法
单元测试 已覆盖的行为
集成测试 跨组件行为
人工抽查 未覆盖的错误路径
灰度对比 真实流量差异

第五项(灰度对比)有个前提:灰度要有可比的基线。同一时段、同一流量比例下的错误率对比才有意义;若灰度窗口与基线窗口流量特征不同,差异会被误读成重构问题。

第四项不可省略:未覆盖的错误路径正是重构最常破坏的地方,因为测试很少写"下游超时后重试一次"这类用例。

第五项是最后一道:重构后灰度跑一段时间,对比错误率与延迟。如果错误率上升而测试全绿,说明破坏的正是未被覆盖的路径。

机械重构也有"看不见的引用"清单,值得逐项搜一遍:

// 批量改名后必搜的五类引用
1. 字符串里的类名/方法名(反射、动态调用)
2. 配置键(yaml/properties 里的键名)
3. SQL 与 ORM 映射里的列名
4. 前端与后端的接口字段名
5. 日志与监控里的指标名/事件名
// 前四项错了会功能失效;第五项错了会"看起来正常但监控断掉"

6. 排错:五种"重构引入的问题"

现象 成因 处理方向
测试全绿但线上错误变多 错误路径被改 人工抽查 catch
下游被打爆 串行改并发 恢复串行或限流
日志少了一段 合并了 catch 补回日志
某处功能悄悄失效 批量改名漏了反射/字符串 全局搜字符串引用
回滚困难 一次改太多文件 分批 + 每批独立提交

第二项(下游被打爆)在"重构 + 性能优化"混做时最常出现:一次提交里既有结构调整又有并发化,出问题时无法判断是哪一项导致的。所以重构与性能改动应当分开提交。

第四项是机械重构的专属风险:改名工具改不了"用字符串拼出来的名字",包括配置键、反射调用、SQL 里的列名。所以批量改名后要专门搜一遍字符串。

7. 边界:什么情况不该交给助手重构

场景 为什么 做法
核心错误处理逻辑 语义不可机械验证 人工改 + 评审
并发与事务边界 语义容易变形 人工设计
涉及外部契约的命名 改名即破坏兼容 人工确认范围
没有测试覆盖的模块 无法验证 先补测试

第四项的顺序很重要:先补测试再重构,而不是"重构完再补"。因为重构后补的测试是照着新实现写的,等于失去了对照。

最后一条与"规模"有关的边界:文件数超过 20 时,收益不再线性增长。因为复核成本随规模上升,而漏改的概率也在上升——此时更划算的是"分批 + 每批独立提交",而不是一次改完。

判断口径一句话:这次改动如果错了,测试会不会变红。会,可以交给助手;不会(错误路径、并发语义、字符串引用),必须人工。


8. 自测

  1. 两类重构的分界线是什么?为什么"回调改 Promise"与"改重试策略"可靠性差别极大?
  2. 机械重构适合交给助手的四个原因里,哪一项是最实在的收益?"不能验证的批量改动"为什么省不下时间?
  3. 三个语义风险点中,哪一项最危险?把"顺序 await"改成 Promise.all 改变了什么?
  4. 重构流程四步里,"先写不变量"为什么最有效?分批改的收益是什么?
  5. 为什么"未覆盖的错误路径"是重构最常破坏的地方?灰度对比能发现什么?
  6. 判断"能否交给助手"的那个问题是什么?
S24-A15 · AI 辅助重构:机械改动它很强,语义改动它很危险
https://www.clxhxhhr.top/posts/6480/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。