代码行数是有用的

6
分类佳文共赏
作者kqr
来源跳转
发表时间

内容

互联网上到处都是把代码行数(lines of code)当作一种度量方式而加以否定的人。 人们会说诸如此类的话:

多年来早已充分证明,编写的代码行数基本上是一个 毫无意义的指标。

以及

指标和 KPI 居然建立在像代码行数这种愚蠢的度量上。

以及

代码行数是个很蠢的指标,任何把它吹捧成有意义东西的人 都是脱离现实的。

以及

显然,我们集体忘记了:代码行数是衡量生产力最糟糕的 指标之一。

以及

我认为,对代码行数的执念是最适得其反的做法之一。

以及

代码行数素以极差的度量方式而闻名,几乎测什么都不靠谱

我觉得这些说法很奇怪,因为它们并不是真的

衡量复杂度

代码行数衡量的是代码复杂度。这一点确实早已有充分依据。你 不必只听我一面之词:

  • Basili 和 Hutchens(1981)分析了 19 个程序,发现代码行数与他们自定义的体量(volume)指标高度相关(+0.98),与圈复杂度(cyclomatic complexity)也高度相关(+0.88)。他们还考察了其他复杂度指标,比如 Halstead 体量、控制流图、嵌套层级等。但这些复杂度指标没有一个比简单的代码行数更能预测“做出一个可运行程序所需的工作量”。
  • Revilla 和 van der Meulen(2007)分析了超过 70,000 个小型 C 程序,这些程序执行 59 种不同任务,结果表明代码行数与 Halstead 体量(+0.82)和圈复杂度(+0.78)都具有很强相关性。他们发现,单看代码行数,对各种事情的预测能力并不比更复杂的复杂度指标差。
  • Herraiz 和 Hassan(2010)分析了真实开源项目(Arch Linux)中超过 200,000 个 C 源代码文件,发现代码行数与圈复杂度(+0.72)以及多项 Halstead 指标(+0.91)都高度相关。

每当我们真的花力气去检验某种代码复杂度指标时,结果总是一样:它的预测效果与简单的代码行数统计相同,甚至更差。这并不意味着不可能存在更好的复杂度指标,只是意味着我们默认应当持怀疑态度。既然之前所有的复杂度指标,拆开外衣后本质上都像是披着风衣的“代码行数测量”,那下一个大概率也还是如此。

Basili 和 Hutchens 对此总结得尤其到位:

由于代码行数极易计算,而且许多研究者都发现它在衡量复杂度方面 相当可信,因此在这类研究中,它必须被视为需要被超越的基准指标。 我们未能找到一个显著优于代码行数的指标。

代码行数并不是一种毫无意义的度量。它是我们目前所知衡量代码复杂度的最佳方式。

复杂度很重要

而复杂度,反过来,决定了软件构建和维护的成本,并且在某种程度上也决定了它有多大用处。其他条件相同的情况下,复杂度更高的软件:(a)成本更高,以及(b)能执行更有用的任务。

这里有必要区分 Fred Brooks 所说的本质复杂度(essential complexity)和偶然复杂度(accidental complexity)。1 1 No Silver Bullet: Essence and Accident in Software Engineering;Brooks;Proceedings of the ifip Tenth World Computing Conference;1986。

本质复杂度

我们可以把本质复杂度理解为由待解决问题本身带来的复杂度。2 2 Brooks 还提到了软件中我们必须面对的另外几种本质复杂度,它们更多与软件“不可见”和“分形式”的特性有关。在比较两个不同程序的复杂度时,这些并不相关,因为它们对所有软件都同样适用。让火星车着陆火星是一个复杂问题,而实现火星车着陆的软件,不可能比“火星车着陆这件事本身所固有的复杂度”更简单。正如 Brooks 所说,

软件工程师必须掌控的复杂性中,很大一部分是任意性的复杂性, 它是被……许多……其接口必须遵从的系统所强加的。…… 仅靠重新设计软件本身,并不能把这部分复杂性消除掉。

如果还想保留功能,就不可能移除本质复杂度。想要降低本质复杂度,我们就必须砍掉功能。

偶然复杂度

