本文是一篇译文,原文地址:https://www.deconstructconf.com/2019/dan-abramov-the-wet-codebase
原文标题:The Wet Codebase
写在前面
本文翻译自 React Team 成员 Dan 在2019 年的一次演讲,主要介绍了他对代码中的抽象的思考。译文有删减。
正文开始
这次演讲和React没有什么关系。这次演讲的主要内容是我愿意穿越回去和过去的自己说的一些话,是关于一个很久之前的代码库的事。
这是一个我很早之前维护的代码库。在这个代码库里,有两个不同的模块,两个文件。我的同事和朋友正在其中一个文件中开发一个新功能。他们注意到实际上这个特性,非常相似的东西已经在另一个文件中实现了。所以他们想,好吧,为什么我不复制并粘贴这些代码呢,因为它们几乎是一样的东西。
他们让我review代码。我只是读了所有关于最佳实践的书:Pragmatic Programmer, Clean Coder, Well Groomed Coder。你不应该复制和粘贴代码,因为这会造成维护负担,很难处理。我刚学了这个缩写DRY(Do not repeat yourself),意思是不要重复你自己。我觉得这看起来像个复制品,我们可以把它"晾干一点"吗?
我的同事说可以把代码提取到一个单独的模块中,让这两个文件依赖于新的代码。于是一个抽象概念诞生了。好 啊。所以当我说抽象的时候,我的意思是你用的是哪种语言并不重要。它可以是一个函数或一个类、一个模块、一个包,或者一些可重用的东西,可以从代码库的不同位置使用。
所以看起来,这太棒了。从此他们过着幸福的生活。让我们看看抽象是如何演变的。接下来发生的事情,我们已经有一段时间没有看代码了,但是我们正在开发一个新特性,它实际上需要一些非常相似的东西。假设最初的抽象是异步的,但是我们需要的是具有几乎相同的东西,只是它是同步的。
所以我们不能再直接重用这些代码了,但是复制和粘贴它也感觉很糟糕,因为它几乎完全相同,只是有点不同。而且,看起来我们不应该重复我们自己,所以让我们把这两个部分统一起来,让我们的抽象变得更华丽,这样它就可以处理这个问题了。我们感觉很好。这有点非正统,但当代码符合现实生活时就会发生这种情况,对吧?你做了一些妥协,至少我们不必重复代码,因为那会很糟糕,对吧?
接下来我们发现,实际上,这个新代码,这个新特性,有一个bug,这个bug是因为我们认为它需要一些和我们完全相同的代码。但实际上它需要一些稍微不同的东西。当然,我们可以通过添加一个特殊情况来修复这个错误。所以我们的抽象,可以有一个if语句。如果是这种特殊情况,那就做些稍微不同的事情。
当我们处理这些代码的时候,我们发现原来的代码也有一个bug。那两个我们认为是一样的case,它们也有点不同,只是我们当时没有意识到。所以我们又加了一个特例。在这一点上,这个抽象看起来有点怪异和吓人。所以也许让它更通用。为什么抽象中有那么多特殊情况?
让我们把它们从抽象中提取出来,它们属于我们的具体用例。看起来像这样。所以现在我们的抽象不知道任何具体的案例。它很普通,很漂亮。没有人真正理解它代表什么了。哦,顺便说一下,我们需要补充一下,既然它是从不同的地方参数化的,我们需要确保所有的代码中都参数化了。
但这是一个渐进的过程,在每一步都对编写和review代码的人来说都是有意义的,所以我们就到此为止。一段时间过去了。在这段时间里,有些人离开了团队,有些人加入了团队。代码里有很多修正。有人需要在这里做一个小的修复。我真的不知道这个东西应该做些什么,只是稍微修正一下,添加这个新特性,改进指标。所以我们就这样结束了,对吧?
再说一遍,每一步都是有意义的。但是如果你忘记了你最初想做什么,你就不知道你有一个周期性的依赖,或者出现奇怪的,只是因为你不再看到全局。当然,在现实生活中,这就是故事的结尾,没有人想接触代码库的一部分,代码库停滞了很长一段时间,然后有人重写了它。也许还能升职。
但如果我们能回到过去,因为这是一个谈话,它不是真实的生活,如果我们有一个时间机器,我们可以回去修理它。所以我想回到抽象仍然有意义的地方。但如果我们有第三种情况,我们真的不想复制代码,即使它需要一些稍微不同的东西。他们说,让我们在抽象上妥协吧。所以这是如果我从今天开始就在那里,我会告诉自己的是,请让这个抽象内联吧。
我所说的内联,是把代码直接复制粘贴到使用它的地方。这会造成一些重复,但会摧毁我们正在创造的潜在怪物。当然,从长期来看,复制并不是完美的,但错误的抽象从长期来看也是不完美的。所以我们需要平衡这两个问题。这对我们有帮助的方式是,如果我们有一个bug,我们意识到这个东西应该做一些不同的事情,我们可以改变它。它不会影响其他任何地方,因为它是孤立的。同样的,也许我们得到了一个不同的错误,我们也改变了它。
我并不是建议你总是复制粘贴的东西。从长远来看,你会意识到这些碎片是有道理的。也许你从中提取了一些东西,它可能不是你最初认为的好的抽象。可能会有所不同。像这样的事情在实践中是最好的。
我认为这实际上是一个自我延续的循环。因此,开发人员从上一代中学习最佳实践,并尝试遵循这些实践。因为有一些具体的问题和具体的解决办法是从经验中产生的。所以下一代人会努力把它们传下去。但是很难解释所有这些上下文和所有这些权衡,所以它们只是被简化为这些最佳实践和反模式的思想。
所以他们被教导给新一代。但是,如果新一代人不了解权衡和他们得出这些结论的原因,他们就没有足够的背景来决定什么时候这是一个坏主意,你能把它延伸到多大程度。因此,他们在试图将这些最佳实践和反模式发挥到极致时遇到了自己的问题。所以他们教下一代。也许这只是你不能打破这个循环,它一定会一次又一次地发生。
我认为打破这种循环的一种方法是,当我们教给下一代的东西时,我们不应该只是二维的,说这里有最佳实践和反模式。我们应该试着解释一下,你到底在权衡什么。这个想法的好处和代价是什么?所以当我们谈论抽象的好处时,它当然有好处。整个计算机就是一大堆抽象的东西。抽象的好处就是让我把重点放在具体的意图上。
但事实上,能够专注于一个特定的层是非常好的。也许你有几个地方的代码,是想要发送电子邮件,你不想知道如何电子邮件是如何发送的。但你可以调用一个名为send email的函数,而且大多数时候它都能工作。能够专注于它真的很好。当然,另一个好处就是能够重用你或其他人编写的代码,而不需要记得它实际上是如何工作的。
因此,如果我们需要一些东西,完全相同的东西,我们已经从不同的地方使用,能够重用它是非常好的。这是抽象的好处。抽象也可以帮助我们避免一些错误。所以在我们有一个bug的例子中,也许我们复制粘贴了一些东西。这是一个反对复制粘贴的论点,就是我们复制粘贴了一些东西,然后我们在一个版本中发现了错误,我们修复了它,但是另一个版本仍然是坏的,因为我们忘记了复制粘贴。所以这是一个很好的论据来解释为什么你想提取一些东西然后把它拿走。
但当我们谈论利益时,我们也应该谈论成本。所以其中一个代价就是抽象会产生偶然的耦合。我的意思是,我们有这两个模块,使用一些抽象,然后我们意识到其中一个模块有一个bug。我们必须在抽象中修复它,因为这就是代码所在的地方。但是现在你的职责是考虑这个抽象的所有其他调用站点,以及你是否真的在另一个代码库中引入了一个修复,在代码库的另一个部分引入了bug。所以这是一个成本。也许你能接受它。我们大多数人也能忍受。但这确实是一个真正的代价。
我们努力避免面条代码,所以我们创造了另外一种面条代码,其中有很多层,你根本不知道发生了什么。所以这是额外的间接性。
所以抽象也会在你的代码库中产生惰性。这是一个社会因素而不是技术因素。我见过很多次的情况是你从一个看起来很有前途而且对你来说有意义的抽象开始。随着时间的推移,它变得越来越复杂。但是没有人真的有时间重构或展开这种抽象,特别是如果你是团队中的新人。你可能会认为复制和粘贴它会更容易,但首先你不再真正知道如何去做,因为你不熟悉这些代码。其次,你不想成为一个只建议最坏做法的人。谁想成为那个说,让我们在这里使用复制粘贴的人?你觉得你会在那个团队呆多久?
你只是接受现实,继续做下去,希望这些代码不会很快成为你的责任。问题是,即使你的团队真的同意抽象是糟糕的并且应该内联,那可能就太迟了。因此,可能发生的情况是,您只熟悉这个具体的用法,并且知道如何测试它。如果您展开抽象,您就可以理解如何验证更改没有破坏任何内容。但可能有另一个团队在这里使用它,另一个团队在那里使用它,可能这个团队已经被重新调整,所以没有团队维护代码,而且您也不知道如何测试它了。所以即使你想改变,你也无法改变。
容易替换的系统往往会被难以替换的系统所取代,这有点像彼得斯原理。彼得的原则是,组织中的每个人都在不断地加薪,直到他们变得不称职,然后他们就不能再加薪了。类似的是,如果某件东西很容易被替换,它很可能会被替换。然后在某个时候,你达到了极限,那就是一团糟,没有人知道它是如何工作的。
我不是说你不应该创建抽象,我是说有些事情,我们会犯错误。那么,我们如何才能真正减轻或减少这些错误带来的风险呢?我在React团队学到的其中一个方法是测试具有具体业务价值的代码。我的意思是,假设我们有一个有点不稳定的抽象,但是我们终于有时间来写一些适当的测试,因为我们修复了一些错误,在新的半年开始之前我们还有一段时间,我们可以修复一些东西。
所以我们想为这个部分写一些单元测试覆盖。直观地说,我将把单元测试放在这里,好吧,这是复杂代码所在的抽象。所以让我们用单元测试来覆盖这些代码。在我看来,这实际上是个坏主意,因为如果后来你觉得这个抽象不好,你试图把它变成复制粘贴,那么,猜猜通过你的测试会发生什么?他们都失败了。现在你会想,嗯,我想我得把它改过来,因为我不想重写所有的测试。我不想成为那个建议减少代码覆盖率的人。所以你不能那样做。
但是如果你有一台时间机器,你可以回去写你的单元测试或集成测试,对照我们真正关心的代码,这些代码是针对具体特性工作的,并不关心你的抽象性。所以你可以把抽象内联回来。您可以创建五个抽象层。测试将告诉您此代码是否有效。实际上他们会引导你去重构它,因为他们可以告诉你你的重构实际上是正确的。所以测试具体的代码是一个很好的策略。
另一个就是克制自己。你看到了完整的请求,觉得这看起来是重复的,就想要开始进行抽象。但这是不合适的。仅仅因为这两个片段的结构看起来很相似,这可能意味着你还没有真正理解问题所在。给它一点时间来实际证明这是同一个问题,而不仅仅是意外地相似的代码。
最后,我认为重要的是,如果发生这种情况,如果你犯了错误,这应该是你的团队文化的一部分,这种抽象是不好的。我们得把它处理掉。您不仅应该添加抽象,还应该删除它们,作为健康开发过程的一部分。所以这意味着你可以留下这样的评论说,嘿,这已经失控了。让我们花点时间来复制和粘贴这个,然后我们会想出如何处理它。
但这也有一个技术因素。所以如果你的依赖树很复杂,那么内联任何东西都可能是非常困难的。假设有一个你想内联的东西,你可以复制它,但是有一些可变的共享状态,现在被复制了。你需要弄清楚如何把这些依赖关系重新连接起来。有可能这是做不到的,然后你就放弃了。我真的没有一个好的解决办法。对于某些代码,你无法真正避免它。例如,在React的源代码中,我们确实存在这样的问题。因为我们试着为你改变,所以你不必去改变它们。所以我们有模块之间的所有这些相互依赖性,这可能有点难以思考。
但在我看来,React最酷的地方在于,它可以让你用依赖树编写应用程序。你有一个从表单使用的按钮组件,这个表单是从app使用的。诸如此类。它遵循这棵树的形状。我们限制了数据流只能是单向的。而双向数据流往往意味着你会犯错误,你会创造出糟糕的抽象,但是你的技术能让你更容易地摆脱它们吗?
我认为对于React组件和其他一些受约束的依赖形式,你有一个很好的属性,通常是复制和粘贴内容,以便内联它们。所以,即使你做了一个错误的决定,你也可以及时撤销它。这是在团队合作和技术方面都要考虑的问题。''不要重复'只是其中一个原则,可能是相当好的想法。
我们常常会从别人那里吸取很多经验之谈。这很好。但我认为重要的是,当我们试图解释这些事情的作用或为什么它们是一个好主意时,我们应该始终解释你到底在权衡什么,是什么东西导致了这个原则或想法。这些问题的有效期是什么时候?有时候会有一些假设的上下文,而这个上下文实际上发生了变化,但是你没有意识到这一点。因此,下一代人需要了解到底是在权衡什么,为什么要这么考虑。
挑选一些你坚信是正确的最佳实践和反模式,不管是从你的经验中,还是因为有人告诉你,或是因为你想出了它们,然后真正地试着分解它,解构你为什么相信这些东西,以及到底为什么要这么做。
- 正文结束
写在后面
我们写代码的时候,经常性的会抽象一些逻辑出来,做成公共组件之类的东西。但是,很多时候,这或许并不是最好的方法。当你在没有完全看清事情的本质时,不要过早的进行抽象,否则会产生很多奇怪的问题。不光是逻辑上的问题,还有团队协作的问题。对于一些经验之谈,最佳实践等,不要盲目地照搬,去拆解它,理解其背后的思考。
结合自己的业务,写出最合适的代码。