重复三次之前看到的是巧合,三次之后看到的才是形状。
一、他抽早了一次
陆远在第 7 个月做了第一次「正经抽象」。projects、files、analytics 三个模块各有一段导出 CSV 的代码,各 30 行,几乎一样;他花 1 小时抽成一个 40 行的公共函数,三处各留 5 行调用。总行数从 90 降到 55,他很满意,还在提交信息里写了「消除重复」。
以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。
第 9 个月,billing 需要一个带租户表头、金额按分转元的导出,公共函数加了两个参数;第 11 个月,files 需要流式写出大文件,又加了一个开关;到第 16 个月,这个函数有 11 个参数、被 7 处调用,每加一个参数都要把 7 个文件重新看一遍。
陆远后来复盘,抽取那天三个调用点确实同时存在,但他没问一句:这三处的输出格式会不会分叉。答案是会,而且第一次分叉只隔了两个月。
更麻烦的是,这个函数已经不在任何一次改动的上下文里。改 projects 时他只记得要传 tenant 与 format,改 billing 时只记得要传金额单位,第 7 个参数是给谁加的,要翻三次提交才想得起来。
抽象的收益在第二次复制时看不见,在分叉时才结算。 三处代码一样的时候抽出来最省事,也最容易抽错;真正的成本不在抽取那一天,而在第一次需求分叉的那一周。
三次规则是一道时间闸,不是一道数量闸。 它的作用不是数到三就抽,而是让你等到三个真实调用点同时存在,再判断它们会不会往不同方向走。
抽错比不抽贵两倍。 不抽时改 7 处各 10 分钟是 70 分钟;抽错后改 7 个调用点各 20 分钟是 140 分钟,因为每处都要先理解那个通用函数的 11 个参数、再决定自己该传哪一组。
行数变少不等于变好。 90 行变 55 行省了 35 行,但如果这 35 行换来的是 7 个调用点的耦合,这笔账要按未来两年的改动次数重算,而不是按抽取当天的 diff 重算。
二、三次规则与一次判断
三次规则管的是「什么时候可以抽」,不管「抽成什么样」。它把第一次与第二次都按「先别动」处理,只把第三次留作判断点。
| 第几次出现 | 动作 | 判断依据 |
|---|---|---|
| 第一次 | 直接写,不抽 | 只有一个调用点,抽出来只是多一层跳转 |
| 第二次 | 复制,不改 | 两份允许不同步,先看它们接下来往哪走 |
| 第三次 | 同时存在才抽 | 先问「未来 6 个月会不会分叉」,会分叉就不抽 |
规则的关键在第三行。三次同时出现只说明「现在一样」,不说明「以后一样」;分叉概率超过一半时,抽象只是把三处重复换成一处分支,重复还在,只是藏进了参数里。
判断分叉要问三个问题:它们的输入来源是否同一个、输出格式是否同一个、变更节奏是否由同一个人决定。三个都相同,抽;有一个不同,先复制。
抽的时机也要看改动频率。三处代码一年都不动一次,抽不抽都省不下时间;三处每月都改,且改法一致,抽取的收益才立刻兑现。
三次规则也不要求三次长得一模一样。第三次出现时如果已经有两处不同,那不是「可以抽了」,而是「已经分叉了」,这时候抽出来只会把两份差异变成两个布尔参数。
反过来,一个只有两个调用点、每次只省 3 行的函数,即使第三个调用点出现了,也不值得抽。抽象的门槛是「省下的改动次数乘以每次省下的分钟数」,不是「看起来像重复」。
三、一次真实抽取与一次抽错
把 90 行压成 55 行的那次抽取,是规则用对了的样子。三个调用点在同一周出现,输入都是同一张表的查询结果,输出都是同样的 CSV 列,变更节奏也由陆远一个人掌握。
| 状态 | 行数 | 调用点 | 改一处的成本 |
|---|---|---|---|
| 抽取前 | 90 | 3 处各 30 行 | 改 3 个文件,各 10 分钟 |
| 抽取后 | 55 | 1 个函数 + 3 处各 5 行 | 改 1 个文件,10 分钟 |
| 净变化 | −35 | 3 | 一致改动时省 20 分钟 |
那 40 行里没有一行是新的业务逻辑:参数校验 8 行、列名映射 12 行、写出 14 行、错误处理 6 行。抽取时只搬不改,搬完三个调用点各自只剩「查数据、调函数、返回」5 行。
抽取本身花了 1 小时。按每月一次一致改动算,每次从 30 分钟降到 10 分钟,省 20 分钟,三个月回本;这是抽对了的样子,之后又有 4 处调用主动接进来,才有了后来的 7 个调用点。
抽错的样子不同:7 个调用点、11 个参数,一次一致改动变成 7 × 20 = 140 分钟,不抽只要 7 × 10 = 70 分钟。多出来的 70 分钟不是抽象本身的开销,是「每处都要判断自己该传哪组参数」的开销。
还有一种更隐蔽的抽错:抽出来的函数只有 2 个调用点,每个调用点只省 3 行,但函数本身 400 行。净多 394 行,且这 400 行只有你一个人维护,每次需求变化都要在里面找开关。
四、复算:两年的账与 1.25 年回本
一次抽取:3 × 30 = 90 行
抽后:40 + 3 × 5 = 55 行
节省:90 − 55 = 35 行
抽错:7 × 20 = 140 分钟
不抽:7 × 10 = 70 分钟
倍数:140 ÷ 70 = 2.0
两年 12 次抽象:12 × 1 = 12 小时
拆掉 4 次:4 × 2 = 8 小时
总成本:12 + 8 = 20 小时
成功 8 处:8 × 2 = 16 小时/年
第一年:16 − 20 = −4 小时
第二年:+16 小时
累计回本:1 + 4 ÷ 16 = 1.25 年
过度设计:400 − 2 × 3 = 394 行
第一年的账是负的,这一点必须提前接受。12 次抽象里有 4 次会被拆掉,拆的时候还要再花 2 小时;20 小时的成本在第一年摊完,收益只有当年那 16 小时,净 −4 小时。
第二年开始转正。8 处成功的抽象每年各省 2 小时,累计到第二年末是 +12 小时;把第一年的 −4 抵掉,等于在第 15 个月回本。这个数字说明抽象是长期动作,不适合用季度报表考核。
拆掉的那 4 次不算失败。它们提供了「这两个调用点其实会分叉」的证据,没有那 4 次,你不会知道边界该画在哪;8 小时买的是判断力,不是代码。
394 行那个例子是反面的极端:抽象的成本主要在维护,不在书写。一个 400 行的配置层只有 2 个调用点,每个省 3 行,净多 394 行,还要在每次需求变化时同步修改。
复算里最容易被忽略的是「成功」的定义。8 处成功的标准不是「抽完没报错」,而是「连续 12 个月没有被拆掉、且调用点只增不改参数」;按这个标准回头看,那 4 次拆掉是迟早的事。
这组数字对分叉率敏感。如果 12 次里被拆掉的是 6 次而不是 4 次,成本变成 12 + 12 = 24 小时,成功只剩 6 处、每年省 12 小时,第一年净 −12 小时,回本从 1.25 年推到 2 年。分叉判断错两次,一年的收益就全被拆解成本吃掉。
敏感性的用法不是算得更准,而是决定「要不要抽」。当拆掉的比例可能超过一半时,抽取的期望收益是负的,这时候复制两份反而是更便宜的选择;这也是三次规则里那句「先问会不会分叉」真正的分量。
五、行动清单与复算验收
- 在代码里搜出重复三次以上的片段,只登记位置与行数,不立刻抽。
- 对每个候选写一行「输入、输出、变更节奏」,三项都相同才进入抽取。
- 抽之前回答「未来 6 个月会不会分叉」,会分叉的比例超过一半就继续复制。
- 抽取时把公共函数的参数控制在 3 个以内,超过就说明它其实在做两件事。
- 抽完记录三个调用点各自的调用行数,总行数必须比抽取前少。
- 每季度回看一次已抽的函数,参数超过 6 个的列进拆分候选。
- 拆掉一个抽错的函数时,把两边的差异写成测试,避免下次再抽回去。
- 新建的「万能配置层」超过 100 行且调用点少于 3 个,直接删掉。
验收标准:如果一次抽取不能让三个调用点的总行数从 90 降到 55,也不能让一致改动从改三处变成改一处,那么这次抽取就不该发生。
清单里最容易被跳过的是第三条。分叉判断写在抽之前,成本是一句话;写在抽之后,成本是 7 个文件的返工,以及 140 分钟的重复劳动。
最后一条针对的是另一种冲动:把「以后可能会用到」写成配置。配置层的每一行都要有人读,调用点少于 3 个时,它就是纯粹的成本。
使用边界与实操验收
三次规则不适用于安全与合规代码。 权限判断、金额计算、加密调用这类逻辑,第一次就该抽成唯一入口;它们的重复不是巧合,是风险,不能用「等第三次」来拖。
跨模块复用要额外加一道边界检查。 同一个函数被 projects 与 billing 同时调用,等于让两个模块通过它耦合;抽之前先确认这条依赖方向是否允许,别为了省 35 行造出一条逆向依赖。
复制两份是健康状态,不是欠债。 两份代码各自演化,往往是两个需求确实不同;强行合并会让下一次分叉更难拆,也会让你在改其中一处时误以为另一处已经同步。
抽象的收益只在改动发生时兑现。 一年不动一次的代码,抽得再漂亮也不省时间;判断要不要抽,先看它一年被改几次,而不是看它被复制了几次。
什么时候该停下来。 当一个公共函数的参数超过 6 个、或者它的名字里出现「and」,说明它已经在承担两条职责,该拆而不是继续加开关。
关键参考与核验范围
核验日期:2026-10-26。以下文档用于核对重构手法、重复代码的处理方式与技术债的定义,本文所有数字均取自全组教学基线,不取自这些文档。
- Refactoring catalog :用于核对「提取函数」「内联函数」等手法的适用前提,以及它们互为反向操作的关系。
- TechnicalDebt | Martin Fowler :用于核对债务与利息的隐喻,以及为什么「过早抽象」通常不计入债务。
- The Twelve-Factor App :用于核对「显式声明依赖」与「配置外置」的边界,避免把配置层当成抽象。
引用口径。 文中 90、55、35、140、70、20、16 等数字全部来自全组教学基线;外部文档只用于核对手法名称与概念边界,不提供任何行数或工时基准。
核验范围。 上述三个入口在 2026-10-26 实测可公开访问(HTTP 200);未核验各页面的最近更新时间与示例代码所用语言版本。
如何自行复核。 取最近半年的一次抽取,数出抽取前后的总行数与调用点数量;再记录一次分叉改了几个文件、每个文件花了多少分钟。两项与本文的 35 行、140 分钟对不上,说明你的代码库还不适用这组基线。
系列导航
- 上一篇:技术债预算:十八条债、二十四分钟与 7.5% 的工程时间
- 下一篇:重构还是重写:三张判据表与 160 小时对 48 小时
- 回到目录:一个人的架构:技术选型、模块边界与演进
- 相关阅读:单体优先:九个模块、一条依赖方向与一次边界检查 、重构还是重写:三张判据表与 160 小时对 48 小时 、开发外包与代码交接:接得住、改得动、走得掉
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。