另一方面,偶然复杂度并非来自待解决的问题本身。相反,它是在把问题转译成软件时被引入的复杂度。Brooks 并没有直接给出偶然复杂度的定义,但他讨论了工具进步如何消除了其中一部分。比如他说:

抽象数据类型(abstract data types)……又移除了流程中的一项偶然性困难, 使设计者能够表达其设计的本质,而不必……写下大量并未增加任何 新信息内容的语法材料。其结果是,可以用更高层次来表达设计。

因此,所谓偶然复杂度,就是我们写进代码里、但原本不必存在的复杂度。有些偶然复杂度是被迫写进去的,因为我们所用的编程语言缺少我们需要的抽象;还有一些则是因为我们并不都是摇滚明星级的 10× 忍者开发者。

复杂度意味着成本

这一区分至关重要,因为

  • 本质复杂度会提升软件的价值,而
  • 本质复杂度和偶然复杂度都会提升软件的成本。

无论好坏,代码行数衡量的只是总复杂度;它无法区分本质复杂度和偶然复杂度。其好处在于,代码行数与软件成本高度对应:例如,Blender 的代码行数比 nginx 更多(确实如此),那我们就会预期 Blender 的开发成本更高(也确实如此)——同时它的持续维护成本也更高(同样如此)。关于这点,附录 A 里有一些挺有意思的观察。

代码行数与复杂度之间的这种关系,不仅适用于软件项目的总体规模(项目越大,成本越高),也适用于软件规模的变化。一个项目如果每天增长若干行代码,那么它的持续维护成本也会每天增加若干分钟。单次小改动时,这种影响很小;但随着时间推移,它会不断累积。代码行数就是我们衡量这种维护成本增长的方式。

即使没有任何具体测量数据,我们也可以把它当作一种规划上的经验法则。比如,一个团队今天把四分之一时间花在维护上,而我们预期接下来一年项目规模会增长到两倍,那么新功能开发的速度就会下降三分之一,以抵消新增代码带来的维护负担。

衡量新增价值

说到这里,我们或许会忍不住承认,Dijkstra 当年写下这段话时一直都是对的。3 3 ewd 1036: On the cruelty of really teaching computer science;Dijkstra;1998。📚 在线可读。

我今天的观点是,如果我们要统计代码行数,就不应把它看作 “产出的行数”,而应看作“花掉的行数”:当前的传统智慧愚蠢到 把这笔账记在了分类账的错误一边。

但事情也没那么简单。写出来的代码行,确实是“花掉的行数”,但其中一些同时也是“产出的行数”。新增复杂度里,有一部分是偶然复杂度,另一部分是本质复杂度。后者确实会提升软件的价值。

如果一个项目中,本质复杂度与偶然复杂度的比例相对稳定(至少在中等时间跨度上、从整体来看,这么认为是合理的),那么代码行数同时也可以作为一个与本质复杂度弱相关的度量,也就是与软件能够提供的价值量弱相关的度量。

对于个人生产力,这同样成立。如果某个人的贡献中,本质复杂度与偶然复杂度的比例从一个月到下一个月都相对稳定,那么他为项目增加的代码行数,就可以作为“他让软件多提供了多少价值”的一个代理指标。不过,不同类型的工作,其本质复杂度与偶然复杂度的占比并不相同,所以除非把很多次贡献平均起来,否则不能安全地假设这个比例是稳定的。4 4 古德哈特定律(Goodhart’s law)显然也是个问题:如果一个人知道自己是按代码行数来被评价的,他很可能就会开始制造更多偶然复杂度,从而只增加成本,不增加价值。

按照这个推理,听起来似乎删代码永远都不是好事,因为它与负生产力相关。但我不是这个意思!删代码会让软件更便宜、更易维护。不过,移除偶然复杂度(纯粹的好事)和移除本质复杂度(有时是好事,但更难评估)之间,存在重要区别。

让问题更加复杂的是,并非所有本质复杂度都能带来同等价值。有些有价值的功能从根本上就很简单,而另外一些功能可能很复杂,却也并不那么值得拥有。这进一步削弱了代码行数与价值之间的相关性。

指导建议

