跳到正文

重构与小步安全改动

环境:Windows 11 x64;Git 2.56.0.windows.1;D:/Qt/Tools/mingw1310_64/bin/g++.exe -std=c++17 -O2(PATH 上的 gcc 是 Strawberry Perl 4.9.2,禁用)。真实仓库 C:\Users\wangt\data\blog 以提交 9d5f630 为锚点:60 个提交 / 108 个跟踪文件 / 71 篇文章 / 0 个 merge。重构与改名的全部写操作在 C:/Users/wangt/AppData/Local/Temp/agents/engineering4/r1、r2 两个临时仓库里做,博客仓库只跑只读命令;性能数字取 5 次交错运行的中位数(同机有多个 agent 在抢 CPU,不交错就会把邻居的抖动读成结论)。

一、坏味道先量化,别靠感觉

“这段代码有重复”是可测的。我把 src 下 24 个 .ts/.astro/.mjs(git ls-files 与磁盘计数都是 24,全部已入库)做了一次结构窗口扫描:连续 6 行、剥掉空白和字符串字面量后逐字符相同的块,报出现次数 ≥2 的组。

指标实测值(本次扫描)
完全相同的 6 行窗口21 组,其中跨文件 7 组
文件行数中位数35 行
最长三个文件data/categories.ts 301、layouts/PostLayout.astro 173、utils/post.ts 116
utils/post.ts(116 行)12 个导出:10 个函数 + 1 个常量 + 1 个类型,混着完整性校验 / 展示格式化 / 查询分组三种职责;assertIntegrity() 是模块私有的
getPosts() 的调用点occurrence 11 处 = 10 个页面与 RSS 入口 + post.ts 定义处;每次调用都重跑一遍全表校验

跨文件那 7 组里最典型的一对,components/PostItem.astro(35 行)和 layouts/PostLayout.astro(173 行)各自渲染了一遍分类 chip 和标签:

{`${category.title}${section ? ` · ${section.title}` : ''}`}   ← PostItem.astro:23 / PostLayout.astro:55
post.data.tags.map((tag) => (                                  ← PostItem.astro:28 / PostLayout.astro:60

窗口重叠会把”位置数”放大(我这次算出 277 个位置),所以只看组数和跨文件组数两个数,别拿位置数当行数用。调用点也一样容易数错:git grep -l getPosts 命中 12 个文件,但其中一个是文章正文里提到了这个名字——量半径要用 git grep -o "getPosts("(11 处)而不是 -l。

再看仓库自己的历史,同样的量化能立刻指出真正的坏味道在哪(git log --numstat 全量统计,60 笔):单提交新增行中位数 194、触及文件中位数 1;但按内容切开算,纯结构改动只有 4 笔,中位数 +2 行,其余 56 笔的体量都来自文章;7/60 笔把代码和文章混在同一提交里,例如:

2d8d606  fix(build): 章节归属校验此前是空转的,改到读取端并证明会失败
 README.md                                     |   4 +-
 src/content.config.ts                         |  28 +---
 src/content/posts/engineering-code-review-practice.md | 184 +++++++++++
 src/utils/post.ts                             |  21 +++
 4 files changed, 212 insertions(+), 25 deletions(-)

那一笔的 212 行新增里 184 行是一篇新文章,真正的结构改动是 content.config.ts -28 / post.ts +21。审查的人得翻完 184 行散文才看到那 49 行代码——这就是”小步”要解决的具体问题,不是抽象美德。

二、先加特征化测试,再动结构

特征化测试(characterization test)不断言”应该输出什么”,只把当前实现的全部输入-输出矩阵固化成基线。它的价值在于:不需要理解业务就能加,而重构恰恰只需要”和之前一样”。

实验对象是一个典型的脏函数(临时仓库 r1):5 个参数(含两个位标志)、4 层嵌套、5 种职责(分层折扣 / 券 / 税 / 满减 / 下限),一堆魔法数。基线是穷举 qty 0..20 × unit 5 个值 × coupon 3 × tier 3 × flags 4 = 3780 例,逐例打印到 10 位小数:

bytes 223396 sha256 0336d9fce1246995…  行数 3781(3780 例 + 1 行 # cases=3780)

然后分两步拆,每步之间只做一类事(git diff --no-index --numstat 量的改动量):

步骤做的事改动量基线对比
1拆出 tier_discount / apply_coupon / apply_flags,判定顺手改成 ? : 表式取值与 if (qty <= 0) return 0 提前返回,常数和判定顺序一个不动1 文件 +23/-18逐字节一致(223396 字节,同哈希)
2消掉”乘 1.00”这条多余乘法(f == 1.0 时直接用 base)1 文件 +8/-7逐字节一致(223396 字节)

每一步只花一次编译 + 一次运行(本机 5 次实测中位 1.41 秒,区间 1.26–1.70),换来的是”我这一步没有改变行为”这个结论不依赖我的判断。写断言要理解业务,写基线不用——这也是先给脏代码加基线、再谈单元测试的原因。

三、“看起来等价”的四种反例(实测)

这一节是全篇最值钱的部分:直觉在判断行为等价上不可靠,而且错的方式各不相同。同一个 3780 例基线,四种改动,四种结果:

改动变了多少例第一条差异
把 qty > 10 手滑写成 qty >= 10(我故意种的)112 / 3780 = 3.0%qty=10 unit=1.00 tier=1 → 10.0000000000 变 9.5000000000
把”先乘税再满减”换成”先满减再乘税”333 / 3780 = 8.8%qty=1 unit=99.00 flags=3 → 94.94 变 104.94
整数域重结合:(x/3)*7 写成 (x*7)/3,x=0..100006667 / 10001 = 66.7%整数除法不满足结合律,截断点位置变了
浮点重结合:unit*qty*0.95 写成 unit*(qty*0.95)0 / 3780(基线是 %.10f)打印精度把差异全吸掉了
同一条浮点重结合,基线换成 %.17g354 / 3780 = 9.4%qty=5 unit=3.00 tier=2 → 14.699999999999999 变 14.700000000000001

前三行说明”等价”要逐例验证;后两行更狠:同一处改动,基线的精度决定了它能不能被看见。%.10f 输出的基线对着重结合改动完全沉默(0 例),换 %.17g 立刻现出 9.4%。所以基线要么用全精度(%.17g / %a),要么直接对二进制摘要,别用人类可读的格式当判据。

顺手补一个”有时真的无所谓”的对照,免得把结论用反:1e6 项级数累加,累加器是 double 时正序与逆序差 0.000e+00;换成 float 累加器,正序 14.357357979、逆序 14.392651558,差 3.529e-02。同样是”换顺序”,一个量不出来、一个明显变——判据只能是测,不是”这个看起来安全”。

四、大规模改动:机械的和语义的分成两笔

临时仓库 r2:calc_price(要改名)和 calc_price_v2(不能动——它的名字同时是输出里的 key)。改名之前先用只读命令量清爆炸半径:

$ git grep -c "calc_price"
main.cpp:1   pricing.cpp:5   pricing.h:2   report.cpp:1     ← 4 个文件
$ git grep -o -E "calc_price[a-z_0-9]*" | wc -l              → 13 处
$ git grep -o -E "calc_price_v2" | wc -l                     →  7 处(这些必须留着)
$ git grep -o -E "calc_price([^a-zA-Z0-9_]|$)" | wc -l        →  6 处(真正要改的)

两种手法跑下来:

手法替换编译对基线的影响
sed s/calc_price/X/g(无边界)13 处全中(diff 里 9 行含新名)通过:-Wall -Wextra 退出码 0、零警告105 行数据输出 100% 变化,calc_price_v2=0.0000000000 变成 compute_line_total_v2=0.0000000000
sed -E s/\bcalc_price\b/X/g6 处通过逐字节一致(6906 字节,calc_price_v2 的 7 处完好)

关键的那句:naive 版本编得过去、测试跑得起、Git 一句坏话都没说——它是靠基线比对才被抓出来的。 _ 是单词字符,所以 \bcalc_price\b 天然不会碰 calc_price_v2,一个字符的差别决定这是重命名还是改行为。

改名那笔落地之后,语义改动单独一笔(把阈值 10 挪到 5):1 file changed, 1 insertion(+), 1 deletion(-),基线 25 / 105 = 23.8% 变化,第一条是 qty=6 unit=1.00 → base=6.00 变 5.70。这两笔的 diff 体积一样小,但基线变化的比例是它们的身份证:0% = 机械,非 0% = 语义。审查时看到这个数字就不必逐行读;反过来说,一笔”改名”里混进了 23.8% 的行为变化,就是最可怕的提交。

五、小步的边界:一笔提交只能装一种东西

第四节那笔”纯改名”我第一版提交歪了。git add -A 加上过窄的 .gitignore(写了 run.exe,没写 runA.exe):

$ git show --stat HEAD        ← 第一版
 A.txt       | 106 ++++++++++++
 B.txt       | 106 ++++++++++++
 base.txt    | 106 ++++++++++++
 main.cpp    |   2 +-
 pricing.cpp |   6 ++--
 pricing.h   |   2 +-
 report.cpp  |   2 +-
 runA.exe    | Bin 0 -> 78513 bytes
 runB.exe    | Bin 0 -> 78497 bytes
 9 files changed, 324 insertions(+), 6 deletions(-)

9 个文件里 5 个是产物(两个 exe 共 157010 字节 + 三个 106 行的输出转储),源码只有 4 个文件、6 行改动。改成 .gitignore: run*.exe / *.txt 后用 git reset --soft HEAD~1 + git reset 退回,重新提交是 5 files changed, 9 insertions(+), 8 deletions(-)(多出来的是 .gitignore 自己)。这个坑和「分支模型与历史整理」里那条是同一条:产物入库的代价不是体积,是这一笔再也无法用 diff 读懂,而且切换提交的操作会随机罢工。

本仓库的历史里有同构的真实例子:d627974 的正文写着”同时收录工程实践两节的最终版”,2d8d606 把 README、schema、校验搬迁和 184 行新文章装进同一笔。它们都不是坏提交——是”提交在深夜赶工时的正常形态”,值得记下来的是判据:如果你无法用一句”这一笔只做一件事”描述它,它就还能拆。

六、小步不是免费的:拆函数的实测代价

r1 的拆法把 1 个函数变成 4 个。热循环 3000 万次调用,v0(原始)与 v3(拆分)交错跑 5 轮:

v0   114.3 / 121.7 / 113.3 / 111.0 / 119.1 ms   中位 114.3   每次 3.81 ns
v3   132.0 / 127.2 / 116.5 / 124.3 / 121.8 ms   中位 124.3   每次 4.14 ns   (比同批 v0 慢 +8.7%)
v0   117.2 / 110.1 / 202.2 / 113.1 / 118.8 ms   中位 117.2   (第二轮交错)
v3b  119.2 / 132.1 / 121.2 / 125.0 / 126.6 ms   中位 125.0   (比同批 v0 慢 +6.7%)

两条排除性证据:nm bench_v3.exe 只剩 _Z10calc_priceidiii 一个符号,三个 helper 全部被内联,所以这不是”多三次调用”;把”乘 1.00”消掉的 v3b 同样慢,所以也不是多一次乘法。差异只能来自代码布局或分支形状,我没有进一步归因。另外 v0 有一轮 202.2ms 的离群值,是同机其它 agent 抢 CPU——这也是为什么这里用交错跑而不是各跑五轮。

结论别读反:拆函数在这个量级上是有代价的(+6.7% 到 +8.7%),只是它出现在 3000 万次调用的紧循环里;而”读不动的函数”的代价出现在每次评审、每次 bisect、每次事故定位上。真要拆热点就测——本节这个 40 行的对照,从写到跑完不到 2 分钟。

七、这个仓库的”回归网”长什么样

内容型仓库的”行为”不是数值输出,而是归属与展示:写错的 section 会把文章挂到不存在的章节下(本仓库真发生过,2d8d606 的正文就是为它写的),带时间的 date 会让列表页、RSS、sitemap 一起漂一天。现有三道闸分别落在两个地方——src/utils/post.ts::assertIntegrity()(每次 getPosts() 都跑,4 条 throw)和 src/content.config.ts 的 zod schema:

section "conc-does-not-exist" 不存在,见 src/data/categories.ts    ← 探针实测:构建退出码 1
date 只能写成 YYYY-MM-DD,收到 "2026-10-15T00:30"。                ← InvalidContentEntryDataError
tagFolder():<>:"/\|?* 与控制字符压成 '-'                          ← 不是闸,是消毒:CI 在 Ubuntu 能建的目录 Windows 不行

2d8d606 那条 commit message 就是一次完整的重构记录模板:先证明旧实现是空转的(Astro 5.18 的 glob() 没有 transform,跨文件校验被静默忽略,用探针验证过”构建照常通过”),再给出新实现的失败证据(同一枚探针,退出码 1,报错原文照抄),最后给回归规模(删掉探针后 215 页构建通过;全仓 60 篇文章逐篇核对 0 处错配)。这三句话的结构比内容更值得抄:空转的检查比没有检查更糟,因为它把责任从人转移给了一个不存在的门卫。

未实测的两项,说清楚:PostItem.astro / PostLayout.astro 那 7 组跨文件重复我没有提取成组件(npm run build 归总控在跑,博客仓库对我只读);assertIntegrity() 在每次构建里被 12 个调用点各跑一遍的实际耗时我也没有测。

小结

  • 坏味道用数字说话:本次扫描 24 个源文件里 21 组相同的 6 行窗口、7 组跨文件;仓库 60 笔里纯结构改动只有 4 笔(中位 +2 行),7 笔把代码和文章混在一起——所以”该重构的地方”和”该拆的提交”都是量出来的,不是感觉出来的。
  • 特征化测试是重构唯一可靠的前置投资:3780 例基线下两步拆分每步逐字节一致,每步验证成本实测 1.41 秒(中位,区间 1.26–1.70);它不需要你理解业务。
  • 直觉在等价判断上不可靠:边界手滑 3.0%、换序 8.8%、整数重结合 66.7% 都是真变化;而浮点重结合在 %.10f 基线下是 0%、在 %.17g 基线下是 9.4%——基线的精度就是保护力,累加器换 float 后正序逆序差 3.529e-02。
  • 大规模改动分两笔:机械改名用 \bcalc_price\b 只碰 6 处、输出逐字节一致;naive sed 碰 13 处、编译零错误却把 105 行输出全改掉。改名之前先 git grep 量半径,语义改动单独一笔,用”基线变化比例”当每笔提交的身份证。
  • 一笔只装一种东西:git add -A + 窄 .gitignore 让”纯改名”那笔塞进 5 个产物(两个 exe 共 157010 字节),修正后是 5 文件 / 9 行——diff 读不懂的提交等于没有提交。
  • 小步有代价且要测:拆函数 +6.7% ~ +8.7%(3000 万次调用的紧循环,交错 5 轮中位),但 nm 证明 helper 已内联、v3b 排除了多余乘法,机制未归因;热点上的拆分要单独量。