



本书介绍如何让你编写的Python更易于管理。随着代码库的增长,你需要一个包含技巧和策略的工具箱来构建可维护的代码。本书将指导你减少代码缺陷,让开发工作更轻松。你将认真考虑如何编写代码,并了解你的决策所带来的影响。当讨论代码是如何编写的时候,我想起了C.A.R.Hoare的名言:
软件设计的构建有两种方法:一种是使它简单到明显没有缺陷,另一种是使它复杂到没有明显的缺陷。第一种方法要困难得多 [1] 。
本书是关于用第一种方法开发系统的,这将会更困难,但不要害怕。我将引导你提升编写Python代码的水平,就像C.A.R.Hoare说的那样,让你的代码“明显没有缺陷”。归根结底,这是一本关于编写健壮的Python的书。
在本章中,我们将讨论什么是健壮性,以及为什么应该关注它。我们将讨论你的沟通方式会如何蕴含特定优势和不足,以及如何最好地表达你的意图。“Python之禅”( https://oreil.ly/SHq8i )中指出,在开发代码时,“应该有一种而且最好只有一种明显的方法来做到这一点。”你将学习如何评估你的代码是否明显没有缺陷,以及如何修复它。首先,我们需要回答一个最基本的问题:什么是健壮性?
每本书都需要一个术语定义,所以我将尽早地解决这个问题。Merriam-Webster提供了许多关于健壮性的定义( https://oreil.ly/2skKO ):
1.具备或表现出强壮或健康的。
2.具备或显示出活力、力量或坚固的。
3.架构或结构强有力的。
4.能够在广泛的条件下无故障地执行的。
这些都是对于目标的精彩描述。我们想要一个健康运行的系统,一个能满足人们多年期望的系统。我们希望我们的软件展示出实力,代码很明显地能经得起时间的考验。我们想要一个结构坚固的系统,一个建立在坚实基础之上的系统。至关重要的是,我们想要的是一个能够无故障执行的系统,并且不应该因为引入变化而变得脆弱。
人们通常会把软件想象成摩天大楼——一些矗立在那里的宏伟结构,是对抗所有变化的堡垒,是不朽的典范。不幸的是,事实要更加混乱。软件系统会持续演进,缺陷被修复,用户界面得到调整,功能被添加、删除,然后重新添加。框架转移,组件过时,安全漏洞出现,软件在不断发生变化。开发软件更类似于处理城市规划中的扩张,而不是建造一个静态的建筑。在不断变化的代码库中,如何使代码更加健壮呢?要如何建立一个对于缺陷具备弹性的强大基础?
事实上,你必须接受变化的发生。你的代码会被分割、拼接,然后再重新编写。新的用例将需要改变大量的代码——这没关系,拥抱它。要明白,仅仅简单地修改代码是不够的。当它过时的时候,最好将它删除并重写。这并不会降低它的价值,它仍会在聚光灯下长期存在。你的工作是使重写系统的某些部分变得容易。一旦开始接受代码的短暂性,你就会开始意识到,编写就当下而言无缺陷的代码是不够的,你需要使代码库的未来开发者能够有信心地更改你的代码。这就是这本书的主题。
你将会学习构建强大的系统。这种强度不是像是一根铁棒所表现的那样取决于它的硬度如何,而是取决于它的灵活性。你的代码需要像一棵高大的柳树一样强壮,在风中摇摆,弯曲但不折断。你的软件将需要处理很多意想不到的情况。你的代码库需要能够适应新的环境,因为维护它的人并不总是你。那些未来的维护者需要知道他们的代码库是健康稳定的。你的代码库需要传达它的力量。所以你必须以一种减少缺陷的方式编写Python代码,即使将来的维护者会将其拆分并重新构建。
编写健壮的代码意味着要谨慎地考虑未来。你希望未来的维护人员能够轻松地查看你的代码并理解你的意图,而不会在深夜的调试会话中诅咒你。你必须传达你的想法、理由和注意事项。未来的开发人员将需要将你的代码改变成新的样子——并且不希望担心有任何改动可能导致它会像一个摇摇欲坠的纸屋一样倒塌。
简单地说,你不希望你的系统出现故障,尤其是当意外发生的时候。测试和质量保证是其中的重要部分,但两者都不能完全保证质量。它们更适合揭示预期中的差距,并提供安全网。相反,你必须让你的软件经得起时间的考验。为了做到这一点,你必须编写整洁和可维护的代码。
整洁代码按照顺序清晰而整洁地表达了它的意图。当你看到一行代码并对自己说“啊,这个写法非常有意义”时,就是整洁代码的标志。你越是逐步调试代码,就越需要查看大量其他代码来理解程序,此时你将掉进代码漩涡,代码就越不整洁。如果一些技巧会使代码对其他开发人员来说不可读,那么这些技巧也不是使代码整洁的方法。就像C.A.R.Hoare所说的,你不想让你的代码变得很晦涩,以至于在检查时难以理解。
整洁的代码对代码健壮性至关重要。对于任何有意义的项目,都可以将其视为筹码。通常有一些与编写整洁代码相关的具体实践,包括:
•以粒度适当的方式组织代码。
•提供良好的文档。
•正确命名变量/函数/类型。
•保持函数短小简单。
虽然整洁代码这个主题会贯穿全书,但我不会用大量的篇幅介绍这些具体的实践。有一些其他的书更好地描述了整洁代码的实践。我推荐Robert C.Martin的 Clean Code (Prentice Hall)、Andy Hunt和Dave Thomas的 The Pragmatic Programmer (Addison-Wesley),以及Steve McConnell的 Code Complete (Microsoft Press)。这三本书都极大地提高了我作为开发人员的技能,对任何想要成长的人来说都是很好的资源。
虽然你绝对应该努力写出整洁的代码,但你也必须准备好在不整洁的代码库中工作。软件开发是一项混乱的工作,有时会因为各种各样的业务和技术因素而牺牲整洁代码的纯粹性。使用本书中给出的建议,通过讨论可维护性来帮助推动代码变得更整洁。
可维护的代码是指可以很容易维护的代码。维护在第一次提交后立即开始,并一直持续到没有一个开发者再关注这个项目为止。开发人员将修复缺陷,添加特性,阅读代码,提取代码以在其他库中使用,等等。可维护的代码让这些任务没有了障碍。软件的寿命长达数年,甚至数十年,所以从今天起就要关注可维护性。
为了不让系统失败,你需要积极主动地让你的系统经得起时间的考验,需要一个测试策略作为你的安全网,但你也需要能够在一开始就避免失败。因此,考虑到所有这些,我从代码库的角度给出了健壮性的定义:
尽管一直处在不断变化的环境中,但健壮的代码库是有弹性和没有缺陷的。
为什么健壮性很重要
让软件做它应该做的事情花费了我们大量的精力,但要知道什么时候完成并不容易。开发里程碑不容易预测。用户体验、可访问性和文档等人为因素只会增加复杂性。现在添加测试以确保你已经覆盖了已知和未知行为的一部分,并且你正在考虑较长的开发周期。
软件的目的是提供价值。尽早交付全部的价值符合全部利益相关者的需求。考虑到一些开发计划的不确定性,满足预期往往存在额外的压力。我们都曾在一个不现实的项目计划或截止期限中犯过错误。不幸的是,许多使软件变得健壮的工具只是短期加入了我们的开发周期。
确实,在立即交付价值和使代码健壮之间存在着内在的矛盾。如果你的软件足够好,为什么还要添加更多的复杂性呢?要回答这个问题,请考虑该软件的迭代频率。交付软件价值通常不是一个静态的工作,很少有系统因为提供了价值而不再被修改。软件的本质是不断发展的,代码库需要做好频繁、长期地交付价值的准备。这就是健壮的软件工程实践发挥作用的地方。如果你不能快速而轻松地交付功能并且不降低质量,则需要重新评估技术以使你的代码更易于维护。
如果你的系统交付延误或损坏了,就会产生实际成本。仔细研究你的代码库,问问你自己,如果一年后你的代码因为有人无法理解而崩溃了,会发生什么?你会损失多少价值?这些价值可以用金钱、时间甚至生命来衡量。问问自己,如果没有按时交付,会发生什么?后果是什么?如果这些问题的答案会令人感到恐惧,那么好消息就是,你所做的工作是有价值的,同时这也强调了消除未来会出现的缺陷的重要性。
许多开发人员会同时在一个代码库上工作。许多软件项目将比大多数开发人员的寿命还长。所以,你需要找到一种方法与现在以及未来的开发人员沟通,而不是在亲自在现场解释。未来的开发人员将以你的决定为基础。每一次错误的追踪、每一个兔子洞和每一次牦牛递须冒险
都会减慢他们的速度,从而阻碍他们交付价值。你需要共情那些追随你脚步的人,所以你需要换位思考。这本书是你思考合作者和维护人员的途径。你需要考虑可持续的工程实践。你需要编写持久的代码。第一步,使你的代码能够持续沟通,确保未来的开发人员能够通过代码理解你的意图。
为什么要努力编写整洁和可维护的代码?为什么要如此关注健壮性呢?这些答案的核心在于沟通。你不是在交付一个静态的系统。代码将继续变化。你还必须考虑到维护人员会随着时间的推移而变化。在编写代码时,你的目标是交付价值。另外,也需要考虑让其他开发人员也能以同样快的速度交付价值。为了做到这一点,你需要能够在不与未来的维护人员见面的情况下交流和展示你的意图。
让我们看看在一个虚构的遗留系统中找到的代码块。我想让你估计一下你需要多长时间来理解这段代码在做什么。如果你对这里的所有概念都不熟悉,或者你觉得这个代码很复杂,也没关系。
这个函数获取一个食谱,并调整每种配料以匹配新的“食用量”。但是,此代码中有许多问题:
•pop用来干什么?
•recipe[0]指的是什么?为什么这是旧的食用量?
•为什么需要一个注释来提醒我使用容易衡量的数字?
如果你觉得有必要重写代码,我不会怪你。这样写看起来更好:
那些喜欢整洁代码的人可能更喜欢第二个版本。没有原始循环,变量不会变化。我要返回一个字典,而不是一个元组列表。这种情况下,所有这些变化都可以被视为积极的。但是,我刚刚可能引入了三个不易发现的缺陷:
•在最初的代码片段中,我清除了原始代码,现在则不是了。即使只有一个区域的调用代码依赖于这种行为,我也打破了那个调用代码的假设。
•通过返回一个字典,列表中不会再重复出现配料。这可能会对多个部分(例如主菜和酱汁)使用了相同配料的食谱产生影响。
•如果任何配料被命名为“servings”,就产生了一个命名上的冲突。
这些是否是缺陷取决于两点:原作者的意图和代码调用。作者的本意是解决问题,但我不确定他们为什么要以这种方式写代码。为什么它们会弹出元素?为什么“servings”在列表里面是一个元组?为什么要使用一个列表?根据推测,代码原作者知道原因,并将其传达给了办公室的同事。他们的同事根据这些假设写了调用代码,但随着时间的推移,原来的意图已经不清楚了。如果不能与未来的开发人员沟通,要维护这个代码就只剩下了两个选择。
•查看所有的调用代码,确保这个操作在实施之前是互不依赖的。如果这是一个有外部调用者的库的公共API,那就祝你好运。我会花很多时间来做这件事,这将使我感到沮丧。
•做出改变,然后等着看后果是什么(客户投诉、测试被破坏,等等)。如果幸运的话,不会发生什么糟糕的事;如果不幸运的话,就会需要花大量的时间来修复用例,这将使我感到沮丧。
就维护而言,这两种选择都不见得有什么成效(特别是在必须要修改这段代码的时候)。我不想浪费时间,想尽快处理好当前的任务,然后继续下一个任务。如果考虑如何调用这段代码,情况会变得更糟。想一想你是如何与以前未见过的代码交互的。你可能会看到其他调用代码的例子,复制它们以适应你的用例,并且从未意识到你需要传递一个叫作“servings”的特定字符串作为列表的第一个元素。
这些都是会让你感到烦恼的决定。我们都在更大的代码库中看到过它们。它们并不是恶意编写的,而是随着时间的推移,有机地维持着最好的意图。函数开始时很简单,但随着用例的增加和多个开发人员的贡献,这些代码往往会变形并掩盖了最初的意图。这是可维护性正在受到损害的明显标志。你需要在你的代码中预先表达出你的意图。
那么,假如原作者使用了更好的命名模式和更好的类型呢?这段代码会是什么样子?
这看起来要好得多,编写得更好,并且清楚地表达了原始意图。原来的开发人员把他们的想法直接编写到代码中。从这段代码中,你知道了以下情况:
•我正在使用一个Recipe类。这使得我可以抽象出某些操作。根据推测,在这个类里面有一个不变量,允许重复的配料(我将在第10章中更多地讨论类和不变量)。这里提供了一个通用的词汇,使函数的行为更加明确。
•食用量现在是Recipe类的一个明确的部分,而不需要成为列表中的第一个元素,后者是作为一种特殊情况处理的。这大大简化了调用代码,并防止无意中的冲突。
•很明显,我想把旧食谱上的配料清除掉。至于我为什么需要做一个.pop(0)则没有任何的模糊理由。
•配料是一个单独的类,并且处理分数( https://oreil.ly/YxUHK )而不是一个显式浮点数。所有参与者都更清楚地知道我在处理分数单位,并且可以很容易地做一些事情,比如limit_denominator(),当人们想要限制测量单位时,可以调用它(而不是依靠一个注释)。
我已经用类型代替了变量,如食谱类型和配料类型。我还定义了一些操作(clear_ingredients、adjust_proportion)、来表达我的意图。通过这些修改,我使代码对未来的读者来说非常清晰。未来的开发人员不再需要和我讨论才能理解这些代码。相反,他们不用跟我讨论就能理解我在做什么。这就是异步沟通的最佳方式。
异步沟通
在一本Python书中写到异步沟通而不提及async和await是很奇怪的。但我恐怕要在一个更复杂的地方讨论异步沟通:现实世界。
异步沟通意味着信息的产生和消费是相互独立的。在生产和消费之间有一个时间差,这可能是几个小时,就像合作者在不同时区的情况一样,也可能是几年,因为未来的维护者会试图对代码的内部运作进行深入的研究。你无法预测什么时候有人会需要了解你的逻辑,在他们使用你生成的信息时,你甚至可能已经不在那个代码库上(或为那个公司)工作了。
与异步沟通不同,同步沟通是指实时的思想交流。这种形式的直接沟通是表达想法最好的方式之一,但不幸的是,它无法扩展,你也不能一直在那回答问题。
为了评估每种沟通方式在试图理解意图时是否合适,让我们看看两个影响因素:距离和成本。
距离指的是为了使沟通富有成效,沟通者在时间上需要有多近。有些沟通方法更擅长实时传递信息。其他的沟通方式在几年后仍然会很有效。
成本是衡量沟通投入的标准。你必须权衡沟通所花费的时间和金钱与所提供的价值。然后,未来开发者必须权衡消费信息的成本与他们想要交付的价值。编写代码而不提供任何其他沟通渠道是你的底线,你必须这样做才能创造价值。为了评估额外的沟通渠道的成本,以下是我考虑的因素:
可发现性
在正常工作流程之外找到这些信息的容易性如何?这些信息的生命周期有多长?搜索信息容易吗?
维护成本
信息的准确性如何?它需要多久更新一次?如果这些信息过时了,会出现什么问题呢?
生产成本
有多少时间和金钱投入沟通?
在图1-1中,我根据自己的经验绘制了一些常用的沟通方式所需的成本和距离。
图1-1:不同沟通方式的成本-距离图
成本-距离图有四个象限。
低成本,近距离
这种沟通方式的生产和消费成本都很低,但不能跨时间扩展。直接沟通和即时回复就是很好的例子。将这些视为及时的信息,它们只有在用户积极倾听时才有价值。不要依赖这些方式来与未来维护者沟通。
高成本,近距离
这种沟通方式成本高昂,而且往往只发生一次(如会议或会面)。这种沟通方式应该在交流时传递大量的价值,因为它们不会为未来提供太多的价值。你参加了多少次浪费时间的会议?你会直接感受到价值的损失。每一位参会者都需要成倍的成本(所花费的时间、空间、后勤保障工作等)来进行交谈。代码检查很少在完成后立即得到关注。
高成本,远距离
这是非常昂贵的沟通方式,但是因其远距离的特点,随着时间的推移,这些成本可以在价值交付中得到回报。电子邮件和敏捷看板中包含了大量的信息,但其他人是无法发现的。这对于不需要频繁更新的大型概念来说非常有用。要从所有的干扰因子中筛选出你想要的信息,将会是一场噩梦。视频记录和设计文档对于及时理解信息非常有用,但维持的成本很高,不要依赖这些沟通方式来理解日常的决策。
低成本,远距离
这种沟通方式的成本很低,而且很容易使用。代码注释、版本控制历史记录和项目文档都属于这一类,因为它们与我们编写的源代码相邻,用户在多年后仍可以查看。开发人员在日常工作流程中遇到的任何事情都是可以被发现的。这些沟通方式很自然地适用于有人负责源代码的地方。然而,你的代码是你最好的文档工具之一,因为它是系统的记录和真实原理的唯一来源。
讨论
图1-1是基于通用的用例创建的。考虑你和你的组织使用的沟通方式,你会把它们画在图中的哪个位置?获取准确的信息有多容易?生产信息的成本有多高?根据你对这些问题的答案,可能会得到一个稍有不同的图,但真实原理的唯一来源将出现在你交付的可执行软件中。
低成本、远距离的沟通方式是与未来沟通的最佳工具。你们应该努力将沟通的生产成本和消耗降到最低。无论如何,你必须编写能够交付价值的软件,所以成本最低的选择是将代码作为主要的沟通工具。你的代码库将成为清晰地表达你的决定、意见和变通方法的最佳选择。
然而,要使这个断言成立,代码也必须是低成本的。你的意图必须在代码中清晰地表达出来。你的目标是尽量减少读者理解代码所需的时间。理想情况下,读者不需要阅读你的代码实现,而只需阅读你的函数签名。通过使用良好的类型、注释和变量名,代码的功能就会非常清晰。
对于图1-1的错误反应是“我们只需要自文档化的代码”。代码绝对应该自我记录正在做的事情,但不能覆盖到每一个用例中去。例如:版本控制将提供更改的历史记录;设计文档讨论的是不局限于任何一个代码文件的全局设想;会议(如果安排得当)可以是执行计划的重要事件;交流协商非常适合与大量听众同时分享想法。虽然本书专注于告诉你在代码中可以做什么事,但不要丢弃任何其他有价值的沟通方式。
既然我已经讨论了什么是意图以及它的重要性,让我们从Python的角度来看一些例子。你如何确定你正确地表达了你的意图?让我们看两个决定如何影响意图的不同的例子:集合和迭代。
当你选择一个集合(collection)时,你就是在用这个集合传达特定的信息,你必须为手头的任务选择正确的集合。否则,维护人员将从你的代码中推断出错误的意图。
思考下面这段代码,它获取一个烹饪书列表,并提供作者与已写书籍数量之间的映射:
我对集合的使用说明了什么?为什么我不传递字典或集(set)?为什么我不返回列表?基于我目前使用的集合,你可以假设:
•我传进来一份烹饪书列表。在这个列表中可能会有重复的烹饪书。
•我返回字典类型。用户可以查找特定的作者,或者遍历整个字典。我不必担心返回的集合中有重复的作者。
如果我想要传达不应该将重复信息传递到这个函数中,该怎么办?列表传达了错误的意图。相反,我应该选择一个集来表示此代码绝对不会处理重复信息。
选择集合可以告诉读者你的具体意图。以下是一些常见的集合类型,以及它们传达的意图:
列表
这是一个用来迭代的集合。它是可变的,可以在任何时候更改。很少会期望从列表的中间检索特定的元素(使用静态列表索引),里面可能有重复的元素。书架上的烹饪书可以用列表的形式存储。
字符串
不可变的字符集合。烹饪书的名字可以是一个字符串。
生成器
一个用来迭代的集合,而不是被索引的集合。每个元素访问都是被惰性执行的,所以每个循环迭代都可能会花费时间和资源。对于计算昂贵或无限的集合来说,它们非常有用。一个菜谱的在线数据库可能会作为生成器返回,当用户只查看搜索的前10个结果时,你不需要获取世界上所有的菜谱。
元组
一个不可变的集合。元组不会发生变化,因此更有可能从中提取特定的元素(通过索引或解包)。它很少被迭代。关于特定烹饪书的信息可以表示为一个元组,例如(cookbook_name,author,pagecount)。
集
不包含重复项的可迭代集合,但是不能依赖于元素的顺序。烹饪书中的配料可以作为一个集存储。
字典
从键到值的映射。键在整个字典中是唯一的。字典通常能迭代访问,或使用动态键建立索引。一个烹饪书的索引是键到值映射(从主题到页码)的一个很好的例子。
不要使用与你的意图不符的集合。我经常遇到不应该有重复项的列表,或者没有实际用于将键映射到值的字典。每当你的意图与代码中的内容脱节时,就会产生维护负担。维护人员必须暂停一下,找出你真正想表达的意思,然后绕过错误的假设。
根据你所使用的集合类型,你可能希望或不希望使用静态索引。静态索引是使用一个常数来索引到集合中,例如my_list[4]或my_dict["Python"]。一般来说,列表和字典通常不需要这样的用例。由于它们的动态特性,你不能保证这个集合在那个索引上有你要找的元素。如果你正在这些类型的集合中寻找特定的字段,这是一个好迹象,说明你需要一个用户定义的类型(在第8~10章中讨论)。对元组设置静态索引是安全的,因为它们是固定的大小。而集和生成器不会设置索引。
例外情况包括:
•获取序列的第一个或最后一个元素(my_list[0]或my list[-1])。
•使用字典作为中间数据类型,例如读取JSON或YAML时。
•处理特定固定块的序列操作(例如,总是在第三个元素后分割或检查固定格式字符串中的特定字符)。
•特定集合类型的性能原因。
相反,动态索引用一个变量(这个变量只有运行的时候才知道)索引到一个集合。对于列表和字典来说,这是最合适的选择。在遍历集合或使用index()函数搜索特定元素时,你会发现这一点。
这些都是基本的集合,但有更多的方式来表达意图。以下是一些特殊的集合类型,它们在与未来的沟通中更具表现力:
frozenset
不可变的集合。
orderedDict
基于插入时间保持元素顺序的字典。从CPython 3.6和Python 3.7开始,内置字典也将根据插入时间保留元素的顺序。
defaultdict
在键缺失时提供默认值的一个字典。例如,可以重写之前的例子如下:
这为最终用户引入了一种新的行为——如果他们在字典中查询一个不存在的值,将返回0。在某些情况下,这可能是有益的,但如果不是,你可以直接返回dict(counter)。
Counter
一种特殊类型的字典,用于计算一个元素出现的次数。这可以大大简化前面的代码,如下所示:
花一分钟想想最后一个例子,看看使用Counter如何可以在不牺牲可读性的情况下使代码更加简洁。如果你的读者熟悉Counter,那么这个函数的含义(以及实现方式)是显而易见的。这是通过更好地选择集合类型来向未来传达意图的一个很好的例子。我将在第5章进一步探讨集合。
还有许多其他类型需要研究,包括array、bytes和range。无论何时遇到一个新的集合类型(无论是内置的还是其他类型),问问自己它与其他集合有什么不同,它向未来的读者传达了什么信息。
迭代是另一个例子,你选择的抽象会指示你所传达的意图。
你见过多少次这样的代码?
这段简单的代码将每个字符打印在单独的行中。这对于第一次使用Python尝试解决这个问题来说是完美的,但是解决方案很快就演变成更具Python风格(代码旨在强调简单性,以惯用风格编写,大多数Python开发人员都能识别):
花点时间思考一下为什么这种选择更可取。for循环是一个更合适的选择,它能更清楚地传达意图。与集合类型一样,你选择的循环结构明确地传达了不同的概念。下面是一些常见的循环结构及其含义:
for 循环
for循环用于遍历集合或范围中的每个元素并执行一个操作。
while循环
while循环用于迭代,直到某个条件为真为止。
推导
推导用来将一个集合转换成另一个集合(通常,这没有副作用)。
递归
当集合的子结构与集合的结构相同时(例如,树的每个子结构也是树),就使用递归。
你希望代码库的每一行都能交付价值。此外,你希望每一行代码都能清楚地向未来的开发人员传达该价值。这就需要最小化任何样板、脚手架和多余代码的数量。在上面的例子中,我遍历每个元素并执行一个操作(打印一个元素),这使for循环成为一个理想的循环结构。我不是在浪费代码。相反,while循环要求我们显式地跟踪循环,直到某个条件发生。换句话说,我需要跟踪一个特定的条件并在每次迭代时改变一个变量。这分散了对循环所提供的价值的关注,并带来了不必要的认知负担。
意图不明是很糟糕的,但还有一种沟通更糟糕:代码让未来的合作者感到惊讶。你要坚持最小惊讶原则:当人们阅读代码库时,他们几乎不应该对行为或实现感到惊讶(当他们感到惊讶时,应该在代码附近有很好的注释来解释为什么要这样)。这就是为什么沟通意图是至关重要的。清晰、整洁的代码可以减少可能出现的误解。
最小惊讶原则指出程序应该总是以最不让用户感到惊讶的方式回应用户
[2]
。令人惊讶的行为会导致困惑,困惑会导致错误的假设,错误的假设导致了bug。这就是你得到不可靠的软件的原因。
请记住,即使是你编写的代码完全正确,将来仍然可能会让别人感到惊讶。在我的职业生涯早期,有一个令人讨厌的bug,由于内存损坏而崩溃。把代码放在调试器下或放入太多的print语句会影响时间,这样bug就不会显示(一个真正的“heisenbug” [3] )。有成千上万的代码与这个错误相关。
所以我必须手动将代码一分为二,通过分别删除一半代码,看看哪一半确实发生了崩溃。经过两周的折腾,我终于决定检查一个听起来无关痛痒的函数getEvent。事实证明,这个函数实际上设置了一个带有无效数据的事件。不用说,我很惊讶。这个函数在它所做的事情上是完全正确的,但是因为我忽略了代码的意图,我忽略了这个错误至少三天。让你的合作者惊讶会浪费他们很多时间。
很多惊讶都来自复杂性。复杂性有两种类型:必然的复杂性和偶然的复杂性。必然的复杂性是在你的领域内固有的复杂性。深度学习模型必然是复杂的,它们不是你可以在几分钟内浏览和理解其工作原理的东西。优化对象关系映射(ORM)必然是复杂的,必须考虑大量可能的用户输入。你将无法消除必然的复杂性,所以你最好的选择是尝试控制它,以免它蔓延到你的代码库,并最终成为偶然的复杂性。
相反,偶然的复杂性是在代码中产生多余、浪费或混乱语句的复杂性。它是当系统随着时间的推移而演进,开发人员在没有重新评估旧代码以确定其原始断言是否仍然正确时出现的情况。我曾经参与过一个项目,添加一个命令行选项(以及通过编程设置它的相关方法),涉及的文件不少于10个。为什么添加一个简单的值就需要改变整个代码库?
以下情况都属于偶然的复杂性。
•听起来简单的事情(添加用户、更改UI控件等)实现起来并非易事。
•让新开发人员理解你的代码库很难。项目中的新开发人员是你的代码可维护性的最佳指示器——不需要等待数年。
•对添加功能所需的时间估算总是很长,但你还是不能够及时完成。
排除偶然的复杂性,尽可能地隔离必然的复杂性。因为它们会成为你未来合作者的绊脚石。这些复杂性的来源会加剧误解,它们让整个代码库的意图变得模糊不清。
讨论
你的代码库中的偶然的复杂性是什么?如果你被丢进代码库而不能与其他开发人员交流,理解简单的概念会有多大的挑战?如何简化本练习中确定的复杂性(特别是在经常变化的代码中)?
在本书的其余部分中,我将探讨在Python中沟通意图的不同技术。
健壮的代码很重要,整洁的代码也很重要。你的代码需要在代码库的整个生命周期内都是可维护的,为了确保如此,你需要对你正在沟通的内容和方式进行积极的预见。你需要尽可能将你的知识清晰地体现在代码中。不断向前看感觉像是一种负担,但是随着实践的进行,它会变得很自然,当你在自己的代码库中工作时,你就会开始收获好处。
每次你将真实世界的概念映射到代码中时,无论是通过使用集合还是决定保持函数的独立性,你都是在创建一个抽象。每一个抽象都是一种选择,每一个选择都传达了一些东西,无论有意或无意。我鼓励你思考正在编写的每一行代码,并问自己:“未来的开发人员将会从中学到什么?”你应该让未来的维护人员能够以与你今天相同的速度交付价值。否则,你的代码库将会变得臃肿,进度将会下滑,复杂性将会增加。作为开发者,你的工作就是降低这种风险。
寻找潜在的麻烦点,如错误的抽象(集合或迭代)或偶然的复杂性。随着时间的推移,这些都是沟通可能会中断的主要区域。如果这类麻烦点的区域经常发生变化,那么现在就应该优先处理它们。
在第2章中,你将把本章的内容应用到一个基本的Python概念——类型中。你选择的类型向未来的开发人员表达了你的意图,选择正确的类型将带来更好的可维护性。
[1] Charles Antony Richard Hoare.《皇帝的旧衣》。ACM 24, 2(1981年2月),75-83. https://doi.org/10.1145/358549.358561 .
[2] Geoffrey James. The Tao of Programming.https://oreil.ly/NcKNK.
[3] 当被观察时,会显示不同的行为的bug。 SIGSOFT'83:Proceedings of the ACM SIGSOFT/SIGPLANsoftware engineering symposium on High-level debugging.