一段共享代码最会骗人的地方,是它刚被抽出来时确实很漂亮。重复少了,名字有了,调用方干净了。直到新需求来了,差一点点就能套进旧抽象,于是有人加一个参数。再来一个需求,再塞一个 if/else。
Sandi Metz 在 2016 年《The Wrong Abstraction》里把这件事说得很硬:duplication is far cheaper than the wrong abstraction。她后面还有一句更像工程戒律:prefer duplication over the wrong abstraction。这不是反对抽象,而是提醒团队:抽象一旦错了,代价会比重复更阴。
坏抽象的故障链:从省事到没人敢动
这套故障链很短,也很常见。
| 阶段 | 看起来在做什么 | 实际发生了什么 |
|---|---|---|
| 看到重复 | 抽出方法、类或模块 | 代码短期变干净 |
| 新需求几乎适配 | 加参数兼容 | 抽象开始变形 |
| 更多需求进入 | 继续塞条件分支 | 共享代码变成分岔迷宫 |
| 团队接手维护 | 不敢删、不敢拆 | 每次改动都要猜旧逻辑 |
重复代码至少诚实。它把差异摊在你面前,让你看见边界在哪里。
坏抽象更麻烦。它把几个并不相同的需求压进同一个壳里,再用参数、默认值、条件分支把裂口缝上。表面统一,内部已经分家。
这里的关键不是“重复好不好”。长期重复当然会带来修改成本,也可能造成行为不一致。问题在于,错误抽象会把差异藏起来,让团队误以为自己拥有一个稳定设计。
很多技术债不是因为代码难看,而是因为代码看起来太像资产。
沉没成本才是最硬的锁
Metz 点破的心理机制很现实:代码越复杂,团队越觉得它不能删。
复杂看起来像投入。投入看起来像价值。于是大家会下意识保护它。哪怕它已经从抽象退化成条件堆。
这就是沉没成本在代码库里的样子。
“知止不殆。”这句话放在抽象上很准。会抽象不稀奇,知道什么时候停,什么时候撤,才是工程判断。
我不太买账那种“先沿着旧结构补一下”的稳定叙事。它短期省事,长期收税。每多一个为了保住旧抽象而加的参数,下一个维护者就多付一点认知成本。
对有维护经验的开发者,这意味着一个很具体的动作:别把“再加一个参数”当默认选项。先看这个参数是不是只服务某个调用方。如果是,把逻辑拉回调用方,比继续污染共享代码更安全。
对 Tech Lead,这件事更像评审规则,而不是个人洁癖。代码评审里最该盯的不是“有没有重复”,而是这些信号:
| 观察信号 | 说明什么 | 建议动作 |
|---|---|---|
| 参数持续增加 | 抽象在吞新场景 | 暂停扩展接口,检查调用方差异 |
| 分支按调用方分岔 | 共享代码只剩壳 | 考虑 inline 回调用方 |
| 改一处要验证很多无关场景 | 抽象边界失真 | 拆开风险路径,再谈复用 |
| 新人不敢动这段代码 | 复杂度已经组织化 | 给重构留排期,不要只靠顺手修 |
接下来最该观察的变量也在这里:共享代码是不是越来越依赖参数和条件分支来维持体面;一次小改动是不是总会牵动一串无关场景。
如果答案是,那段代码已经不是复用成果,而是风险中枢。
真正高级的是敢撤回错误设计
Metz 给的解法并不玄。不是喊一句“重构”,而是把抽象拆回现场。
可以按这个顺序做:
- 把共享抽象 inline 回每个调用方。
- 根据每个调用方真实传入的参数只保留它会执行的路径。
- 删掉不需要的分支。
- 让重复重新暴露真实边界。
- 从当前需求出发再判断有没有新的抽象。
这一步看起来像后退,其实是止损。
重复重新出现时,团队会更容易看见哪些地方真的相同,哪些地方只是当年硬凑在一起。抽象应该从相同处长出来,不该靠条件分支强行维持。
但限制也要说清楚。Metz 不是在鼓励复制粘贴,更不是说所有重复都该留下。短期保留几个条件分支,有时可以帮助观察模式。前提是短期。
如果一个共享函数开始靠参数决定人格,一个模块开始为不同调用方走不同暗道,就该停了。它也许曾经正确,但那一天已经过去。
软件设计里最难的不是写出抽象,而是承认抽象有保质期。很多团队缺的不是架构能力,是撤销能力。
能抽出来,是技术。能拆回去,是判断。
