重复自己
在我从事软件开发的整个职业生涯中,被反复提及最多的建议之一就是“不要重复自己(don't repeat yourself)”,也被称为 DRY principle(DRY 原则)。很长一段时间里,我都对此深信不疑,从未质疑过它的正确性。
直到我看到真正的专家写代码——他们一直在复制代码1,我才改变了看法。我意识到,重复自己其实有不少好处。
为什么人们推崇 DRY
普遍的看法是,如果你重复自己,就必须在多个地方修复同一个错误,而如果你有一个共享的抽象,就只需要修复一次。
我们避免重复的另一个原因是它会让我们感觉自己很聪明。“看,我懂得所有这些避免重复的聪明方法!我会用接口、泛型、高阶函数和继承!”
这两种理由都具有误导性。重复自己有很多好处,从长远来看,它或许能让我们更接近目标。
保持势头
写代码时,你会想保持势头以进入心流状态。如果你不断停下来去设计完美的抽象,很容易就会失去这种势头。
相反,如果你允许自己复制粘贴代码,就能保持思路连贯,专注于手头的问题。你不会同时引入另一个寻找合适抽象的难题。
通常,更容易的做法是先复制现有代码并加以修改,直到它变得过于累赘,这时再去重构它。
我认为,“编写模式”和“重构模式”是两种不同的编程模式。在编写模式下,你应该专注于把想法实现出来,压制住内心那个一直告诉你代码很烂的批评家。而在重构模式下,你则要扮演相反的角色:成为那个批评家。你会通过寻找合适的抽象、消除重复和提高可读性来改进代码。
让这两种模式保持分离。不要试图同时做两件事。2
找到合适的抽象很难
刚开始写代码时,你还不知道合适的抽象是什么。但如果你复制代码,合适的抽象就会自行显现;一遍又一遍地复制同样的代码实在太繁琐,这时你就会开始寻找将其抽象出来的方法。对我来说,这种情况通常在第一次复制相同代码后就会出现,但我会尽量克制住冲动,直到复制到第二或第三次。
如果开始得太早,你可能会得到一个不适合问题的不良抽象。你会知道它是错的,因为它感觉很别扭。一些典型的症状包括:
- 通用、无法传达意图的命名,例如用
render_pdf_file而不是generate_invoice - 没有额外上下文就难以理解
- 抽象只在一两处被使用
- 与实现细节紧耦合
错误的抽象很难摆脱
我们很容易满足于脑海中浮现的第一个抽象,但大多数情况下,它并不是正确的那个。而移除错误的抽象是一项艰巨的工作,因为现在的数据流已经依赖于它。
我们也容易爱上自己创造的抽象,因为它们耗费了时间和精力。这使我们即使在它们不再适合问题时也不愿抛弃——这是一种沉没成本谬误。
当其他程序员也开始依赖它时,情况会变得更糟。这时你就必须小心地修改它,因为它可能会破坏代码库的其他部分。一旦引入了一个抽象,你就必须与它共存很长时间,有时甚至是永远。
如果你拥有的只是代码的一份副本,你就可以只在一处修改它,而不用担心破坏其他任何东西。
重复远比错误的抽象廉价
—Sandi Metz(桑迪·梅茨),错误的抽象
最好等到最后一刻再确定抽象,那时你已经对问题空间有了深刻的理解。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 principle 常被误解为一条不惜一切代价避免任何重复的铁律,这可能会导致复杂性。
当你试图通过引入抽象来避免重复时,你不得不在远离实际业务逻辑的地方处理所有边界情况。你最终会在抽象中加入冗余的检查和条件,只是为了确保它在所有情况下都能正常工作。之后,你可能会忘记这些检查背后的原因,但你仍会“以防万一”而保留它们,因为你不想破坏任何调用方。结果就是增加了代码库复杂性的死代码;这一切都只是因为你想避免重复自己。
普遍的看法是,如果你重复自己,就必须在多个地方修复同一个错误。但其前提是假设错误存在于所有副本中。实际上,每个副本可能已经以不同的方式演化,而错误可能只存在于其中一个。
当你创建一个共享抽象时,该抽象中的一个错误会破坏所有调用方,同时破坏多个功能。而对于重复的代码,错误只会被隔离在某一个特定的用例中。
事后清理
要确信你没有在共享抽象中破坏任何东西,要比检查单份代码副本困难得多。当然,如果你有很多副本,就有忘记全部修复的风险。
让这种方法奏效的关键是事后进行清理。这可以在提交代码之前或在代码审查期间进行。
在这个阶段,你可以审视自己复制的代码,看看是保持原样更有意义,还是已经能看出合适的抽象。我会在对问题有了更深入理解后才尝试重构代码,而不是更早。
撤销一个不良抽象的一个技巧是,将代码内联回它被使用的地方。一段时间内,你会在代码库中再次“重复自己”,但这没关系。基于你掌握的新信息重新思考问题。通常你会找到一个更适合问题的更好抽象。
当抽象是错误的时候,前进最快的方式就是后退。
—桑迪·梅茨,错误的抽象
tl;dr
寻找合适的抽象没有问题,但不要对此过分执着。当复制代码有助于你保持势头并找到合适的抽象时,不要害怕去复制。
这句话值得反复强调:“重复自己。”
有关示例,请参阅 Ferris 开发 Rustendo64 或 tokiospliff 开发 C++ 游戏引擎。 ↩
这也是我写文章的方式:我先写出初稿并压制内心的批评家,然后再扮演编辑/批评家的角色来“重构”文本。这样,我就能兼得两全:既有不会阻碍创造力的快速反馈循环,又能得到更精致、结构更佳的最终成品。当然,这种方法并非我首创。如果你想进一步了解这一技巧,我推荐阅读 Anne Lamott(安妮·拉莫特)的书 Bird by Bird: Instructions on Writing and Life(《鸟鸟相传:写作与生活启示》)中的“Shitty first drafts”一章。 ↩
这类似于 OODA loop(OODA 循环) 概念,其代表“观察(Observe)、判断(Orient)、决策(Decide)、行动(Act)”。它由军事战略家 John Boyd(约翰·博伊德)提出。战斗机飞行员利用它来等到最后责任时刻再决定行动方案,从而能够基于当前局势和可用信息做出最佳决策。 ↩
随机一篇博客