代码并非技术债 [译]

目标读者:科技领域的极客和高管们。

Hacker News 上参与讨论。

知识立场:充满自信。

引言

今天,我看到了一篇文章,它的错误程度让我几乎要在 Hacker News 上发表一篇愤慨的长文。

但我的愤慨长文通常篇幅颇长,所以改为在这里写成博客文章。

摘要:代码是一种资产,技术债则是指软件 及其内部模型 与实际问题和我们的心理模型不相符,或者是内部接口并未能尽可能减少假设的情况。

文章解析

这篇文章是 “所有代码都是技术债”(All Code Is Technical Debt),作者是 Paul McMahon

文章的每一个论点都 略有 不妥,从标题就开始出现问题。

让我们逐一分析。

论文

Paul 在他的引言的结尾提出了这样的观点:

随着你在一个应用程序中添加的代码越来越多,开发的速度就会变慢,因此我认为所有代码都是技术债务(technical debt)。

但这种说法真的成立吗?难道增加代码就一定会拖慢开发速度?

让我们换个角度思考:删除代码是否总能提高开发速度呢?

这个想法一看就不对!

人们不喜欢 C 语言的原因之一(除了内存漏洞)是它那简陋的标准库(standard library)。C 语言几乎就是把“删减代码”做到了极致,而我从实际经验中可以告诉你,这样的标准库严重拖慢了开发速度。

我花了差不多7 年时间来构建自己的 C 语言库作为替代。

那么,如果反过来的情况不成立,原始的论点呢?

它根本就不成立。

拿我的最知名的项目中的这次提交来说。

在这次提交中,我在 bcdc 两个程序中都加入了三个新的关键字和命令。

对这次提交来说,我删掉了 7 行代码,加入了 108 行。

更准确地说,我修改了 7 行,新增了 101 行。

也就是说,我平均每增加一个关键字,就在每个程序中删掉了 1 行代码,新增了 18 行。

那么,经过 537 次提交后,这些代码还剩多少呢?

为了得出 537 这个数字,我运行了 git rev-list 1ead5b96..HEAD | wc -l 命令。

我还检查了代码的提交记录,排除了那些仅涉及代码风格的空白修改。在最初的 108 行代码中,有 77 行至今仍然存在,占比为 71.2%。

而且,大多数被修改的代码都是在我后来加入新关键字时改变的数字或数据。

但关键在于增加这些功能的简易度;即便将两个程序的开发合并考虑,加入一个关键字也只用了 36 行代码。这让代码审核、测试和修改都变得简单。仿佛技术债务根本就不存在。

这正是有意为之的

“那好,Gavin,这跟 Paul 的论文主题有何关联呢?”

Paul 的主张是,添加代码总会带来更多技术债务。他这样说道,

在最初构建应用程序时,你能以惊人的速度开发新功能。不必担心这些功能会对现有用户造成什么影响,只需专注于新功能的实现。

然而,随着应用程序日渐成熟,开发速度不可避免地会放慢。在一个糟糕的产品上,开发速度很快就会降低。但即便是在精心打造的产品上,随着时间推移,开发速度依然会逐渐减缓。

但对这三个功能来说,并非如此。包括测试在内,我总共只用了大约两个小时!

当我刚开始的时候,情况并非如此;我可能需要几周时间才能添加一个新功能!

所以尽管我的代码量增加了,我的开发速度却提高了!

代码量增加了多少呢?就是这么多:

截至 2023 年 12 月 20 日的 bc 的 Theseus 堆栈图
截至 2023 年 12 月 20 日的 bc 的 Theseus 堆栈图

这张图只包含了include/src/gen/目录中的文件,并且排除了gen/中的文本文件。

特别值得注意的是,唯一一个代码量大幅减少的月份是 2018 年 8 月,那还是初期阶段。

如果你绘制bc代码的存活率图,它会是这样:

截至 2023 年 12 月 20 日的 bc 的 Theseus 存活率图
截至 2023 年 12 月 20 日的 bc 的 Theseus 存活率图

同样,这也只是统计了上述目录中的文件。如果你为整个代码库绘制存活率图,结果将是这样的:

2023 年 12 月 20 日 bc 项目的第二个 Theseus 堆栈变化图
2023 年 12 月 20 日 bc 项目的第二个 Theseus 堆栈变化图

