别再乱抽象了!重复代码才是最优解!
软件开发中,重复代码常被视为万恶之源,但抽象错误更危险。错误抽象比重复代码昂贵得多,它会随时间恶化为充满条件判断的复杂怪物。最佳策略是宁可有重复代码,也别过早陷入错误抽象,若发现抽象有误,最快的前进方式是后退,通过重新引入重复代码来找到正确方向。
程序员看见重复代码就兴奋
程序员A打开代码编辑器,目光扫过屏幕,突然发现两段代码长得一模一样。那个瞬间,他的大脑就像发现了新大陆的探险家,或者更准确地说,像在自家后院挖到了金矿。他的手指开始微微颤抖,心里那个声音在呐喊:"重复!这是重复!我必须消灭它!"
于是程序员A开始了他神圣的使命。他把那两段代码复制出来,放进一个新函数,给这个函数起了一个听起来很厉害的名字,比如processData或者handleRequest。他可能还专门建了一个新类,毕竟听起来更高级。原来那两段代码的位置,现在变成了两行优雅的函数调用。
代码看起来完美了。行数减少了,结构清爽了,程序员A觉得自己今天的KPI已经超额完成。他愉快地提交了代码,关闭IDE,甚至哼着小曲去接了一杯咖啡。在他的世界里,代码库又向着"整洁"迈进了一大步。他没有意识到的是,他刚刚埋下了一颗定时炸弹,这颗炸弹的倒计时已经悄然启动。这个新的抽象就像一件刚买回来的新衣服,看起来光鲜亮丽,但完全不知道它是否适合所有场合,或者它会不会在第一次水洗之后就缩水变形。
新需求让完美抽象开始变形
时间不紧不慢地往前走,产品和市场又琢磨出新花样了。产品经理带着需求文档找到了程序员B,这次的需求是:在现有功能基础上加一点小变化,几乎和现有逻辑一样,只有那么一丁点不同。程序员B打开代码,看到了程序员A留下的那个漂亮抽象,他觉得直接在抽象上改应该没问题,毕竟只是微调,没必要推翻重来。
程序员B面临一个简单的选择题:复制粘贴改一下,还是让现有抽象变得灵活点。因为那个抽象已经在代码库好几个地方安了家,复用似乎是最体面的做法。他决定给函数加一个参数,根据参数值决定走哪条路。改动不大,测试也通过了,代码顺利合并。看起来一切尽在掌握,老抽象现在能处理新情况了。
但仔细一瞧,原来的抽象已经不再"抽象"了。它本来是一个通用的、对所有调用者一视同仁的解决方案,现在开始对不同的场景做出不同的反应。这种区别对待就像一个交通指挥,本来所有车都走一条路,现在因为来了几辆自行车,他决定在路边开条小道,但小道的规则和主路不一样。代码里开始出现if和else,虽然只有寥寥几行,但它们就像白蚁,繁殖起来只是时间问题。原来看似坚固的统一抽象,现在出现了裂痕。
条件判断在代码里疯狂繁殖
新需求接二连三地找上门来。程序员X来了,带着另一个需求,这个需求又只和现有逻辑有微妙的差别。程序员X打开代码库,看到了那个已经有两个参数的函数。显然,他决定继续这条路,因为他没时间也没胆量重构整个代码。他加入第三个参数,写上新的条件判断。代码依然能用,但文件已经悄悄多了一倍。
就这样,循环开始了。新需求到来,新程序员接手,新参数被添加,新条件被写入。最初那个优雅的小函数,现在变成了一只长满触手的怪物。每个参数都代表一种特殊情况,每个条件判断都在处理某个特定场景。代码里不再是清晰的逻辑流程,而是一团由分支组成的毛线球。没有谁能记住所有参数组合的含义,也没有谁能准确预测改动一个条件会对其他调用造成什么影响。
这个怪物还在继续膨胀。它吞噬着开发者的时间,消耗着团队的精力。每次新增功能,都必须在这堆纠缠不清的条件里找出路。添加一行代码可能意味着需要理解十个参数和二十个条件分支的交互。测试用例的数量也跟着暴涨,因为每个参数组合理论上都需要被验证。最初的抽象已经面目全非,成了套在所有人脖子上的沉重枷锁。
沉没成本让所有人都不敢回头
读到上面那段,你大概已经认出你曾经待过的地方了。你加入这个项目时,那个怪物抽象已经存在了很久。大家都心知肚明这代码有问题,却没人敢碰。代码库的历史记录显示,无数开发者为它投入了海量时间。它复杂得一塌糊涂,仿佛每个代码块背后都藏着一段艰辛的调试史。这恰恰是最大的陷阱。
我们的大脑会对已投入成本产生依恋。这种心理在经济学上叫"沉没成本谬误",在编程里,它让我们对错误抽象死抓着不放。看着那些错综复杂的条件逻辑,潜意识会嘀咕:"这代码这么难懂,写它的人肯定花了无数个日夜才弄对,它一定极其重要。让这些投入打水漂,那简直是罪过。"这种念头推着你继续在怪物身上缝缝补补,试图用更多条件来满足新需求。
结果就是拆东墙补西墙。你改一处,另一处就塌。代码脆弱得不堪一击,改一行就能引出三个新bug。新人花几周时间才能弄懂一个函数。发布新功能的速度慢得像爬。每次成功的修改,都让代码更加错综复杂,也让下一步修改难上加难。你被困在一个亲手打造的死循环里,而出口就在脚下,你却不敢踩,因为那块地板看起来太值钱了。
最快的出路是后退一步
面对错误抽象,最优解往往反直觉:别往前冲,往后退。最快的前进方式,就是倒退。Sandi Metz给出的药方听起来像在开倒车,却是唯一的生路:把抽象出来的代码重新塞回每一个调用它的地方。这步操作叫"内联",就是把那坨共享代码展开,放回每个调用者自己的地盘里。代码量会瞬间膨胀,但这只是暂时的阵痛。
接下来,针对每个调用者,根据它传入的参数,挑出它真正需要用到的部分。因为之前的抽象里塞满了各种条件分支,这些分支对应不同的调用场景。现在你把代码展开后,就能根据实际情况,把不相干的分支和逻辑删掉。只给这个调用者留下它自己需要的那几行。虽然会造成部分代码在文件里多次出现,但每段代码的责任变得清晰明确了。
经过这一步,原来的抽象和那些复杂的条件判断一起消失了。每个调用者只剩下它自己需要的代码,不再依赖一个臃肿的共享函数。有趣的是,在逆向操作的过程中,你常常会惊讶地发现,原来每个调用者虽然都用同一个抽象,但实际跑的代码几乎都独一无二。它们只是"看起来"相似而已,骨子里各有各的脾气。现在它们各自独立了,你可以用一种全新的视角重新审视它们。
重复代码让你重新看清真相
当你把旧抽象完全拆除,代码库恢复到一种"原始"但清晰的状态后,真正的机会才浮现出来。现在你手里有多个独立的代码块,你可以重新审视它们:哪些逻辑是真正共通且频繁变化的?哪些只是巧合长得像?由于它们现在互相独立,修改一个不会影响另一个。你可以在安全的距离外观察它们的演化趋势。
这个时候,重复代码不再是耻辱,而是你最有价值的信息来源。它会告诉你哪几块代码在未来可能会因为同一个原因而改变。比如你会发现,订单处理和库存扣减的逻辑虽然写法不同,但它们总是在同一个需求变更时被一起修改。这个信号非常强烈:它们应该被抽象在一起。相反,两块代码只是碰巧用了相似的循环结构,但修改历史毫无交集,那它们就不应该被强行合并。
通过这样的过程,你可以重新提取真正有价值的抽象。这一次,你拥有充足的数据支撑决策。你看到了代码演化的方向,知道哪些东西该被合并,哪些东西该保持独立。新的抽象会更精准、更稳定,因为它不是建立在猜测上,而是建立在真实世界的变化模式之上。这不是重复做无用功,而是用信息差换来了正确的决策。就像你先画了草图,现在终于能在上面精雕细琢了。
记住你不是第一个被困住的人
Sandi Metz在2014年的RailsConf演讲中就已经提出了这个观点:重复比错误抽象便宜得多。她的这番话在当时引发巨大反响,也点醒了无数在泥潭中挣扎的程序员。为什么这个观点能引起共鸣?因为几乎所有开发者都经历过这种状况。我们都曾是程序员A,也都有过在条件判断迷宫里转圈的痛苦经历。
这个模式跨越语言和框架。不论你用Ruby还是Java,写前端还是后端,错误抽象带来的麻烦都一摸一样。它源自人类共有的思维习惯和心理弱点:我们厌恶重复,我们执着于过去的投入,我们害怕推倒重来。认识到这个心理陷阱,是摆脱它的第一步。当你下次发现自己正在给一个已有函数加第四个布尔参数时,停下来想想,你是不是正在重复那个经典的故事。
更扎心的现实是,当一个抽象被证明是错误的时候,继续投入只会让损失更大。把烂摊子改对需要勇气,但拖延只会让局面恶化。当代码库已经在错误抽象的重压下呻吟,当你发现自己需要同时修改十个文件才能加一个小功能,请记住,你并非第一个被困住的人。那些前辈们也曾面对同样的选择,他们选了那条看起来更容易的路,把代码引向了死胡同。而你,现在有机会选另一条路。
这不是撤退而是换方向进攻
当你决定把错误抽象拆掉,把代码复制回各个调用处时,可能有人会说你是在开倒车。他们会质疑代码行数变多了,重复出现了,好像质量下降了。但真正的软件设计从来不以代码行数论英雄。代码的可理解性和可维护性远比它有多"精简"重要。一个充满条件判断的共享函数虽然只有五十行,但它给团队造成的认知负担,远超过三段各二十行的重复代码。
仔细想想,你拆掉了一个失败的抽象,你让每段代码的责任变得明确,你为未来的正确抽象铺平了道路。这明明就是在前进,只是在换一个更好的方向。重复代码只是暂时的工具,你用它来理清思路,找到那些真正应该被绑在一起的逻辑碎片。等你发现了正确的模式,再去提取新的抽象。这个过程就像考古学家先用铲子把泥土拨开,看清楚文物的全貌,再小心翼翼地把它完整取出。
Sandi Metz在她的文章结尾写得非常干脆:"当抽象错误时,最快的前进方式是后退。这不是退缩,而是朝着更好的方向前进。去做吧,你会改善自己的人生,也改善所有后来者的人生。"这段话值得每一个开发者铭记在心。下一次,当你面对那个长满触手的共享函数时,请对自己说:这不是撤退,是调头,向着更明亮的地方前进。你的同事和你未来的自己,都会为此感激不尽。
代码重复比错误抽象更安全
软件开发界流行一句话叫“不要重复自己”(DRY)。这听起来无比正确:同一段逻辑出现两次,为什么不把它提炼成一个函数呢?但现实世界的残酷在于,我们经常在只看到两处相似代码时就急于抽象,结果创造出一种“错误抽象”。这种抽象表面上消除了重复,实际上把两个本该独立演化的逻辑强行绑在一起。一旦需求变化,这种绑定就变成枷锁。比如两个看似相同的折扣计算,一个用于促销活动,一个用于会员等级,今天它们都用百分比,明天促销改成满减,会员折扣却要叠加积分。如果当初把它们塞进一个通用函数,现在你就得给这个函数加一堆if-else判断,最终这个函数变成一团乱麻。
更糟糕的是,错误抽象会扩散。新手开发者看到已有抽象,自然倾向于复用,而不是重新实现。于是这个本就不准确的抽象被更多模块依赖,它的错误假设像病毒一样感染整个系统。当某天你终于意识到抽象有问题,修改它意味着要同时理解所有使用场景,并保证不破坏任何一个。这种修改的成本远远高于在十个地方分别修改重复代码。重复代码至少各自独立,改一处不影响另一处,而错误抽象把多个模块的命脉系于一根脆弱的绳子上。
错误的抽象还制造了认知负担。读代码时,你看到一个通用函数,必须深入理解它的所有参数和分支,才能确定在当前调用场景下它到底做了什么。而重复代码虽然冗长,却直接展示了在当前上下文中的具体行为。代码的首要目标是被人理解,而不是被机器执行。错误抽象牺牲了可读性,换取表面上的简洁,这种交易在长期维护中注定亏本。
重复代码揭示真实的抽象边界
重复并非全无价值,它实际上是发现正确抽象的必要过程。当你把同一段逻辑复制粘贴到第三处时,才开始真正理解这段逻辑的核心是什么,哪些部分是固定的骨架,哪些部分随场景变化。如果在第一或第二次出现时就抽象,你掌握的信息太少,几乎必然猜错。所谓的“三次原则”不是教条,而是经验教训:抽象需要足够多的实例来验证其合理性。
重复代码还保留了变化的自由度。在产品快速迭代阶段,需求每天都在变,你今天认为相同的逻辑,明天可能就分道扬镳。如果过早抽象,你等于提前承诺了这两处逻辑将永远同步演化,这是一个风险极高的赌注。而保留重复则让你可以各自修改,观察它们是否真的朝着同一方向变化。只有在经过几个迭代周期后,你才能确信这两个地方确实应该共享同一份代码。
此外,从重复代码中提取抽象比修正错误抽象要容易得多。前者相当于做减法,你只需要把相同部分抽出来,用函数调用替换,编译器会帮你检查有没有遗漏。后者相当于重构地基,你必须先拆除依赖这座抽象的所有建筑,再重新打桩,工程量和风险都不可同日而语。所以明智的做法是:先安心复制粘贴,等到重复的次数和场景足够多,正确抽象的形状自然浮现。
错误抽象加剧技术债务的累积
技术债务并非都是坏事,有计划的债务可以加速交付。但错误抽象属于“高利贷”,它不光增加债务本金,还让利息复利增长。每新增一个依赖它的功能,债务就膨胀一圈。由于错误抽象的设计不符合真实需求,新功能往往需要绕过抽象,在外部打补丁,这些补丁又成为下一层错误抽象的温床。最终整个代码库像一座违章搭建的城中村,到处是临时的支柱和悬空的阳台。
更隐蔽的是,错误抽象会阻碍团队协作。在一个大型项目中,不同小组可能负责不同模块。如果核心抽象设计有误,所有小组都必须围绕这个有缺陷的契约工作。某个小组发现抽象不能满足需求,要么被迫修改抽象并通知所有人,要么在本地做变通。前者引发连锁反应,后者造成不一致。而重复代码则天然解耦,每个模块可以独立演化,不受其他模块的抽象选择干扰。
代码审查时,错误抽象也难以发现。一段满是if-else的通用函数看起来“很优雅”,审查者往往不会深究它是否真正代表了业务语义。而重复代码虽然臃肿,却让审查者直接看到每个使用场景的具体逻辑,更容易发现其中隐含的差异。说到底,错误抽象是一种“看起来没问题”的陷阱,它利用工程师对整洁代码的追求,掩盖了更深层的设计缺陷。
重构时优先解体错误抽象
当你的代码库已经存在错误抽象,怎么办?直接删除它往往不现实,因为太多模块依赖于它。更实用的策略是逐步解体:先逆向操作,把抽象函数的内部分支展开,复制回各个调用点,让代码重新变回重复状态。这个过程看似倒退,实则是为了重建正确的抽象基础。在复制过程中,你会被迫仔细审视每个调用场景的独特需求,这正是当初做抽象时遗漏的功课。
解体后,你拥有了一组独立但相似的代码块。接下来不是急着再次抽象,而是让它们独立演化一段时间。观察哪些部分确实保持不变,哪些部分开始分化。经过几个需求的洗礼,那些真正稳定的核心逻辑会显露出来,这时再基于这些扎实的实例进行抽象,成功率大幅提升。记住,抽象的价值不在于消除重复,而在于表达不变性。只有在你能确定哪些是不变的情况下,抽象才有意义。
这种逆向重构在团队中推行时,可能会遇到阻力,因为“消除重复”已经被神化为工程师的基本素养。你必须用具体的案例说服团队成员:保存一份坏抽象带来的长期维护成本,远超暂时增加代码行数的代价。好的工程决策不是追求代码的整洁度,而是追求变更的低成本。重复代码虽然不漂亮,但它诚实,它不掩饰各个场景之间的差异,这种诚实是未来正确抽象的基础。
判断抽象优劣的实践标准
如何判断一个抽象是正确还是错误?一个简单标准:当需求变化时,修改抽象的成本是否正比于使用它的模块数量。如果增加一个使用场景需要改动抽象本身,并且这种改动会影响到所有已有场景,那这个抽象大概率是错误的。正确的抽象应该是开放的:新场景可以复用,但不强迫已有场景适应新规则。
另一个信号是抽象函数的参数数量。如果一个函数需要三个以上的布尔标志来控制内部行为,它几乎肯定在试图服务多个不相关的场景。这种“万能函数”是错误抽象的直接症状。正确的做法是为每个场景提供独立的函数,即使它们内部有部分重复代码。这种重复是可控的,而万能函数内部的逻辑纠缠是失控的。
测试也是检验抽象质量的试金石。如果为一个抽象函数写单元测试需要枚举大量组合,每个组合对应不同场景,说明这个抽象承担了太多责任。反之,如果测试用例简单直接,每种场景的测试独立清晰,说明抽象边界划分得当。记住,可测试性不是抽象的副产品,而是其核心目的之一。难以测试的抽象,必然难以维护。
处理现有代码时优先选择保守策略
面对一个不确定是否应该抽象的代码片段,最安全的选择是保持重复。重复代码至少不会把你锁死在一个错误方向上。你可以用注释标注这些重复片段,说明它们当前看起来相似,但可能在未来演化中分化。这种“有意识的重复”比无意识的错误抽象更加可控。
渐进式重构策略更可取:当发现三处以上重复时,先提取公共部分作为独立的函数,但不替换所有调用点,而是保留一处调用新函数,其他继续使用原代码。观察新函数是否真的被多个场景顺利使用,如果某个场景需要特殊处理,那就在该场景保留原代码。这种“软抽象”给了你试错空间,即使抽象方向错了,受损范围也有限。
代码评审中要特别警惕那些“顺便做的抽象”。当开发者在实现一个功能时,顺手把两段相似代码合并成抽象,这往往是灾难的开端。因为此时的抽象动机不是基于需求,而是基于审美。正确的抽象应该由明确的需求驱动:要么是频繁修改同一逻辑,要么是多个场景确实需要同步变更。如果缺乏这些明确需求,请坚决地选择复制粘贴。
总结
宁要重复代码,不要错误抽象。错误抽象会随时间腐化为条件判断的泥潭,沉没成本谬误只会让你越陷越深。发现抽象有误时,最快的出路是倒退,重新引入重复,用演化数据指导新抽象,这不是失败,而是通向更优设计的必经之路。
作者单位背景: Sandi Metz, 知名Ruby开发者与软件设计专家