DRY的幻觉:你消灭的重复,正以耦合的形式回来找你

DRY——Don't Repeat Yourself。这四个字母可能是软件工程里被引用最多、被误解最深的原则。

每个程序员入行第一天就被告知:重复代码是邪恶的,看到重复就要提取,看到提取就要抽象,看到抽象就要泛化。于是我们疯狂地消灭重复——公共函数、工具类、基础库、共享模块......代码库越来越"干净",重复率越来越低,CI里没有任何duplicate code的警告。

然后某一天,一个业务需求来了——需要改动某个"公共"逻辑。你打开那个被47个地方引用的utils函数,改了一行,跑了一下测试——绿了。上线,结果三个业务线同时报警。你突然发现,那三个业务线依赖的是同一个函数的三种不同行为,被那个"公共"函数强制统一了。

你消灭了重复代码,但你制造了耦合。而耦合,是比重复贵十倍的技术债。

 

重复代码不是问题,问题是不知道为什么重复

 

所有关于DRY的讨论,都默认了一个前提:重复等于坏。但这个前提本身就是幻觉。

重复代码至少有三种完全不同的面目。

第一种是意外重复——两个开发者不知道对方写了同样的功能,造了两个轮子。这种重复才是真正需要消灭的,因为它意味着信息浪费和潜在的不一致。

第二种是暂时重复——两个业务场景暂时需要类似的逻辑,但它们的演进方向不同。今天一样的代码,三个月后可能南辕北辙。这种重复如果被过早消灭,就会变成架构上的肿瘤。

第三种是必要重复——两个不同的业务上下文恰好需要类似的代码实现,但语义完全不同。比如金融场景下的"金额计算"和营销场景下的"金额计算",代码看起来一样,但精度要求、四舍五入规则、合规约束完全不同。强行合并,就是在埋地雷。

问题在于,大部分开发者在看到重复代码的那一刻,根本不区分这三种情况。第一反应是"这里重复了,我要提取",然后一个"公共函数"就诞生了。这个公共函数的命名往往是业务语义模糊的——`formatAmount`、`calculateTotal`、`getValidData`——因为它必须兼容两个以上的使用场景,只能取最低公约数。

而最低公约数的命名,意味着最低公约数的设计。

 

过早抽象是DRY最昂贵的副作用

 

编程界有个老话——Rule of Three:同样的代码出现三次,再提取。但在实际工作中,这个规则很少有人遵守。更常见的做法是看到第二次重复就忍不住了,甚至有人看到潜在的重复就开始抽象——"这个逻辑将来肯定其他地方也要用"。

过早抽象的危害,比重复代码更严重。重复代码的问题是一眼就能看到的——代码冗余、维护成本线性增长。但过早抽象的问题是被藏起来的——你以为代码被复用了,实际上你创建了一个只有你理解的依赖关系网,每个调用者都在和这个抽象层做心理博弈。

金融场景有个特别典型的例子:理财产品列表和持仓列表都需要展示"七日年化收益率"。一开始这两处的格式化代码完全一样,很自然地提取成了`formatYieldRate(value)`。后来理财产品列表需要展示到小数点后两位(合规要求),持仓列表需要展示到后四位(用户需求)。于是`formatYieldRate`加了一个参数`precision`。再后来合规要求产品列表的收益率要加星号标注,持仓的不用......几轮迭代后,这个"公共函数"变成了这样:

```javascript
function formatYieldRate(value, precision, options = {}) {
const { showAsterisk, roundMode, useAbsoluteValue } = options;
// ...15行格式化逻辑
// ...8行特殊处理
// ...5行兼容旧逻辑
// ...3行TODO
return result;
}
```

做了多年开发的人一定见过这种函数。它曾经是一行代码。现在它有30多行,5个参数,3种行为模式,而且没人敢改——因为调用方太多,改了谁都说不清会影响什么。