可以看到,最初的 40% 代码在短短六个月内就被替换掉了,但随后的 40% 即使在五年多的时间里也依然存在。

另外,看到那些在不同时间点出现的小断层了吗?那是我为了降低所谓的“技术债”(即代码中长期累积的问题)而进行的代码重构。

项目初期出现了一个大断层,因为我那时候还在不断地探索和调整代码。六个月后,我终于找到了一个不错的方向,但我并没有就此止步;每当发现问题,我都会着手解决。

事实上,我经常会增加一些代码来降低这种技术债。比如,我在bc中自己实现了一个文件输入/输出系统

“哦,Gavin…”

你真笨
你真笨

其实并不是。

你得知道,我设计了一个内置的命令行历史功能,它需要直接操作终端,这是基本需求。

如果我用了普通的文件输入/输出,虽然能用,但在处理历史记录时会遇到许多麻烦。

但自己动手实现一个文件系统,就解决了这些繁琐问题,同时也减少了技术债。

没错,增加代码反而减轻了我的技术负担。

Paul 可能是根据常规经验来判断的,但我不想只按“拇指规则”行事(这里指过于简化的规则)。

所以,我们需要找到一个更精确的“规则”。

原则

我已经提出了一个观点

你的软件试图解决一个问题,每个问题都有其独特的形态,因为现实充满了出人意料的细节

技术债就是软件与问题不契合的每一处。

(已强调。)

事实上,这个定义很简单明了:技术债务产生于你的软件(及其背后的模型)与它试图解决的问题不相符合的地方。

但事情并非完全如此…

隐喻

“好的,Gavin,那么关于我们的隐喻,我们是应该希望代码更多还是更少呢?因为听起来你似乎倾向于更多的代码。”

不,其实更少的代码确实更好,只要其他条件都相同。但是“其他条件都相同”这种情况几乎和中彩票一样少见。

这并不意味着“代码越少越好”同样罕见;事实并非如此。

我想提出一个新的隐喻:把代码/软件看作是一种资产

“那技术债务是什么呢?”

耐心点。

如果我要彻底运用财务术语,那就让我们来谈谈负债

负债

一个[n]…实体所欠的价值。

作为程序员,你的责任是解决问题。代码里任何不助于解决问题的部分,都构成了这个负债。

所以,技术债务其实就是代码负债

我之所以用“代码负债”而非“技术负债”,是为了强调这不只是技术人员的问题不仅仅是技术人员的问题

“这太愚蠢了,Gavin;资产怎么可能是负债,你的隐喻不成立。”

确实,资产本身不能是负债,但它们可以附带负债。

例如,如果我贷款买车,那么这个债务就是一个负债,尽管那辆车本身是一个资产,而且这个负债与车相连。

同样地,作为资产的代码也可能附带负债,进而降低其价值。

更深一层的意思是,你可能会在资产上面“负债累累”(underwater)。这意味着你的资产的价值可能低于其所附带的负债。

在代码方面也会出现这种情况;当开发速度缓慢到几乎停止时,你就陷入了困境。

这可能因为两个原因:问题发生了变化(你的负债增加了),或者你的软件变得不再那么有用(你的资产贬值了)。

没错,我依然在使用财务隐喻。

“软件怎么可能贬值,Gavin?它不过是代码。”

node_modules 加入讨论

如果软件的运行环境发生变化,它可能就无法运行了。而无法运行的软件,其价值就等同于零。

这就是我为什么选择 C 语言的又一个原因:它的环境是稳定不变的。

“但这并不能完全解释你所说的‘零’代码责任,Gavin。”

你的观点有一定道理。我的确会持续关注相关问题,并且不断更新我的软件,以确保其适应性。

接口

但这并非我几乎没有代码责任的唯一原因,而你却面临这种责任。

Paul 曾说过,“增加新的假设意味着增加债务,”这个观点是正确的。

但他认为增加功能必然伴随增加假设。其实,如果你合理设计和使用接口,就几乎不需要引入新的假设。

不相信吗?

想想看,我给我的bc语言添加了关键字,这完全没有增加任何新的假设。

其解析过程遵循了现有的解析规则(只解析到需要的字符,不多也不少),以及虚拟机现有的假设(从结果栈中移除操作数,然后放入新的结果)。

“但这仅仅因为你在开发一门编程语言,而且可以轻松做出假设!”

