面条代码 vs. 馄饨代码

本文转载自 godsme_yuan 的作品《面条代码 vs. 馄沌代码》,原文地址如下:

1. (转载添加标题)什么是面条代码

面条代码 (Spaghetti Code)指的是冗长,控制结构复杂,混乱而难以理解的代码。

就我个人而言,曾经编写过大量面向对象和面向过程的代码,也曾经写过至少数千行的函数式代码,印象中,从来没有编写过冗长复杂的函数。有趣的是,我从来没有把短小精干,低圈复杂度的函数当做一个目标(直到2007年我才第一次听说“圈复杂度”这个名词,直到现在也讲不清楚它的计算方法),它只是 消除重复,分离关注点,清晰表达 等价值观驱动下的一个结果。

在从事咨询工作以后,我见到了大量的“面条代码”,更准确的说,见到的大多是“面条代码”。于是开始对自己的能力感到怀疑,并对那些有能力和“面条代码”和平共处的工程师感到钦佩。

因为,从骨子里我就对复杂事物感到恐惧和缺乏耐心。我喜欢一目了然,逻辑清晰的事物,如果一个设计可以让我舒服的靠在凳子上,无须看代码,仅凭思考就能在脑海中浮现清晰的画面,我才会感到踏实 —— everything is under control。

我相信,每个人都会喜欢这种踏实感。由此可以推论出,那些能够编写和控制“面条代码”的工程师,一定都具备优秀的理解力和控制复杂事物的能力。

直到有一天,我在《反模式》一书中看到了对它的定义和描述,才知道原来有这么多的人痛恨它,看来像我一样能力低下的人并不在少数。随后看到 Uncle Bob(转载注,原链接已 404,可使用 Wayback Machine)在《Clean Code》中谈到方法长度和类规模时,均提到“ 第一条规则是短小,第二条规则是还要更短小 ”。至此,我彻底感到释然 —— 能和大师一样愚笨,就没什么可感到羞耻的了。

2. 平均复杂度

按照短板理论(转载注,原链接已 404,Wayback Machine 为短板理论之我见),一个团队所编写的代码,应该让团队中最笨的人也可以容易理解,这样团队的整体生产效率才能有效提升。当然你也可以把最笨的人踢出团队,但剩下的成员里,相对于最聪明的人,依然会存在最笨的人。

不信?请看我最近在网上读到的一篇文章(转载注,已挂),里面谈到“我的一个老同事曾经说 Visual C++ 很臭,因为它不允许你在一个函数内拥有超过 10,000 行代码” 。

谢天谢地,幸亏他不是我同事,否则,我就算再聪明 100 倍,也还是逃不出被踢出局的命运。并由此第一次对 VC 产生了好感(不过我觉得它应该把函数长度限制设为更小的值,比如 100 行,这样才可能在一定程度上促进社区的代码改善)。

所以,我们只能以大多数程序员的平均接受能力为准。而根据相关调查统计,大多数人对于 7 ± 2 以内规模的事物有较好的控制力。

3. 面向对象

事实上,当使用“面向对象”范式来进行软件设计时,如果你非常重视“ 消除重复 ”,“ 分离关注点 ”,“ 清晰表达 ”,那么很自然的,你会得到一大批短小精干的类和方法。即便你使用其它范式,比如面向过程或函数式编程,在相同的关注下,你也很难得到 面条式代码

所以,曾经有人在演讲中提到:在面向对象系统中,你不可能得到 面条式代码

你或许开始质疑:“我们的系统都是用 C++ 或者 Java 写的,为何有那么多的 ‘面条代码’”?

事实上,“面向对象”是一种编程范式,与具体语言关系不大。使用面向过程的语言也可以构造出面向对象系统;反之,使用面向对象语言构造的系统,却未必是面向对象系统 。在面向对象思想的指导下, 面条代码 确实很难出现。而使用“类结构”却存在大量 面条代码 的系统,并非真正的面向对象系统。这样的系统,有一个专门的名字:肉团面(Spaghetti with meatballs)。

4. 馄饨代码 —— better or worse?

