导读:这篇文档主要介绍了编写和阅读代码的最佳实践和技巧。它强调了代码的可读性和简洁性,以及通过阅读他人的代码来学习编程和了解习语的重要性。此外,它还指出了一些常见的代码问题,例如将整个数据文件读入内存等,需要注意和避免。通过这篇文章,读者可以学到如何写出更好的代码并提高自己的编程水平。本文中的 CRISP 代码是指“Correct”(正确的)、“Readable”(可读的)、“Idiomatic”(习语性的)、“Simple”(简单的)和“Performant”(高效的)的代码。
相关阅读:
世界级编程大师 Bob 大叔为“干净代码”辩护遭质疑:时代变了,别用 Clean Code 那套要求我们了!
我相信我们都支持“干净的代码”,但这是一种十全十美的东西,没有人可以合理地反对。谁想写脏代码,除非它可能是为色情网站编写的?
当然,问题在于我们中很少有人能就“干净的代码”达成共识,以及如何实现它。像“方法只应该做一件事”这样的规则在 T 恤上看起来很好,但实践中并不那么容易。什么算是“一件事”呢?
从 ZX81 开始到现在,我的编程生涯还没有结束,我发现了一些经久不衰的原则。原则比规则更灵活,可能更广泛适用。
我认为好的程序应该是正确的(Correct)、可读的(Readable)、习语性的(Idiomatic)、简单的(Simple)和高效的(Performant)。因此,这里是我在 Go 编程语言中实现 CRISP 代码的五个规则:除了第一个规则外,它们的重要性并没有特定顺序,但它们可以形成一个漂亮的反向缩略词。
正确
你的代码可能有很多其他精彩之处,但如果不正确,那么这些精彩之处就不重要了。事实上,如果我们愿意放弃正确性,我们可以相对容易地获得任何其他属性。
正确是首要品质。如果一个系统没有完成它应该完成的事情,那么它的其他方面——无论它的速度有多快,是否有一个漂亮的用户界面——都不重要。但这说起来容易做起来难。
——Bertrand Meyer,《面向对象的软件构造》(Object-Oriented Software Construction)
这似乎很明显,不值得一提,但如果存在不正确的代码,而我认为确实存在,那么肯定值得一提,因为显然有人没有得到备忘录。
实际上,代码正确意味着什么呢?直截了当的答案是“它做了程序员打算做的事情”,但即使这也不完全正确,因为我可能打算做错了事情!例如,我可能在错误的印象下编写了一个素数生成器,认为所有奇数都是素数。在这种情况下,我的代码可能生成我要求的奇数,但仍然不正确。
好的测试套件,就像十全十美的东西一样,几乎每个人都希望拥有,即使他们更希望其他人做艰苦的工作来产生它。测试是任何良好程序的必要,但不充分的一部分。毫无疑问,有些正确的程序没有测试,但谁知道它们是哪些呢?
一个良好编写的测试表达了程序员认为在给定一组情况下认为代码应该做什么,并且运行它应该给我们一定程度的信心,它是否真正做到了这一点。然而,测试本身也可能是不正确的。
这就是为什么我们需要对任何给定的测试非常怀疑和怀疑的原因之一。它看起来像在说什么?它是否真的这么说的吗?这么说是正确的吗?如果测试验证程序的结果是否符合一些期望的结果,那么期望是否正确?如果测试声称覆盖某个特定代码段,它是否实际上以所有重要的方式测试了该代码的行为,还是仅仅使其执行了一次?
没有测试是一个非常糟糕的情况,但是相比进行无效的测试,它实际上稍微好一些。一个不正常或者不足的测试可能会让我们对代码产生虚假的信心,而代码也恰好是错误的。
因此,一旦编写了测试,请非常仔细地再次阅读它们,保持反对的眼光。程序员是不可救药的乐观主义者:尽管有很多证据表明我们的代码不会工作,但我们总是认为它会工作。相反,我们应该假设如果存在错误,则一定存在错误。谦虚并不是软件工程师通常与之相关的美德,但对于好的测试编写者来说,这是他们所熟知的东西。
即使是经过最彻底的测试的代码,也不应该被认为是正确的。应该假定它是不正确的。它几乎肯定是不正确的。
可读
再说一遍,这听起来似乎不需要说出来:谁在为不可读的代码辩护?至少不是我,但似乎周围有很多不可读的代码。当然,并不是说有人刻意地写不可读的代码;只是因为我们错误地把其他美德放在可读性之上,结果就是这样。
性能就是其中一种美德,有些情况下性能确实很重要。但并不像你想象的那么多:如今的计算机非常快,由于它们大多数任务是为了人类服务,通常可以放慢一点速度。
因此,虽然可读性并不像正确性那么重要,但它比任何其他东西都更重要。正如丘吉尔所说的勇气一样,“可读性理所当然地被视为第一品质,因为它是保证所有其他品质的品质。”
是什么使代码具备可读性?我不认为“可读性”是你可以添加到代码中的东西:我认为它是当你删除所有使代码难以理解的东西后所剩下的东西。
再举个例子,一个选错了变量名的程序可能会让读者不知所措。同一个事物却用了不同的名称,或者不同的事物用了相同的名称,这会让人困惑。不必要的函数调用,仅仅是为了满足“方法应该少于 10 行”的规则,会打断读者的思路。这就像读一篇文章时遇到一个不熟悉的单词,你应该停下来查一下它的意思,冒着失去思路的风险,还是继续努力通过上下文来推断它的含义呢?
奇怪的是,让任何代码更易读的最好方法,是从阅读代码开始。但我是指一种特殊的阅读方式。我不是指匆忙地滚动代码页,浏览和略读以获取大致情况。这是一种有用的技能,但是用于其他任务。
相反,我们需要谨慎的、顺序的、有意识的方式来阅读代码。我们需要从头开始,直到结尾。如果程序的起始点不清楚,那么我们需要首先解决这个问题。
当我们跟随程序的执行流程时,我们需要仔细阅读每一行代码,然后尝试回答两个问题:
请问这一行代码写的是什么?
请问这一行代码是什么意思?
例如,像下面这行代码:
这句代码表达的意思很明显:它将某个数值变量 requests
的值增加一。但不太容易弄清楚它的含义。在 requests
变量中计数的是什么?它为什么要增加?这有什么重要性?requests
的当前值是从哪里获得的?它的当前值是什么?何时何地会检查该值?是否有某个最大值?达到最大值时会发生什么?等等。
可能对所有这些问题都有很好的答案,但这不是重点。重点是,读者能否通过查看代码来回答这些问题?如果不能,我们可以采取什么措施来提供答案或使它们更容易找到?通过像新手一样阅读我们自己的代码,我们可以以新的视角看待它,并发现需要更多认知努力才能跟随的部分。
以这种仔细、分析的方式阅读代码是真正学习编写代码的最佳方式之一。
习语
我认为“习语”这个词不太恰当:我更倾向于使用“传统”一词,但这并不符合 CRISP 缩写的含义。但是,当人们说“某某是习语”时,他们真正的意思是“这是传统的做法”。
传统做法很有用:例如,有很多可能的方法来布置汽车的驾驶控制器,但我们习惯的方式并没有被证明是最优的。这只是我们习惯而已。没有法律阻止汽车制造商以不同的方式布置踏板,但在实践中他们并没有这样做。即使有某些人体工程学上的好处,也不值得用户承受认知成本。
同样地,我认为为事物使用传统名称非常有价值:在 HTTP 处理程序中,请求指针总是被称为 r
,而响应编写器被称为 w
。如果有普遍的传统,那么遵循它是值得的。你还会有当地和个人的传统做法。在我的代码中,任意的 bytes.Buffer
总是被命名为 buf
,在我的测试中比较的值总是被命名为 want
和 got
,等等。
err
是一个很好的普遍惯例的例子:Go 程序员总是使用这个名称来引用任意错误值。虽然我们通常不会在同一个函数中重新使用变量名,但我们确实会在整个函数中重复使用 err
来表示可能出现的各种错误。通过创建变体名称如 err2
、err3
等来避免这种情况是错误的。
这是因为它需要读者进行更多一点点的认知努力。如果你看到 err
,你的思维可以无缝地理解。如果你看到其他名称,你的思维就必须停下来解决这个谜题。我称这些微小的障碍为认知微侵犯。虽然每个微侵犯的个体效应是可以忽略不计的,但它们很快就会累积,如果它们足够多,就会造成麻烦。
当你编写每一行代码时,你应该考虑“需要多大的努力才能理解这一点?我能不能在某些方面减少这个量?”伟大的软件工程的秘诀就是把许多小事做好。选择正确的名称,按照逻辑组织代码,每行代码只表达一个想法:所有这些都将累积起来,使你的代码易于阅读,且易于使用。
如何学习什么是习语的和传统?通过阅读其他人的程序,就像我们阅读自己的程序一样仔细、有意地阅读。如果你从来没有读过小说,你就不可能写出一部好小说,同样的道理也适用于编程。(最好也阅读一两本精心挑选的关于编程的书籍,尽管这不那么重要。)
尽可能广泛地阅读代码是非常有用的,因为你会发现野外代码的质量参差不齐。阅读糟糕的程序和优秀的程序同样有用,但原因不同。即使是在优秀的程序中也会发现错误,当你发现错误时,你也会学到东西。但是最有用的学习是了解大家都在做什么。
简单
哈,简单。还有什么比这个更滑稽和棘手的概念呢?每个人都认为他们知道简单,但奇怪的是,实际上没有两个人能在“简单”的含义达成一致意见。
正如 Rich Hickey 所指出的那样,简单并不等同于容易。“容易”是熟悉的,不费吹灰之力的,是我们不加思考就会采用的事物。这通常导致“复杂”,所以从“复杂”到“简单”需要付出大量的努力和思考。
简单性的一个方面是直接性:它做到了它说的事情。它没有奇怪和意外的副作用,也没有将几个不相关的事物混为一谈。直接性与简洁性成反比:与其写一个短而复杂的函数,不如写三个简单而相似的函数。
这是人们发现难以编写简单代码的一个原因:我们都害怕重复自己。“不要重复自己”的原则已经根深蒂固,我们甚至将其用作动词:“我们需要优化这个函数”(正如 Calvin 提醒我们的那样,将名词动词化会令语言变得奇怪)。
但重复本身并没有什么问题。我再说一遍,重复本身并没有什么问题:我们经常做的任务可能是重要的任务。如果我们发现自己创建新的抽象只是为了避免重复,那么我们就错了。我们使程序变得更加复杂,而不是更加简单。
这就带我们进入简单的另一个方面:节俭。事半功倍。一个只做一件事情的程序包比一个做十件事情的程序包更简单。函数越少,调用栈越浅,程序就越简单。这可能会导致一些长函数,但这没关系。函数应该恰到好处,长度适宜。为了缩短函数长度而增加不必要的复杂性,这只是盲目奉行规则违背常识的典型例子。
Alan Perlis 曾经观察到,简单并不是复杂的先决条件,而是其随后产生的结果。换句话说,不要试图编写简单的程序:先编写程序,然后将其变得简单。阅读代码,问它在说什么,然后问自己是否可以找到更简单的方式来编写相同的东西。
你可以在自然性中找到简单性。任何一种语言都有其自己的道,自己的自然形式和结构,与它们一起工作通常会得到比与它们相对抗更好的结果。例如,你可以尝试使用 Go 作为 Java、Python 或 Clojure,这些语言都没有问题,但是用 Go 编写 Go 程序更简单,结果也更好。
性能
你可能会觉得我把这个放在最后很奇怪。难道几乎所有有关编程的听闻或阅读都与性能有关吗?是的,的确如此。但我并不认为这是因为性能很重要,而是因为它很容易谈论。什么可以被衡量就会被最大化,而性能很容易被衡量:你可以用秒表来测量。相比之下,像简单性甚至正确性这样的东西就难以量化了。
但是,如果代码不正确,谁在乎它运行得有多快?换句话说,如果不正确,我们可以任意地使一个给定的函数更有效。同样地,如果它不够简单,我们将浪费更多的程序员时间来理解它,而我们所能节省的 CPU 时间远远不够弥补这个成本。而程序员每小时的运行成本比任何 CPU 都高。优化限制资源难道不是有意义的吗?
就像我之前说过的,对于绝大多数程序而言,性能并不重要。当它重要时,最好的解决方案通常不是使你的代码更难读。这里适用于“慢就是顺,顺就是快”。如果为了节省几微秒的时间而使你的程序陷入无望的复杂性,那很好,但那将是任何人对它进行的最后一次优化。试图加速复杂代码通常是适得其反的,因为如果你无法理解它,你就无法优化它。另一方面,当你必须加速一个简单的程序时,它很容易加速。
尽管我们不必让性能考虑左右我们的选择,但我们应该意识到这些选择所带来的性能影响。如果我们不需要快速完成这项任务,我们也不会交给计算机。另一种思考方式是,一个高效的程序可以在给定时间内完成更多的工作,即使实际所用的时间并不重要。
公平地说,即使是效率低下的程序也会运行得相当快:正如 Richard Feynman 所观察到的那样,计算机内部很蠢,但它的速度很快。这并不意味着我们可以浪费时间,因为计算乘以时间等于能量,而我们正在以一种已经荒谬地不可持续的速度加热我们的星球。如果我们因为选择了笨拙的数据结构而最终排放出了几百万吨的碳,那将是一种耻辱。
当你编程时,“机械同理心”的概念非常有帮助。这意味着你对机器的基本工作原理有一些了解,你会小心谨慎地避免滥用它或妨碍它。例如,如果你不知道什么是内存,你怎么能写出高效的程序呢?
我经常看到一些代码毫不在意地将整个数据文件读入内存,然后只是每次处理几个字节。我是在一台只有 1K 内存或大约一千个双字节的机器上学会编程的,它甚至无法容纳这篇文章。
过了几年以后,我用一台大约有 1600 万个双字节的内存的机器来写这篇文章,而且这些字节的大小是原来的八倍,这意味着我们现在可以放松下来,使用尽可能多的内存了吗?没有,因为任务也变得更加庞大了。
一方面,你在系统中传输的数据越多,花费的时间就越长。另一方面,无论你的 Kubernetes 集群有多大,它仍然由固定、有限内存的物理机器组成,而且没有一个容器可以使用多于单个节点的总 RAM。所以,好好管理你的字节,千兆字节会自动照顾好自己。
要点
任何想要向你推销完美软件工程方法论的人都是江湖骗子,尤其是如果这个方法论有一个漂亮的反向缩略词。
始终要定义你的术语,以避免不幸的误解:像“清晰”、“简单”和“易读”这样的词听起来很不错,但是像“自由”、“正义”或“平等”一样,它们对不同的人意味着不同的事情。
不要相信像“干净的代码”、“函数应该只做一件事”或“不要重复自己”这样的简洁口号:如果你能准确地定义它们的含义,它们总是被证明是错误的。
正确性胜过一切,甚至可读性;这是如此明显,我们需要不断地提醒自己。
测试只能证明代码做了测试编写者认为应该做的事情,而大多数情况下,它们甚至无法证明这一点。
一个错误或不充分的测试比根本没有测试要糟糕得多,因为它看起来像你有测试。
所有的软件都有 bug,它们分为两类:目前为止发现的 bug 和没有发现的 bug。
每个人都赞同“可读性”,但是对于什么使代码具有更多或更少的可读性,人们的看法却大相径庭。
事实上,使程序可读性在很大程度上包括查找和删除不可读性的实例。
你可能知道代码说的是什么,但你能说出它的意思吗?
原创性是令人愉快的,除了在编程方面。
继续在代码中寻找并移除认知微侵犯,直到找不到更多,然后再仔细查找。
要想胜任,就要写大量的代码;要想成为大师,就要阅读大量的代码。
简单不等同于容易。
如果避免不必要的抽象概念有助于清晰的表达,那就重复一遍。
首先编写程序,然后让它变得简单。
随波逐流:顺应语言,而不是与之对立。
优化通常是不必要的,甚至适得其反:不要浪费时间来节省时间。
无论如何,你的程序很可能已经足够快了:电脑内部蠢得要命,但它却像疯了一样快。
时间是无限的,但空间是有限的,所以要充分利用机会在速度和内存之间权衡取舍。
提高程序速度很容易(将其运行在更多的计算机上),但缩小内存占用很难(一个盒子里的 RAM 只有那么多)。
注意字节,千兆字节会自己照顾好。
如果不能做到简洁明了,就要做到 CRISP。
作者简介:
John Arundel,是一位知名的 Go 语言程序员和导师。他已经写软件 40 年了,认为自己开始懂得如何写好代码了。
原文链接:https://bitfieldconsulting.com/golang/crisp-code#circle=on
评论 1 条评论