这就是DRY的隐性代价:你消灭了一行重复代码,换来了一坨没人敢动的共享逻辑。而这一坨逻辑的维护成本,远比两份独立的重复代码高——因为重复代码改一份只需要理解一个业务场景,共享逻辑改一次需要理解所有调用方的业务场景。

 

共享模块创造了隐性的权力结构

 

代码复用还有一个很少被讨论的副作用:它创造了隐性的权力结构。

当一个模块被多个团队或多个业务线共享时,谁有权决定它的变更方向?答案是:没有明确规则。于是每个变更都需要"协商"——和调用方协商、和负责人协商、和架构组协商。协商的成本随着调用方数量指数级增长。

这在大型组织里尤其明显。前端公共组件库就是典型的例子——设计团队要求和品牌规范一致,业务A要求支持自定义主题,业务B要求更小的包体积,C要兼容老版本......每一个需求都是合理的,加在一起就变成了一个膨胀到失控的公共库,版本号越来越高,Breaking Change越来越多,迁移成本越来越大。

到最后,大家用共享库不是因为好用,是因为不用不行——你已经深度耦合了。你消灭了代码重复,但你重复了更昂贵的东西:协调成本、等待成本、回归风险。

有些团队会搞出"共享库治理"——代码评审、变更审批、版本策略......这些流程化的解决方案本质上是在用流程成本替代代码重复成本。是否划算,取决于你的重复到底有多少——如果只是两三处重复,用流程成本去替代它,这是一笔亏本买卖。

 

前端的DRY陷阱特别多

 

相比后端,前端的代码复用陷阱更多,因为前端有三个独特的压力源。

第一个是UI一致性诉求。产品经理看到两个页面的按钮样式不一样,第一反应是"抽取一个公共组件"。但UI的一致性和代码的复用是两码事——你可以用Design Token保证视觉一致,而不需要把按钮的交互逻辑也塞进同一个组件。Ant Design的Button组件做得不错——它复用的是样式和基础交互,而不是业务逻辑。但很多团队在"保证一致性"的驱动下,把业务逻辑也耦合成公共组件,结果Button组件需要知道"下单按钮"和"撤单按钮"的区别。

第二个是状态管理的集中化冲动。Redux的单一Store哲学强化了一种信念:状态应该集中管理、统一复用。但一个全局Store意味着全局耦合——任何模块的状态变更都可能影响其他模块的selector逻辑。Dva的model命名空间缓解了这个问题,但没解决——跨model的数据依赖依然是前端架构最常见的痛点之一。前段时间写前端的"状态管理数据库错觉",核心观点之一就是前端在重新发明数据库的范式设计,而数据库范式设计的核心矛盾之一,恰恰是范式化(消灭重复)与反范式化(容忍冗余以换取独立性)之间的权衡。

第三个是跨端复用的诱惑。React Native和小程序让"一套代码多端运行"成为一个可见的诱惑,但不同平台的交互模式、性能约束、API差异决定了即使UI看起来一样,底层逻辑也不应该完全复用。强行复用的结果就是层层适配器、堆堆hack,最终代码量比写两份还多——而且更难维护,因为你看不懂哪层在做什么。

 

跨层复用是最危险的复用

 

前面说的都是单层内的代码复用问题。但真正严重的复用陷阱,是跨层复用。

全栈开发中一个常见但危险的实践:后端的数据模型直接透传到前端。后端有个Order类型,前端就拿这个类型做渲染。后端改了Order,前端就跟着改。这算复用吗?当然算——类型定义只写了一份。但这是一种极其脆弱的复用,因为前后端对同一个概念的语义预期完全不同——后端的Order可能包含审计字段、内部状态机、关联实体ID,而前端只需要展示几个表面字段。当后端因为一个内部需求修改了Order结构,前端就跟着炸了。

BFF层的存在价值之一,就是在这个位置切断跨层复用——不是共享数据模型,而是翻译数据模型。后端说"订单状态是PROCESSING",BFF翻译成前端能理解的"处理中"。这不是重复代码,这是必要的语义隔离。之前写接口契约那篇文章时聊过前后端的语义鸿沟问题——类型对齐救不了协作,而跨层类型复用恰恰放大了这个鸿沟。