但这并非故事的全部,那位演讲者的完整阐述是: 在面向对象系统中,你不可能得到面条代码,但却会得到馄饨代码(Ravioli Code)。

顾名思义,馄饨代码是指 程序由许多小的,松散耦合的部分(方法,类,包等)组成。

很明显,在那位演讲者看来,馄饨代码绝对不是一个褒义词,把面条代码重构为馄沌代码只是把一个问题变成了另外一个问题 。

而这样的看法并非个案。在咨询的工作中,我不止一次听到这样的反馈,馄饨代码相对于面条代码,更加难以理解和跟踪。

对于这样的意见,最初我感到非常困惑,因为这与我的认知和经验恰恰相反。但既然持有这种看法的人并非“一小撮不明真相的群众”,那就有必要站在对方的角度来思考一下造成这种结果的原因。

在这些工程师看来,在面条代码中,一个大函数尽管复杂,却完整的描述了整个过程或算法的所有细节。但在馄沌代码中,这些过程和细节被拆分的支零破碎,分散到不同的类和方法中,为了理解一个过程或算法,必须在类和方法间跳来跳去,如果有多态存在,你都不知道到底是哪个子类的相关方法被执行。

面对这种困惑的工程师们需要了解:面向过程和面向对象的思维方式和解决问题的方法有着很大的差异 ——

5. 算法 vs. 机制

面向过程解决问题的思路是算法或流程,你会把它想像为一条流水线,或者把自己看作亲力亲为的 CPU。而面向对象着重于机制的建立,你可以把目标系统想像成一台由许多零件构成的机器,或者一个良好运转的组织。

所以,对于一个面向过程的程序,你需要理解的是事物处理过程或算法步骤,每个过程都是无状态的,只有输入和输出(对于全局或静态变量的访问是面向过程的副作用)。

而对于一个面向对象系统,你需要首先理解一个设计的结构(类,类的职责,以及类之间的关系),很多在面向过程的代码中必须用过程来描述的“流程”,比如一些分支逻辑判断,在面向对象的系统中,靠类结构就已经解决了。在理解了结构之后,下一步需要了解的是类之间的交互。在明白了类结构之后,对于交互的理解就不再是件困难的事情。除非你的类结构本身就混乱,晦涩,难以理解。

你不妨想像一下,当你试图了解某个机构一个具体事务流程的时候,最高效的方法肯定是先了解它的组织架构,了解每个部门职责,以及部门之间的关系。在此基础上,再去理解一个具体事务的流程时,就会容易理解的多。反之,在你不了解组织架构的情况下,一上来就直奔一个具体事务,你可能更加希望一个部门,甚至一个人就把所有的事情都做了;拿着一份文件在各个部门之间穿梭盖章,肯定会让你非常困扰和厌烦。

6. 分离 What & How

另外,对于 馄饨函数 的理解也需要不同的思维方式。以 面条代码 面目出现的函数,不仅仅在描述“ 做什么(What) ”,同时还会呈现“ 怎么做(How) ”。因此,一个函数内部必然充斥着大量的实现细节,从而导致阅读者只能依靠注释,或者通过对细节的归纳总结,才能最终理解“What”。而 馄饨函数 则是将两个关注点进行分离,在高层通过抽象来描述“What”,在底层通过展示细节来描述“How”,最终放在一起来完整描述一个算法。需要特别强调的是,为了能够达到描述 What 的目的,好名字非常重要。

这样的方式,应该更加科学,更加符合人类认知习惯。但同时也可以理解,对于某些已经了解了 What,只想了解 How 的人, 馄饨代码 会额外增加函数间跳跃的成本。

但世上没有免费的晚餐,既然问题的本质复杂度就在那里,为之付出一定的代价就是必然的。 除非不再选择程序员作为职业,否则,我们只能通过评估各个方案总体上的成本收益比,来选择合适的方式。

7. 馄沌代码 —— 更好的成本受益比

另外,一个必须承认的事实是, “复杂性”是损害“可理解性”的面条代码 的复杂性体现在 一个函数内部细节数量和逻辑控制 ,而 馄饨代码 的复杂性则体现在 函数或类的数量和结构

