重复你自己
原文由 Matthias Endler 于 发布,订阅该博客
在我从事软件开发的这些年里,被反复提及最多的一条建议就是“不要重复自己”,也就是所谓的 DRY 原则。很长一段时间里,我都对此深信不疑,从未质疑过它的正确性。
直到我看到真正的高手是怎么写代码的:他们经常复制代码1。我才意识到,重复自己其实有不少好处。
为什么人们喜爱 DRY
一种常见的说法是,如果你重复了代码,就得在多个地方修复同一个 bug;而如果你有一个共享的抽象,就只需要改一处。
我们避免重复的另一个原因是,它能让我们感觉自己很聪明。“看,我懂得各种避免重复的巧妙方法!我会用接口、泛型、高阶函数和继承!”
这两种理由其实都有失偏颇。重复自己带来的诸多好处,反而可能让我们在长远上更接近目标。
保持势头
写代码时,你会想保持势头,进入心流状态。如果总是不停地停下来去设计完美的抽象,很容易就会打断节奏。
相反,如果你允许自己复制粘贴代码,就能保持思路的连贯,专注于手头的问题。你也不会同时给自己增加一个“寻找正确抽象”的额外难题。
通常更省事的做法是,先复制现有代码并加以修改,直到复制带来的负担变得难以承受时,再去重构它。
我想说,“编写模式”和“重构模式”是两种截然不同的编程状态。在编写模式下,你要专注于把想法实现出来,压制住内心那个总说你代码很烂的声音。而在重构模式下,你则要扮演相反的角色:成为那个批评者。你要去寻找合适的抽象、消除重复、提升可读性,从而改进代码。
要把这两种模式分开。不要试图同时做两件事。2
找到合适的抽象很难
刚开始写代码时,你还不知道正确的抽象是什么。但如果你不断复制代码,合适的抽象就会自己浮现;一遍又一遍地复制同一段代码实在太繁琐,这时你自然会开始寻找抽象的方法。对我来说,通常在第一次复制之后就会有这种感觉,但我会尽量克制住冲动,等到第二次或第三次复制时再考虑抽象。
如果抽象得太早,很可能会得到一个与问题不匹配的糟糕抽象。你会感到它很别扭,这就说明它错了。一些典型的症状包括:
- 命名过于宽泛,无法表达意图,例如,用
render_pdf_file而不是generate_invoice - 脱离额外上下文就难以理解
- 该抽象只在一两个地方被使用
- 与实现细节紧耦合
错误的抽象很难摆脱
我们很容易满足于脑海中出现的第一个抽象,但它往往并不是正确的。而要移除一个错误的抽象则要费很大功夫,因为现在的数据流已经依赖于它了。
我们还容易对自己创造的抽象产生感情,因为它们耗费了时间和精力。这让我们即便在它们已经不再适用于问题时,也不愿将其舍弃——这就是沉没成本谬误。
当其他程序员也开始依赖它时,情况会变得更糟。这时你就得小心翼翼地修改它,因为它可能会破坏代码库的其他部分。一旦引入了一个抽象,你就得与它共存很长时间,有时甚至是永远。
而如果你只是复制了一份代码,就可以只在一处修改它,而不用担心会破坏其他地方。
重复远比错误的抽象廉价
—Sandi Metz,The Wrong Abstraction
最好等到最后一刻、对问题域有了透彻理解时,再去确定抽象。3
抽象的心智负担
抽象虽然减少了代码重复,但也是有代价的。
抽象会让代码更难阅读、理解和维护,因为你必须在多层间接跳转之间来回切换,才能弄明白代码到底在做什么。抽象可能分布在不同的文件、模块或库中。
穿越这些层次的成本很高。资深的程序员或许能在脑中同时记住几层抽象,但我们每个人的上下文窗口都是有限的(这取决于对代码库的熟悉程度)。
而当你复制代码时,就可以把所有逻辑都放在一处。你只需要通读一遍,就能明白它做了什么。
克制过早抽象的冲动
有时,代码看起来相似,服务的却是不同的目的。
例如,考虑两段通过遍历集合来计算总和的代码。
total = 0
for item in shopping_cart:
total += item.price * item.quantity而在代码的另一处,我们有
total = 0
for item in package_items:
total += item.weight * item.rate在这两种情况下,我们都是遍历集合并计算总数。你可能会想引入一个辅助函数,但这两种计算其实截然不同。
经过几轮迭代后,这两段代码可能会朝完全不同的方向演化:
def calculate_total_price(shopping_cart):
if not shopping_cart:
raise ValueError("Shopping cart cannot be empty")
total = 0.0
for item in shopping_cart:
# Round for financial precision
total += round(item.price * item.quantity, 2)
return total相比之下,运费的计算可能会变成这样:
def calculate_shipping_cost(package_items, destination_zone):
# Use higher of actual weight vs dimensional weight
total_weight = sum(item.weight for item in package_items)
total_volume = sum(item.length * item.width * item.height for item in package_items)
dimensional_weight = total_volume / 5000 # FedEx formula
billable_weight = max(total_weight, dimensional_weight)
return billable_weight * shipping_rates[destination_zone]如果我们过早地套用“不要重复自己”的原则,就会丢失每种计算各自的上下文和具体需求。
DRY 也会引入复杂性
DRY 原则常被误解为一条要不惜一切代价避免任何重复的铁律,而这反而会导致复杂性。
当你试图通过引入抽象来避免重复时,就不得不在远离实际业务逻辑的地方处理所有的边界情况。你最终会在抽象中加入多余的检查和条件,只为确保它在所有情况下都能工作。久而久之,你可能会忘记当初加入这些检查的理由,但又因为担心破坏调用方而“以防万一”地保留它们。结果就是产生了一堆增加代码库复杂性的死代码;这一切仅仅是因为你想避免重复自己。
一种常见的说法是,如果你重复了代码,就得在多个地方修复同一个 bug。但这种说法的假设是 bug 存在于所有副本中。实际上,每个副本都可能以不同的方式演化,bug 可能只存在于其中一个副本里。
当你创建了一个共享的抽象,抽象中的一个 bug 就会影响每一个调用方,一次性破坏多个功能。而对于重复的代码,bug 则只会被隔离在某一个具体的使用场景中。
事后清理
要确认你没有在共享的抽象中引入破坏,比检查单份代码的副本要困难得多。当然,如果你有大量副本,也会有遗漏修复的风险。
让这种做法奏效的关键在于事后清理。这可以在提交代码之前,也可以在代码评审期间进行。
在这个阶段,你可以审视自己复制的代码,看看是保持原样更合理,还是已经能看出合适的抽象。我会等到对问题有了更深的理解之后再去重构代码,而不是更早。
一个撤销糟糕抽象的技巧是,把代码重新内联回它被使用的地方。有一段时间,你会在代码库中再次“重复自己”,但这没关系。基于你掌握的新信息重新思考问题。往往你会找到一个更贴合问题的更好的抽象。
当抽象是错的,最快的出路就是往回走。
—Sandi Metz,The Wrong Abstraction
tl;dr
寻找合适的抽象没有错,但不必对此耿耿于怀。当复制代码能帮助你保持势头、找到正确抽象时,就不要害怕去复制。
这句话值得再重复一遍:“重复你自己。”
例如,可参考 Ferris 开发 Rustendo64 或 tokiospliff 开发 C++ 游戏引擎的视频。 ↩
我写文章也是用同样的方法:先写初稿,屏蔽掉内心的批评者,然后再扮演编辑/批评者的角色去“重构”文本。这样我就能兼得两者的好处:既有不会扼杀创造力的快速反馈循环,又能得到更精致、结构更佳的成品。当然,这种方法并非我首创。如果你想进一步了解这个技巧,我推荐阅读 Anne Lamott 的书Bird by Bird: Instructions on Writing and Life中的“Shitty first drafts”一章。 ↩
这与 OODA 循环的概念相似,即“观察(Observe)、判断(Orient)、决策(Decide)、行动(Act)”。它由军事战略家 John Boyd 提出。战斗机飞行员会运用它来等到最后的可行时刻再决定行动方案,从而能够基于当前态势和可用信息做出最佳决策。 ↩
随机一篇博客
评论
登录后参与讨论