实际上,这种编程语言是图灵完备的!在编程领域,这是极其复杂的一个概念。要让一门编程语言能够精准解决其设计所面向的问题同样非常困难。

我还在一门更复杂的语言中实现了类似的成就,其中包含用户定义的关键词用户定义的词法分析器

即将发布...

总结一下:你的接口应当尽量减少假设

换种方式来说,代码的内部模型(假设)需要与其内部接口相匹配。

我的做法是:

  • 永远在自己的能力范围内编程。
  • 严格定义接口,包括接口的前提条件和后置条件。
  • 严格遵循这些接口进行编程,必要时对接口或编码进行调整。
  • 不断迭代,直至接口近乎完美。

当这些接口能够轻松地加入新功能而不影响已有功能时,我就知道我已经成功了。

模型

然而,这仍然不够。

软件可能面临的最后一种风险是,它与人们心中的模型不吻合。

这里所说的是心理 模型。

著名论文“编程作为理论建构”原文)提到了这个概念,称之为“理论”,但其实是指的同一个意思。

无论用什么术语,模型或理论本质上是软件在其创造者和使用者心中的存在形态。

当然,这个心理模型不可能完全准确;如果准确无误,我们就不会遇到任何程序错误。因此,代码风险的另一方面就是软件与心理模型之间的不一致。

为了解决这个问题,我会反复进行测试。我使用易于出错的版本进行模糊测试,并修复所有发现的错误。

“但这如何提升你的心理模型呢?”

因为在我看来,程序错误的定义之一就是我的心理模型与软件实际表现之间的差异

所以,当我修复一个错误时,实际上是在缩小我的模型与实际软件之间的差距。我会一直这样做,直到二者达到和谐统一。

有时候,这还包括在现实情况更合理时调整我的心理模型。

膨胀

另外,不必要的代码膨胀同样会带来风险。

所有的代码都需要维护成本,因此多余的代码会带来不必要的高额维护费用。

我会定期审查代码,以清除不必要的部分。

预防

我还做了一件几乎没什么开发者会做的事:在开始编码之前,我会先设计我的代码。

换言之,我会先构建一个包含软件要解决问题的心理模型,然后才动手编程。

这样可以确保我的编码工作高度集中于其既定目的,并有助于防止软件实际情况与心理模型之间的偏差。

当然,我也需要不时更新最初的模型,但如俗话所说,预防胜于治疗。

就是我如何有效地避免代码风险。

其他观点

我认为有必要回应 Paul 提出的其他几点。

功能可能带来负面价值

确实,功能如果与问题不相符,就可能带来负面影响。这正符合我的比喻:这样的功能仍然是资产,但就像一辆报废的汽车,是个糟糕的资产。

现实世界里的例子也有,比如不适应需求的 空中客车 A380

代码本身并非固有价值

的确如此。

解决不了问题的代码,其实是负资产。

这实际上就是在重申:“功能可能带来负面价值”。

一旦添加了某个功能,它就可能永久存在

这个观点大体上正确。

这就是为什么我主张在一开始就尽量减少代码负担;通过果断削减那些不必要的功能,我就可以避免这个问题。

在不影响商业目标的前提下,我建议你也尝试这样做。

避免技术债务的方法是不编写代码

确实,但这就好比说“要避免在篮球比赛中出手失误,就不要投篮”。

代码是解决问题的工具,但要记住,任何工具都需要维护才能发挥其价值。编写代码要解决实际问题,确保其与问题紧密对应,并持续维护。

在现有假设的约束下工作

没错,这是非常有用的建议。这与我之前提到的关于假设的建议是一致的。当然,如果可以的话,你应当改变那些不合适的假设。

结论

关于技术债务和代码负债的讨论一直很热烈,以下是我的看法:

  • 代码是资产。
  • 但代码可能存在负债,具体表现为:
    • 代码与问题不相符。
    • 代码的内部逻辑与问题不吻合。
    • 代码的内部逻辑与用户及程序员的思维方式不一致。
    • 代码的内部逻辑与代码自身的接口不协调。
    • 当然,还有代码量超出解决问题所需的情况。
  • 代码可能会让你陷入僵局,这通常发生在开发停滞不前时。
  • 因此,和任何其他资产一样,代码也需要持续维护。

我的观点对吗?这个问题我留给你来判断。

让这场辩论继续下去!