看起来我们只是将复杂性从一种形式转化为另外一种形式,但事实上, 馄饨代码 收获了更多,它的意义不仅仅体现在 可理解性 ,还体现在 可重用性,灵活性 ,更加符合“高内聚低耦合”原则。

所以,尽管 面条代码 在现实中广泛存在,但对其却是压倒性多数的批评;而 馄饨代码 ,尽管也并不完美,却在面向对象阵营得到广泛的支持,甚至被列为整洁代码的典范。

8. 仅仅“小”是不够的

“短小的函数”并不意味着 馄饨代码 。在“高圈复杂度”被确认为是 面条代码 的特征之后,很多团队都定义了自己的“圈复杂度红线”;另外,一些团队也规定了单个函数“代码行数”的上限。但这样的约束,只能导致“短小的函数”,而“馄饨代码”并不仅仅“短小”,还要 松散耦合 ,还要 表达清晰

首先,即便一个函数只有一行代码,但也会由于包含了过多的细节而难以理解。不信,看看这个例子:

#include <stdio.h>
#include <math.h>
 
double l; main(_,o,O){ return putchar((_--+22&&_+44&&main(_,-43,_),_&&o)?(main(-43,++o,O),((l=(o+21)/sqrt(3-O*22-O*O),l*l<4&&(fabs(((time(0)-607728)%2551443)/405859.-4.7+acos(l/2))<1.57))["#"])):10); }

这个程序绝对可以通过编译链接,并且功能强大 —— 能够用 ASCII 画出当前的月亮盈亏状况。技术上这个程序的函数主体只有一行代码,但其所包含的信息量之大,估计没有几个人仅仅靠阅读和分析就可以完全理解。

这个例子可能有些极端,那我们不妨看一个正常的例子:

return (0 < width && width <= 100 && 0 < height &&height <= 75) ?
      height* width : 0;

这个例子并不非常晦涩,任意一个合格的程序员花点时间就能领会它的意图。但如果我们将其改成下面的样子,其容易理解的程度就得到了进一步的提高。

return isValid()? calcArea() : INVALID_AREA;

尽管我们通过提取函数和定义常量,增加了新的代码元素,但这种付出是值得的。

另外,如果一个函数的表述不具备“对称性”,或者不符合 SLAP,那它就无法达到“抽象”与“细节”,“What”与“ How”分离的目标;就算这个函数非常短小,它也是晦涩的。

所以,我们真正的目标是“ 消除重复 ”,“ 清晰表达 ”,而不是“馄饨代码”,更不是“短小函数”。 “馄饨代码”只是一个结果,而不是“动机” ,而“短小函数”则只是“馄饨代码”的特征之一。永远 “不要把解决问题的方法当作问题本身”

9. 消除不必要的复杂度

另外,由于“复杂性”会影响“可理解性”,所以,我们需要控制不必要的复杂度。那些不必要的抽象,不必要的函数,均不应该在一个设计中出现。所以,Kent Beck 在“简单设计”原则中描述:

如果一个代码元素对于 满足功能消除重复 ,或者 提高表达力 都没有用处,那么它就不应该存在。

在重复没有出现的情况下,对于“预先设计”所引入的复杂性,需要特别的小心和谨慎,究竟是这个“预先设计”更有价值,还是去除其引入的复杂性以提高“可理解性”更有价值?这需要设计者根据成本收益原则进行仔细的权衡。

但“重复”一旦出现,为了消除它而引入的复杂度,就是你必须要承受的代价。即便由此降低了“可理解性”,也物有所值。因为,一般而言“重复”比“难以理解”所带来的后果更加严重:“重复”往往意味着设计上的问题,以及维护上的高昂成本 。

所以,“可重用性”和“可理解性”并非“正交”的两个概念。但它们也并非相互排斥,水火不容。在“消除重复”的前提下,我们还是可以尽量提高代码的“可理解性”,更何况,事实上很多时候,“消除重复”的过程就是“提高可理解性”的过程。只有在少数情况下,当它们发生冲突的时候,我们才需要在二者之间做出取舍 。