另一个常见的跨层复用陷阱:前端直接引用后端的枚举定义。`OrderStatus.PROCESSING`前后端共用一份——看起来很DRY,但一旦后端新增了一个中间状态`RISK_REVIEWING`,前端如果没有同步更新,就会在UI上出现空白或错误。我见过不止一个金融项目的线上事故,根因就是前后端共用的状态枚举没有同步更新——后端加了个风控审核状态,前端还用旧的枚举列表渲染,用户看到的就是一个空白的选择项。

跨层复用的核心问题是:它把两个独立演进的系统用代码耦合在一起,而这两个系统的变更驱动力完全不同——后端受数据模型和业务规则驱动,前端受用户交互和展示需求驱动。当两个不同频率的波动被强行同步,结果只可能是共振——所有问题同时爆发。

 

什么时候该复用,什么时候该重复

 

说了这么多复用的坏话,并不是说DRY原则是错的——DRY对"知识"的复用是对的,但对"代码"的复用需要极其谨慎。

一个更实用的判断标准:

复用知识,不一定要复用代码。同一个业务规则——比如"七日年化收益率的计算公式"——应该在系统中有且仅有一份表达。这是知识的DRY,必须遵守。但这个规则在不同页面、不同端的展示逻辑,完全可以各自实现。这是代码的重复,可以接受。

两份代码有三个以上的差异化方向,不要合并。如果你能预见两个调用方未来会在精度、格式、交互方式中的任何一个维度分化,就让它们各自独立。等分化真正发生了再合并,代价远小于先合并再拆分。

公共模块的调用方超过5个,审视是否该拆分回去。5是个经验阈值——当调用方超过这个数,共享模块的变更协调成本就会超过维护重复代码的成本。不是说一定要拆,但必须审视:这个共享模块是否已经变成了所有人都在迁就的"最低公约数"?

跨层、跨团队、跨业务线的复用,加倍谨慎。层内的同一团队的复用,沟通成本可控;跨层的复用,变更驱动不一致;跨团队的复用,优先级冲突不可避免;跨业务线的复用,则几乎注定会走向"共享库治理"的流程化困境。

每一份"重复代码"旁边写注释说明为什么不合并。这是成本最低的实践,却最被忽视。如果两份代码看起来一样但没合并,后人一定会忍不住"清理"——除非你明确告诉他"这两份代码独立演进,不要合并"。注释就是阻止过早抽象的最后防线。

 

重复不是罪,过早统一才是

 

回到DRY原则的原始含义——Andy Hunt和Dave Thomas在《The Pragmatic Programmer》里写的是"Every piece of knowledge must have a single, unambiguous, authoritative representation within a system"。注意,他们说的是"piece of knowledge",不是"line of code"。

知识的单一表达和代码的单一实现,是两件完全不同的事。业务规则、算法、配置常量——这些是知识,必须唯一。而实现代码,具体到某个组件的渲染逻辑、某个工具函数的参数签名——这些允许重复,只要重复是有意识的、有文档的、有演进空间的。

做了很多年开发的人,多少都经历过这样的循环:最初看到重复就提取,然后共享模块越来越臃肿,再然后花大力气拆分回去,拆完又出现新的重复......来回折腾。这个循环本身就在说明一个事实——代码组织和代码复用没有银弹,只有权衡。

重复代码是可见的技术债——你看得见它,还得了它。耦合是隐形的技术债——你看不见它,直到它爆炸。

与其追求一个零重复的代码库,不如追求一个低耦合的代码库。零重复的代价往往是高耦合,而低耦合的代价是可以容忍一些有意识的重复。

下次在IDE里看到那道黄色波浪线——"Similar code found in..."——先别急着提取。问自己三个问题:这两处代码的演进方向一样吗?它们的变更驱动一样吗?合并后改一处需要理解几处?

如果答案让你犹豫,就让它们重复着。那不是懒,是务实的工程设计。

Total votes: 0

添加新评论