不久前,游戏引擎资深研发 Casey Muratori 发表文章 “干净”的代码,贼差的性能后,引发了大量开发者讨论。 Casey 还发布了一个 20 多分钟的视频:
https://www.youtube.com/watch?v=tD5NrevFtbU
随后,经典的《代码整洁之道》一书的作者 Robert C. Martin(20 世纪 70 年代初成为职业程序员,世界级编程大师,设计模式和敏捷开发先驱,后辈程序员亲切地称之为“Bob 大叔”)也加入了这场“干净代码”与性能之间的论战中。他在推特上发文称:
最近有人将 Clean Code 等同于过度工程。这当然是一种矛盾修饰法。根据定义,过度设计的代码就是不干净的。这不禁让人怀疑,那些大声抱怨的人是否真的研究过他们抱怨的对象。
对此,有网友提出:将一个 150 行的函数分解成一堆仅由该函数调用的小方法是否被认为是过度工程?Bob 大叔回应道,“这完全取决于工程师的目标。如果是为了可读性和表现力,那这样的分解也是可选的。但如果是为了性能,那这种分解可能不是最优的。”
Bob 大叔随后还发表了一系列观点,然后补充说他忘了 @ Casey ,两人因此有来有回地展开了一次“对话”。看过他们对话的网友表示,“几乎所有涉及 Casey Muratori 和 Jonathan Blow (注:业内知名的独立游戏开发者)的话题都可以归结为:
最后,这位网友还加了一句让 Bob 感到扎心的话:“不过,这并不是专门为 Clean Code 辩护,那本书非常糟糕。”
随后有网友也开始“歪楼”对 Bob 大叔的《代码整洁之道》评价起来:我不是父母辈儿的,但我读过这本书,并有一个建议:避免去读它。“严格地说,这本书并不全是坏的。书的大部分内容相当合理,据我回忆,其中一些建议实际上很好。问题是,唯一能区分好坏的是那些压根就不需要这本书的人。其余的人注定只能看到表面价值,最终我们成了盲目遵循原则的、坚定的狂热者,这些原则使他们的程序比原本做的大 3~5 倍(甚至没有夸张)。”
下面是他们两人围绕 Clean Code 的“对话”,用讲理的方式表达了对自己认为的“干净代码”应该是什么样的,其中充满了对对方的不服。
Casey :我们都不在一个频道上。 Bob 大叔:我没感觉到。你说的不准确,但不重要了。
Casey :在回应之前,让我们先做一点澄清。你提到的关于清洁代码的大部分解释,我在视频里也都有提到——比如更倾向于继承层次结构,而不是 if/switch 语句;不公开对象内部(也就是「迪米特法则」)之类。但好像你对我的说法很意外,所以在正式讨论类型设计之前,能不能先解释一下这个问题?这样我才能明白为什么咱们老是对不上频道。
Bob 大叔:对不上频道吗?我倒没这种感觉。我只看了你视频的前半部分,然后就感觉我已经看明白了。我发过一条回复,说你的分析基本是对的。我还说过你在描述“清洁代码”时的措辞不太准确,但我已经不记得具体哪里不准确了,反正也不重要。
总之,我想说的是,你展示的结构并不是那种能挤出每一纳秒极限的最佳性能设计方式。实际上,这些结构可能会浪费掉很多纳秒,或者说执行效率根本就没到纳秒那个层次。在当初那个每一点提速空间都很重要的时代,我们会非常精心地规划函数调用开销和间接成本。如果可以,我们甚至会解开循环,特别是在嵌入式实时环境当中。
但如今,这种环境已经非常少见了。绝大多数的软件系统所消耗的性能还不足现代处理器的 1%。更重要的是,处理器既便宜又容易获取。这些事实,改变了开发工作的基本思路——关注重点从程序性能转向开发效率,以及开发者构建系统并保持系统稳定运行的能力。正是这些需求,让“清洁代码”这个概念全面起飞。
对大多数组织来说,帮程序员节约时间比帮计算机节约 CPU 周期更有经济价值。所以如果非要说咱们之间“对不上频道”,那可能就是在优先级判断上。如果你是想尽量榨取每一纳秒的提速空间,那清洁代码确实没用;但如果你是想尽量提升开发团队每个工时所对应的生产力,那清洁代码往往就是达成目标的有效策略。
Casey 显然还没有被说服:能不能讲得更具体点,免得咱们再有什么误会。可以列举几个具体的软件示例吗?比如,假设我们都熟悉的 Visual Studio 和 CLANG/LLVM,这些也符合你之前提到的、绝大多数软件所占用的资源不足现代处理器 1%的情况吗?
Bob 大叔:那不是,我觉得 IDE 是一类非常专业的软件系统,属于极少数情况。
IDE 这东西非常有趣,因为它涵盖的范围太大、涉及的情况太多。其中有些部分需要抠到几个纳秒,但也有些部分根本就不在乎性能波动。现代 IDE 必须能在用户敲击键盘的同时解析大量代码,这个解析的过程就很挑性能,从而保证跟得上开发者的输入速度。但另一方面,配置对话框部分的代码就不怎么讲究效率。
顺带一提,IDE 编译引擎所强调的效率,更多在于算法效率而非循环精益。循环精益代码虽然能把效率提高一个数量级,但选择正确的算法能直接把效率提高好几个数量级。
另外,我说的那种资源占用不足现代处理器 1%的软件,其实是程序员们经常用到的常规系统。比如一个网站、一个日历应用、一个流程控制仪表板(管理简单流程)等。实际上,几乎任何 Rails 应用、Python 或者 Ruby 应用,甚至是大部分 Java 应用,都属于这类。而且它们全都不符合把性能推向极限的要求。
我目前的首选语言是 Clojure,它的速度只有等效 Java 程序的 1/30,没准只有同等 C 程序的 1/60。但我不在乎,毕竟我可以在必要时随时转去用 Java。另外,对很多应用程序来说,换个更强的处理器反而是最便宜、最简单的办法。总之,我觉得帮程序员节省时间才是目前最主要的降本方向。
但千万别误会我的意思。我也是上世纪 70、80 年代成长起来的老汇编玩家和 C 用户了。在必要时,我也会认真计算微秒级别的差异(纳秒这个太夸张了,人类几乎把握不住)。所以我知道循环精益代码的重要性。但今天的处理器比我们当时用的设备快上万倍,所以对于现在的大多数软件系统,我们更倾向于“浪费”一点 CPU 周期,来换取程序员的幸福生活。
Casey:那你的意思是 XXX ? Bob 大叔:我不完全同意。
Casey:如果我理解得没错,你的意思是说软件可以分两大类,具体情况要归类之后再分析。从这个角度看,我日常用的大多数软件其实都属于“每一纳秒都很重要”的类别,比如 Visual Studio、LLVM、GCC、Microsoft Word、PowerPoint、Excel、Firefox、Chrome、fmmpeg、TensorFlow、Linux 、Windows、MacOS、OpenSSL 等。你同意吗,就是说这些软件都需要高度关注性能?
Bob 大叔:不完全同意。相反,我的经验是,大多数软件之内还是要再细分来看。某些模块需要在纳秒级周期内执行,其他模块的响应时间则可以容忍微秒、毫秒甚至更长。是的,有些模块甚至在响应时间不超过 1 秒的情况下都是没问题的。
大多数应用程序都由多个模块组成,不同的模块对应不同的应用范围。例如,Chrome 就必须快速完成渲染。在填充复杂的网页时,每一微秒都很重要。另一方面,Chrome 中的首选项对话框就相对不强调性能,响应速度到毫秒级别也完全可以。
如果我们把特定应用程序中的各个模块按响应时间列成一份直方图,应该会看到某种非正态分布。部分应用程序可能包含大量的纳秒级模块和少量毫秒级模块,其他应用程序的大部分模块则可能都在毫秒级别,只有少数在纳秒级别。
例如,我目前正在开发一款应用程序,其中绝大多数模块有毫秒级的响应就很好了;但也有少数模块的性能要求是其他模块的 20 倍。我的策略就是用 Clojure 编写毫秒级模块,因为虽然速度不快,但它却是种非常方便的语言。微秒模块用 Java 来写,速度更快但没那么方便。
有些语言和结构,实际上就是对裸机的抽象结果,能帮助程序员专注于解决问题本身。比如说,程序员不用分心去优化 L2 缓存的命中率,这时候他们编写毫秒级代码的效率就要高得多。相反,他们可以更多关注业务需求,特别是几年之后其他人接手项目时能不能看懂代码、接管维护。
也有一些语言和结构会直接跟裸机映射,这样程序员就能更轻松地通过算法极限压榨剩余性能。这类结构的编写、解释和维护难度往往更高,但如果编写的对象确实需要纳秒级别的性能,那就必须承受这一切。
当然,这只是两种极端情况,大部分软件和语言其实介于二者之间。所以作为开发者,我们应该了解这些环境,明确知晓哪种环境最适合当前的实际问题。
大约十年前,我写过一本书,名字就叫《Clean Code》。其中更多关注的是毫秒级别的问题,而非纳秒级别的问题。在我看来,直到现在,程序员生产力都是相对更重要的问题。书里讨论了多态和 switch 语句之间的优劣取舍,用了整整一个章节。我想再援引其中的总结性论述,“成熟的程序员知道,一切对于对象的理解都是不可靠的。有时候,我们真正需要的只是简单的数据结构和能操作它们的过程。”
Casey:好吧,那我再调整一下自己的说法。Visual Studio、LLVM、GCC、Microsoft Word、PowerPoint、Excel、Firefox、Chrome、fmmpeg、TensorFlow、Linux、Windows、MacOS 和 OpenSSL,对于这些程序、至少是程序中的某些模块,“毫秒级别的性能就够了”,对吗?
Bob 大叔:毫秒?当然是对的。我也承认这些程序里还有不少微秒级甚至是纳秒级的模块。但大多数情况下,毫秒级就够了。
Casey:行,那让我们说点别的。 Bob 大叔:让我给你点“温馨提示”。
Casey:很好,看来我们已经在软件类别方面达成了一致。我想描述一下你所提到的编码实践的特征。因为你说的特征也适用于 LLVM 之类的软件,所以我就以它为代表。而且 LLVM 恰好是开源的,所以我们能明确知晓它的工作和构建原理(Visual Studio 就不行)。
我觉得你在回复、书中和讲座里都在强调同一个观点,就是说在对 LLVM 这样的大型软件进行编程时,程序员不用太关心性能。他们应该更多关注如何提升自己的开发生产力。如果说场景只限定在你之前提到的简单日历应用层面,那这么说没有问题。但在 LLVM 当中,确实存在着“纳秒/微秒/毫秒很重要”的情况,所以程序员早晚都得认真思考性能优化,否则他们一定会发现程序运行起来太慢。
假定有人想用 LLVM 构建一款真正的大型程序,比如虚幻引擎或者 Chrome 等。在这种情况下,假设性能问题出现在代码中的某些孤立部分(也就是你之前提到的“模块”),那现在肯定应该将这些部分重写为性能取向。
这就是我对你之前论述的理解,包括“如果我的 Clojure 代码太慢,我随时可以转向 Java”,也就是说如果某个部分需要更高的性能,你就会用 Java 进行重写。
我的理解准确吗?
次日,Bob 大叔在回答这个问题之前先说道:
顺带一提,前几天我回去看了你的完整视频。我觉得既然咱们决定参与讨论,那我确实该认真研究一下你的观点。先说结论:我对你的某些言论难以苟同,接下来会用“温馨提示”的方式做点补充。
Bob 大叔:首先,这些几何形状的面积都能用相同的基本公式(KxLxW)来计算,这个太妙了。应该只有程序员和数学家才能欣赏其中的美感。
总的来说,我认为你的视频很好地解释了程序员在环境资源受限时,需要如何找到出路。很明显,在资源丰富的环境中,人们不会专门选择 KxLxW 这种解法,毕竟大家不确定场景中会不会引入其他形状。另外,就算是问题范围就稳定在符合 KxLxW 的情况之内,使用更传统的公式也能降低其他程序员的理解门槛。毕竟这是种很少见的算法,人们往往会感到困惑甚至重新做验证。我承认,这个验证的过程是美妙的、甚至堪称“尤里卡”时刻,但上班都挺忙的,最好不要无谓地占用宝贵时间。
我不知道你有没有读过 Don Norman 的作品。很久之前,他写过一本名叫《日常事物的设计》的书,相当值得一看。他在书中陈述了这样一条经验法则:“如果你觉得某种事物精巧而复杂,请当心——它可能是自我放纵的结果。”所以在资源丰富的环境里,我觉得 KxLxW 的解法就属于这种情况。
Bob 大叔:我是敏捷宣言的签订者之一,他们坚信前期架构和设计非常重要。
按你提到的这个案例,那我可能得再具体考虑一下,包括寻找可能出现性能问题的地方并更多关注这些模块。比如,我可能已经开发了一个精简版的模块,再把它放进压力测试里看行不行。当然,我最担心的永远是花了大量的时间和精力,但因为选择的方法不对,所以最终没能满足客户需求(这事在我自己身上也发生过)。
总而言之,对于这类复杂问题,揪住单一因素做分析永远是不够的。没有唯一正确的方法,这一点我在《Clean Code》中也曾多次提出。
Casey:说了半天都没回答我的问题。 Bob 大叔:你说得有道理,不过我昨天就在课堂上讲过这个问题了,谢谢。
Casey:一边看你的回复,我脑子里已经跳出了很多问题。但你最后说得确实很好,所以就从这里入手吧。
在对话中,你谈到软件架构中有几个性能关注点:IDE 解析器更多关注“纳秒级”,所以应该把“模块”划分成纳秒/微秒/毫秒等几个响应要求级别。你还建议程序员可以先创建一个“精简版”模块,再通过压力测试分析其运行效果,最终编写软件以确保性能维持在可以接受的范围。甚至可以根据性能需求选择不同的语言,比如你在例子中说的 Clojure、Java 和 C 语言。总结来讲,你的观点就是“所以作为开发者,我们应该了解这些环境,明确知晓哪种环境最适合当前的实际问题。”
聊了这么多,我想回归最开始的问题:对于我把“清洁代码”放在追求性能的对立面这件事,你为什么会表现得很惊讶?你说了半天,完全没有提到这方面的观点。
当然,你的书里和博文里也肯定过性能的重要性。但从数量上看,你刚刚讲的这一切,但你以往的表达里都占比很低。比如说,这是包含六个段落、总长好几个小时的视频合集,即《Clean Code》系列讲座。在长达九个小时的内容中,你从来就没提到过前面回复的这类内容:
https://www.youtube.com/playlist?list=PLmmYSbUCWJ4x1GO839azG_BBw8rkh-zOj
如果你对性能问题真像回复中表现得这么重视,那为什么在九个小时的课程里都没拿出哪怕一个小时,专门给听众们解释一下优化性能、提前设计的现实意义?比如代码可能会对性能产生影响,应该如此避免对性能有害的编程结构之类,包括你在回复中提到的应该预先建立性能测试等等。
或者从另一个角度提问,你是不是觉得性能的意义是理所当然、无需赘述的,所以你没有给予特别的强调。但你的听众对性能并不熟悉,这种偏废难道不会妨碍他们在正确的时间考虑这个正确的问题吗?毕竟你都开始做“作为开发者……”这样的总结了,多少应该提一提吧?
Bob 大叔:坦率讲,我认为你的批评有道理。而且巧的是,我昨天在课堂上,正好花了很多时间讨论软件开发学科中的性能成本和生产力收益问题。谢谢你的监督。
但要说“意外”,我觉得不准确。不过毕竟只是个语气词,没必要纠结下去。
你问我是不是一直觉得性能永远重要,根本没必要强调。通过自我反省,我觉得可能真是这样的心态。我不是性能方面的专家,我的专长是帮助软件开发团队高效构建和维护大型复杂软件系统,为他们提供实践、原则、设计思路和架构模式。正所谓“拿起锤子,看什么都像钉子”,我们可能都习惯于从自己的专业角度出发看待问题。
至于我很少强调性能意义,其实换个角度看,这又何尝不是另一个锤子和钉子的故事呢?因为你是性能调优方面的专家,所以总喜欢从这个角度看待问题,都差不多的。
但我还是要承认,这段讨论比我当初的预期更有助益,也让我的观点发生了变化——虽然变化不是特别大,我也没觉得我的《Clean Code》系列教程真有那么差。总之,如果你从头到尾把这九个小时的内容看下来,就会发现我在其中多次提到过性能问题,而且至少有两、三次达到了能让你认可的强调程度。
因为就像你猜测的那样,我确实认为性能问题很重要,需要进行预测和规划。
Casey:就这样吧,我不想聊了。 Bob 大叔:你启发了我单行过长引起的性能问题。
Casey:老实说,性能调优就是我的一切:)不开玩笑,就在 GitHub 上编辑这段回复的同时,我发现因为输入的段落行数过多,页面已经开始卡顿了。就几百个字,但因为在系统里叠层太多,本该瞬时完成的操作就慢到影响使用。这也是我如此强调性能的原因之一——就在当下,即使是那些非常简单的软件功能,也经常会慢到无法使用。这绝不是我瞎编的,你可以看看我录制的这段视频,看我在输入回复时页面卡成了什么样子:
https://www.youtube.com/watch?v=gPgm28zXNEE
我用的可是 Zen2 芯片,速度快得很!所以我会抓住一切机会宣传性能的意义,其中没准就蕴藏着改善体验的可能性。很多组织绝对不会考虑“纳秒/微秒/毫秒/秒”之类的性能分级,但我想说,拜托你们考虑考虑吧。只要能把性能这个观念植入他们的脑袋,并帮助他们获得解决问题的能力,那将是对整个世界的重大改进。
所以我觉得要聊的主题到这里就差不多了。如果你想继续聊,那接下来可能就要延伸到架构领域了。那是比性能更宽泛的讨论空间,如果你想要,我也很愿意奉陪。
Bob 大叔:你这段视频也太夸张了,cps 可能都没有 25。我想问问你用的是什么浏览器。我正在用的是 Vivaldi(Chrome 的一个 fork),虽然不像你那么可怕,但输入延迟也挺夸张的。所以我做了一些实验,事实证明延迟跟文件大小无关,倒是跟段落长短有关。我在同一段落中键入的内容越多,其长度就越长,延迟也就越厉害。
那为什么会这样?首先,我想我们都在输入相同的 JavaScript 代码,毕竟没人会继续用浏览器里编写的工具了。其次,我觉得这段代码的作者从没想过会有人把整个段落搞成单行形式(请注意左侧的行号)。即便如此,在 25cps 的速率下,到 200 至 300 字符时延迟也会变得非常明显。这是怎么回事呢?
会不会是因为程序员用的是某个质量不佳的数据结构,每次延长都为其分配一个新的内存块,之后把数据复制到新块中?我记得旧 Rouge Wave C++库就是这样处理不断增长的字符串的。总之,这延迟实在是太夸张了。
当然,这更像是个算法问题,而不是单纯的性能问题。毕竟如果软件运行得太慢,大家首先想要检查的肯定是算法。但你的观点确实有道理,写这段代码的程序员没想到自己的功能会被用户如何使用,所以在处理意外负载时表现很差。
所以,也许我应该从现在开始,每行结束都打个回车。
总之,你的回复启发我意识到了这个单行过长引起的性能问题。现在我会把单行字符限制在 80 个以内,这样无论是多低端的芯片,应该都不会再卡顿了。
两人对话到此结束了。不过在昨天的推特中,Casey 表示:
我有个坏消息要告诉大家。会有……更多的视频。引起骚动的那篇文章只是我课程序言中被删掉的内容。如果我们要全面讨论干净的代码和性能,那么做好准备,因为我可以在接下里的一个月时间里都做这个。
“不管我个人对 Bob 或 Casey 的感觉如何,我真的很喜欢这种形式,让两个意见不一致的人通过编辑共享的对话文件来协作进行对话。我现在真的很想自己试试这种方式。”有网友表示。
原文链接:
https://github.com/unclebob/cmuratori-discussion/blob/main/cleancodeqa.md
领取专属 10元无门槛券
私享最新 技术干货