上面的讨论可能会让人觉得,代码行数终究还是个无用指标,但重要的是,我们要区分它作为指标的两种用法:

  • 作为成本指标时,代码行数基本没问题,即便考虑到古德哈特定律也是如此。因为代码行数几乎是总复杂度——即本质复杂度加偶然复杂度——的完美代理指标。
  • 作为生产力指标时,代码行数就很难用了,因为它只是本质复杂度的不完美代理,而本质复杂度又只是客户价值的不完美代理。不过,如果我们能够确保“价值与本质复杂度之间的关系”是稳定的,并且“本质复杂度与偶然复杂度的比例”也稳定,那么代码行数也可以作为生产力指标来使用。5 5 不过要注意,“价值与本质复杂度之间关系稳定”意味着你的产品组织并没有越来越擅长自己的工作。你未必会想要以此为目标。

看,这可比本文开头那些引语要细致得多了。

附录 A:开源维护的成本

文中我们用 Blender 和 nginx 做了例子。Blender 有 280 万行代码,nginx 有 25 万行代码。猜测 Blender 的维护成本高于 nginx,似乎很合理;事实也确实如此,至少从一个角度来看是这样。这里我们用维护者数量作为维护成本的代理指标,定义为:过去六个月里提交次数超过五次的作者人数。下面是一些 GitHub 上热门项目的数据。

项目代码行数维护者
Rust3,800,000229
Blender2,800,00071
Kubernetes2,300,00089
VSCode2,000,00081
Node.js1,300,00039
PowerToys760,00015
React550,00010
NeoVim410,00025
Transmission300,0008
nginx250,0004
yt-dlp240,0006
Redis230,00012
Audacity180,0009
Excalidraw160,0004
tmux100,0003
Fish90,00010
htop49,0004
i329,0003
scc24,0003

作为一个非常粗略的参考,这些数据暗示:开源项目每增加 25,000 行代码,大约就需要多一位维护者。6 6 如果仔细看,会发现维护者数量似乎是代码规模的二次函数,也就是说,代码总量越大,每一行代码的成本就越高。这很合理——组件之间的交互会随着组件数量按平方增长;对于 n 个事物,大约就会有 n2n^2 对交互关系。假设这些维护者平均每天花一小时做维护。7 7 这些项目中,有些是业余时间项目,有些则有企业赞助。“每天一小时”这个数字是我拍脑袋估的。如果你觉得不对,可以用你更认可的数字重新算一遍。那就意味着:

  • 每 200,000 行代码,每天就需要整整一个工作日的维护时间。借此我们可以估算:如果一个人完全没有时间做新开发,他最多能独自维护多大的项目。
  • 如果开发者的完全成本(fully-loaded hourly cost)是每小时 5050 美元(视你所在地区而定,这个数可能还算便宜),那么每 100,000 行代码,仅维护一项每天就要花费 200200 美元。
  • 如果一个 8 人团队希望最多只把 1/5 的时间花在项目维护上,那么他们的项目最多只能扩张到 320,000 行代码,再往上就需要再招一个人。

这些具体数字建立在很多假设之上,所以大概不具有普遍真理性。但它们依然很值得思考,而且从我的职业经验来看,也足够接近现实,能作为有用的经验法则。

附录 B:我们真的需要把函数写得更小吗?

Basili 和 Hutchens 还提出了另一个有趣的观察,不过他们也承认,样本太小,无法据此推广。作为“程序有多难写”的代理指标,他们测量了程序为了最终成功满足其规格说明而必须经历的修改次数。他们试图依据结果程序中各个独立组件的规模来建模,对程序中组件复杂度高分位数据分别测试线性拟合和指数拟合。

按照 Uncle Bob 这类经典建议——函数必须短,否则可维护性会受损 8 8 Clean Code: A Handbook of Agile Software Craftsmanship;Martin;Pearson;2008。——我们会预期指数拟合效果最好。这对应的假设是:单个复杂组件会造成不成比例的维护负担。

但 Basili 和 Hutchens 发现,线性拟合反而更好!在他们的数据中,一个复杂组件带来的维护负担,并不比五个各自只有它五分之一大小的组件更高。这反而更接近 John Carmack 那类人的观点,即把子程序内联会带来更高的清晰度

再说一次,他们的样本太小,无法推广。我也不知道是否有人用更多样化的数据复现过这一结果。不过,这真是很棒的科学研究!

评论

(0)
未配置登录方式
暂无评论