2020年初,React核心团队成员Dan Abramov在深夜完成了一次"完美"的重构。他看到同事实现的图形编辑器代码中存在大量重复——每种形状(矩形、椭圆、文本框)的缩放逻辑都包含相似的数学运算。作为一个追求简洁的程序员,他花了几个小时将这些重复代码抽象成一个优雅的组合系统,代码量减少了一半,所有重复都消失了。
第二天早上,他的老板请他进了一对一会议室,礼貌地要求他回滚这次修改。
这不是一个关于"代码不够干净"的故事。恰恰相反,那次重构的代码极其干净——只是它完全错了。那个"优雅"的抽象锁死了未来的需求变化路径。当产品需要为不同形状的不同拖拽点添加特殊行为时,Dan的抽象变成了一个需要层层条件判断的复杂怪物,而原始的"重复"版本却可以轻松扩展。
这个案例揭示了一个被广泛忽视的困境:代码复用——这个被奉为圭臬的原则——正在以隐蔽的方式吞噬软件项目的可维护性。
DRY的原始面目:知识重复,不是代码重复
1999年,Andy Hunt和Dave Thomas在《程序员修炼之道》中首次明确提出DRY原则:
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
每一条知识必须在系统中有一个单一、明确、权威的表示。
注意这个定义的关键词:knowledge(知识),而不是code(代码)。这正是二十多年来被广泛误读的地方。
Dave Thomas在2019年的书籍20周年纪念版中专门澄清了这一点:
Many people took it to refer to code only: they thought that DRY means “don’t copy-and-paste lines of source.” […] DRY is about the duplication of knowledge, of intent. It’s about expressing the same thing in two different places, possibly in two totally different ways.
一个电商系统中的"发货逻辑"是一块知识——它应该只存在于一个地方。但两个恰好看起来相似的代码片段,可能代表完全不同的业务意图。把它们强行抽象在一起,就是将两块本不相关的知识耦合了起来。
Martin Fowler在总结Kent Beck的简单设计四原则时,将"无重复"列为第三条,但他特别强调:消除重复的过程本身是一种设计活动,它驱动出更好的设计。关键在于这个过程是响应式的——你发现重复,理解其本质,再决定是否抽象——而不是预判式的——看到相似代码就急于抽象。
过早抽象的陷阱:Sandi Metz的洞察
Ruby社区知名专家Sandi Metz在2016年发表了一篇引起广泛共鸣的文章《The Wrong Abstraction》。她描述了一个典型的演进模式:
- 程序员A发现重复代码
- 程序员A提取重复,创建新抽象
- 时间流逝
- 新需求出现,当前抽象"几乎"完美
- 程序员B添加参数和条件分支来处理特殊情况
- 更多需求到达,更多参数,更多条件
- 代码变得难以理解
这个模式的可怕之处在于:现有代码会产生一种强大的引力。它的存在本身就暗示着它是"正确且必要的"。我们投入了时间创造它,沉没成本谬误让我们不愿放手。
Metz给出的建议反直觉但深刻:
Duplication is far cheaper than the wrong abstraction.
重复远比错误的抽象便宜。
当你发现自己在往"共享"代码里添加参数和条件分支时,抽象已经错了。Metz的解法更激进:回退。将抽象代码内联回每个调用点,删除那些条件分支,让每个调用点只保留它真正需要的代码。只有当重复的模式重新清晰展现时,才考虑重新抽象。
这听起来像是"浪费时间",但实际经验表明:当抽象已经腐化时,继续在错误基础上修补,只会让代码越来越难以理解和修改。最快的前进方式,有时是后退。
Rule of Three:抽象的时机边界
那么,什么时候应该抽象?Kent Beck在极限编程实践中提出了"Rule of Three"原则,被Jeff Atwood在Coding Horror博客中广为传播:
- 第一次,直接实现
- 第二次,注意到了重复,但忍住
- 第三次,才进行抽象
这个原则背后有一个更深层的观察:构建真正可复用的组件,比构建单用途组件难三倍。
这不是比喻。Robert Glass在《Facts and Fallacies of Software Engineering》中引用的研究表明:
It is three times as difficult to build reusable components as single use components, and a reusable component should be tried out in three different applications before it will be sufficiently general to accept into a reuse library.
Jeff Atwood深信这个原则,以至于他创建的两个公司(Stack Overflow/Stack Exchange和Discourse)都建立在这个理念之上。Discourse在发布时甚至明确告诉用户"现在还不能购买",因为他们需要先找到三个合作伙伴来验证软件是否足够通用。
抽象泄漏定律:所有的抽象都在撒谎
2002年11月,Joel Spolsky发表了一篇影响深远的文章《The Law of Leaky Abstractions》。他提出了一个令人沮丧但无可辩驳的定律:
All non-trivial abstractions, to some degree, are leaky.
所有非平凡的抽象,在某种程度上,都会泄漏。
Spolsky用TCP协议作为经典案例。TCP承诺提供一个可靠的数据传输通道——你发送数据,它保证到达,顺序正确,内容完整。但TCP是建立在IP协议之上的,而IP是不可靠的:数据包可能丢失、乱序、重复。
TCP通过重传、排序、校验等机制,在不可靠的IP之上实现了可靠性。这是一个伟大的抽象。但它仍然会泄漏:如果网线被咬断,TCP无能为力;如果网络拥塞,TCP的传输会变慢,但抽象告诉你"这是可靠的连接",它隐藏了延迟的变化。
这个定律对软件开发有深远影响。每一个抽象——无论设计得多么精妙——都无法完全隐藏底层的复杂性。当你使用一个ORM来抽象数据库操作时,你迟早会遇到N+1查询问题;当你使用一个远程文件系统抽象时,你迟早需要处理网络超时。
Spolsky指出一个悖论:抽象本应简化我们的生活,但抽象泄漏意味着我们仍然需要理解底层细节。你教会一个新手使用STL字符串类,但有一天他们写下"foo" + "bar"然后得到奇怪的结果,你又要回头教他们关于C风格字符串和指针的一切。
这意味着,随着抽象层越来越厚,成为一个熟练程序员反而越来越难。不是因为你需要知道的东西变少了,而是因为你需要知道的东西更多了——每一层抽象的细节。
Hyrum定律:当抽象变成枷锁
Google工程师Hyrum Wright提出了另一个影响深远的观察,被称为Hyrum定律:
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.
当API的用户足够多时,无论你在契约中承诺什么:你系统的所有可观察行为都会被某人依赖。
这与抽象泄漏直接相关。当一个抽象被广泛使用时,用户不仅依赖你文档中承诺的行为,还会依赖各种"恰好能工作"的边缘行为:某个函数恰好返回结果的顺序、某个操作恰好需要的毫秒数、某个错误消息恰好包含的文本。
这些隐性依赖形成了"隐性接口"。当你想要修改实现时,你必须同时维护显式接口和隐性接口。这就是为什么改变一个"内部实现"会意外破坏远处的代码——你以为你只暴露了接口,但用户实际上依赖了实现。
一个足够流行的抽象,其接口会"蒸发"——实现本身变成了接口。任何对实现的改变,都变成了破坏性变更。
数据说话:代码克隆的真面目
学术界对代码克隆(code clone)的研究,提供了更多实证支持。
NSF资助的实证研究表明,软件系统中通常存在7%到23%的重复代码。另一项对GitHub项目的分析显示,9%到31%的项目包含大量可在其他地方找到的文件。
但关键问题是:这些重复代码真的有害吗?
Concordia大学的研究团队分析了代码克隆的不一致修改。他们发现,只有1%到3%的不一致修改会引入缺陷。换句话说,97%以上的情况下,开发者能够有效地管理重复代码的演进。
Kapser和Godfrey的研究更进一步:他们识别出了11种代码克隆的使用模式,发现克隆往往是有目的的实现策略,而非懒惰的结果。在某些场景下,复制代码比创建复杂的抽象更合理。
这些研究结论与"重复代码是万恶之源"的教条形成鲜明对比。重复代码确实需要关注,但它不是自动需要消除的"缺陷"——它可能是一个信号,提示我们需要仔细思考是否值得抽象。
AHA原则:在DRY与WET之间
Kent C. Dodds提出了一个务实的折中方案:AHA(Avoid Hasty Abstractions,避免仓促抽象)。
这个原则承认DRY和WET(Write Everything Twice,写两遍)都有道理,但都过于教条。AHA的核心是:不要对何时抽象设定硬性规则,而是在时机感觉对的时候抽象,不要害怕在到达那个点之前复制代码。
Dodds将Sandi Metz的洞察总结为:
Prefer duplication over the wrong abstraction.
宁可重复,也不要错误的抽象。
他还添加了一个重要原则:优先优化变化。我们不知道代码的未来会怎样。可能花了几周优化性能、设计完美的抽象API,第二天发现需求完全变了。唯一可以确定的是——事情会变化。如果代码永远不需要修改,它长什么样其实无关紧要。
这听起来像是无政府主义,但Dodds强调这需要正念:不是允许任何混乱,而是意识到我们无法预知未来需求。复制代码直到你对使用场景有足够信心,让重复模式自然显现,再进行抽象——这比预判性抽象安全得多。
何时复制,何时抽象:一个实用框架
综合以上研究与实践经验,可以总结出以下判断框架:
应该复制的情况:
- 两个代码片段代表不同的业务知识,只是恰好看起来相似
- 抽象会引入不必要的耦合,将本应独立演进的部分绑在一起
- 重复只出现一两次,且没有明确的共性模式
- 抽象后需要添加参数或条件分支来处理差异
应该抽象的情况:
- 同一块业务知识出现在三个或更多地方
- 修改一处必须同时修改其他地方(真正的知识重复)
- 抽象后代码更清晰,没有引入复杂的条件逻辑
- 抽象的边界明确,调用者不需要了解内部细节
判断的黄金法则: 当你在为"共享"代码添加第四个参数、第五个条件分支时,停下来。抽象已经错了。最快的修复方式是后退——内联代码,删除抽象,让重复重新展现其真实面目。
四十年困境的本质
代码复用的困境,本质上是简化与灵活性之间的永恒张力。
抽象的目标是简化:隐藏细节,暴露接口,让使用者不必关心"如何实现"。但简化是有代价的:你放弃了控制,放弃了选择权。当需求变化时,你能否在抽象的约束下找到出路?
1999年提出DRY原则时,Hunt和Thomas的目标是消除"知识的重复"。但二十多年来,这条原则被简化成了一个口号:“不要复制代码”。这个简化版本丢失了关键的区分——知识重复 vs. 代码重复——而这正是问题的根源。
真正的工程智慧不在于"绝不重复"或"绝不抽象",而在于判断何时该复制、何时该抽象。这种判断无法简化为规则,它需要经验、需要思考、需要理解业务。
Dan Abramov在那次失败的抽象后写道:让整洁代码指引你,然后放手。代码整洁本身不是目标,它只是帮助我们在复杂的系统中找到方向。真正重要的是代码如何与团队一起演进——不是它看起来怎样,而是它如何变化。
重复不是敌人,错误的抽象才是。知道何时应该后退,可能比知道如何前进更重要。
参考文献
- Hunt, A., & Thomas, D. (2019). The Pragmatic Programmer, 20th Anniversary Edition. Addison-Wesley.
- Spolsky, J. (2002). The Law of Leaky Abstractions. https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/
- Metz, S. (2016). The Wrong Abstraction. https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction
- Abramov, D. (2020). Goodbye, Clean Code. https://overreacted.io/goodbye-clean-code/
- Atwood, J. (2013). The Rule of Three. https://blog.codinghorror.com/rule-of-three/
- Wright, H. Hyrum’s Law. https://www.hyrumslaw.com/
- Fowler, M. (2015). Beck Design Rules. https://martinfowler.com/bliki/BeckDesignRules.html
- Dodds, K. C. (2020). AHA Programming. https://kentcdodds.com/blog/aha-programming
- Kapser, C., & Godfrey, M. W. (2008). “Cloning considered harmful” considered harmful: Patterns of cloning in software. Empirical Software Engineering, 13(6), 645-692.
- Bettenburg, N., et al. (2009). An empirical study on inconsistent changes to code clones at release time. Working Conference on Reverse Engineering.
- Rahman, M. M., et al. (2019). An empirical study of code clones. NSF Public Access Repository.
- Glass, R. L. (2002). Facts and Fallacies of Software Engineering. Addison-Wesley.