1.3 代码质量的支柱
前面介绍的4个目标可以帮助我们聚焦要实现的根本目标,但它们并没有提供日常编程的具体建议。努力找出更加具体的策略,帮助我们编写符合这些目标的代码,是有益的。本书将围绕6个此类策略展开介绍。我将这6个策略称为“代码质量的六大支柱”(或许有些言过其实)。我们将首先概述每个支柱,后续的章节将提供具体的示例,说明如何在日常编程中应用它们。
代码质量的六大支柱如下:
● 编写易于理解(可读)的代码;
● 避免意外;
● 编写难以误用的代码;
● 编写模块化的代码;
● 编写可重用、可推广的代码;
● 编写可测试的代码并适当测试。
1.3.1 编写易于理解的代码
考虑如下这段文本。我们有意地使其变得难以理解,因此,不要浪费太多时间去解读。粗略地读一遍,尽可能吸收其中的内容。
取一个碗,我们现在称之为A。取一个平底锅,我们现在称之为B。在B中装满水,置于炉盘上。在A中放入黄油和巧克力,前者100g,后者185g。这应该是70%的黑巧克力。将A放在B之上;等待A的内容物融化,然后将A移到B之外。再取一个碗,我们现在称之为C。在C中放入鸡蛋、糖和香草香精,第一种原料放两个,第二种185g,第三种半茶匙。混合C的内容物。A的内容物冷却后,将其加入C中并混合。取一个碗,我们称之为D。在D中放入面粉、可可粉和盐,第一种原料50g,第二种原料35g,第三种半茶匙。完全混合D的内容物,然后过滤到C中。充分搅拌D的内容物使其完全混合。我们要用这种方法制作巧克力糕饼,我是不是忘记说这个了?在D中加入70g巧克力屑,充分搅拌D的内容物。取一个烘焙模具,我们称之为E。在E中涂上油脂并铺上烘焙纸。将D的内容物放入E中。我们将把你的烤炉称为F。顺便说一句,你应该将F预热到160℃。将E放入F中20min,然后取出。让E冷却几小时。
现在,我们提出一些问题。
● 这段文本说的是什么?
● 按照这些指示,我们最终能得到什么?
● 我们需要哪些配料?各种配料的分量是多少?
我们可以在这段文本中找到上述问题的答案,但并不容易。这段文本的可读性很差。造成这一结果的问题很多,包括如下。
● 没有标题,因此我们不得不通读整段文本,以领会它的意义。
● 这段文本没有很好地组成为一系列步骤(或者子问题),而是像一堵长长的文本墙。
● 用毫无益处的模糊名称指代事物,如“A”,而不是“装有融化后奶油和巧克力的碗”。
● 信息与需要它们的地方相隔甚远:成分与数量相互分离,烤炉需要预热这样的重要指示到最后才提及。
(你可能已经感到厌倦,没有读完这段文本,它是巧克力糕饼的食谱。如果你真想制作这种食物,附录A中有一个更易于理解的版本。)
阅读一段质量低劣的代码并试图领会其含义,与我们刚刚阅读巧克力糕饼食谱的体验没有什么不同。特别是,我们可能很难理解关于代码的如下情况:
● 做什么;
● 怎么做;
● 需要什么成分(输入或状态);
● 运行代码后得到什么。
在某个时间,其他工程师很有可能必须阅读并理解我们的代码。如果我们的代码在提交之前必须经过代码评审,那么这种情况几乎是立刻发生的。但即便忽略代码评审,在某个时间,其他人也会查看我们的代码,并试图领会它的作用。这可能发生在需求变化或者代码需要调试的时候。
如果我们的代码可读性很差,其他工程师就不得不花费很多时间来解读它。他们很有可能错误地理解它的作用,或者遗漏一些重要的细节。如果发生这种情况,代码评审期间就不太可能发现缺陷,而在其他人修改我们的代码、添加新功能时,就有可能引入新缺陷。软件的功能都是基于代码来完成的。如果工程师无法理解代码的作用,也就几乎不可能确定软件能否正常工作。正如食谱一样,代码必须易于理解。
在第2章中,我们将了解到,如何通过定义正确的抽象层次来帮助实现可读性。而在第5章中,我们将介绍使代码更易理解的一些具体技术。
1.3.2 避免意外
过生日时得到一件礼物,或者彩票中奖,都是意外好事(惊喜)的例子。但是,当我们试图完成一件特定任务时,意外通常不是好事。
想象一下,你饿了,因此决定下单购买一些比萨。你拿出电话,找到比萨店的电话号码,拨通电话。很奇怪的是,电话那端沉默了很长时间,但最终还是接通了,有个声音询问你:想要什么?
“请来一份大的玛格丽塔比萨,外送。”
“好的,你的地址?”
半个小时以后,你收到外卖,打开包装一看却发现图1-3中的情景。
图1-3 如果你以为是在和一家比萨店通话,实际上却是一家墨西哥餐厅,那么你的订单仍然有意义,但送来的可能是意想不到的东西
哇,这真是意外。显然,有人将“margherita”(一种比萨的名称)误听成“margarita”(一种鸡尾酒的名称)。但这是件怪事,因为这家比萨店没有提供鸡尾酒。
原来,你手机上使用的定制拨号应用添加了一个新的“智能”功能。应用开发者发现,当用户拨打一家餐厅的电话却遇到忙线的情况时,80%的人会立刻致电另一家餐厅,因此,他们创建了一个节约时间的方便功能:当你拨打一个应用识别为餐厅的电话号码且遇到忙线,该应用将无缝地拨打电话簿中下一家餐厅的电话号码。
在这个例子中,下一家餐厅恰好是你最喜欢的墨西哥餐厅,而不是你以为正在拨打的比萨店。墨西哥餐厅肯定提供玛格丽塔鸡尾酒,而不是比萨。应用开发者的意图很好,也认为这种功能可以方便用户的生活,但他们创建了一个会带来某种意外的系统。我们依赖自己对电话的心理模型,根据听到的声音确定发生的事情。重要的是,如果我们听到语言应答,心理模型就会告诉我们已经接通刚刚拨打的电话号码。
定制拨号应用的这个新功能改变了应用的表现,超出我们的预期。它打破了我们的心理模型假设,即语音应答意味着我们已经接通刚刚拨打的电话号码。这个功能或许很有用,但因为其行为超出普通人的心理模型,就必须明确告知人们发生的情况,例如用语音信息告诉人们拨打的电话号码正忙,询问是否愿意拨打另一家餐厅的电话号码。
可以将这个定制拨号应用类比为一段代码。其他工程师在使用我们的代码时将名称、数据类型和常见约定作为线索,以构建一个心理模型,用于预测我们的代码以什么为输入、完成什么功能、返回什么结果。如果我们的代码行为超出这个心理模型,就很有可能导致软件潜藏缺陷。
在致电比萨店的例子中,即便在发生意外情况之后,一切似乎仍正常运转:你点了一个玛格丽塔比萨,餐厅也乐于效劳。直到很久以后,错误已无法纠正,你才发现无意间点了一杯鸡尾酒而不是一份比萨。这与软件系统中的代码完成意外工作时常常发生的情况很类似:因为代码调用者没有预料到这种意外,这些代码将一无所知地继续执行。在一段时间里,一切看起来都很正常,但随后出现可怕的错误,程序处于无效状态,或者将一个奇怪的值返回给用户。
即便有着最好的意图,编写提供一些有用或者“聪明”功能的代码仍有造成意外的风险。如果代码做了某些意外的事情,使用代码的工程师不会知道也不会思考处理那种情况的方法。这往往会导致系统“跛行”,直到在远离问题代码的地方出现明显的古怪表现。或许,这只会产生一个有些恼人的缺陷,但也可能造成破坏重要数据的灾难性问题。我们应该提防代码中的意外情况,并尽可能避免。
在第3章中,我们将看到,代码契约是一种有助于解决这个问题的基础技术。第4章在介绍软件错误时提到,如果不能正确提示或处理这些错误,就可能导致意外情况。第6章将关注避免意外的一些更具体的技术。
1.3.3 编写难以误用的代码
在电视机的背部,我们可能会看到如图1-4所示的接口。我们可以在这些接口里插入不同的线缆。重要的是,电视机厂商通过将不同的接口设计成不同的形状,可以防止用户将电源线插进HDMI接口中。
图1-4 电视机厂商有意将电视机背部的接口做成不同形状,以避免用户插入错误的线缆
想象一下,如果电视机厂商没有这么做,而是将每个接口做成相同的形状,将会出现什么情况。你认为会有多少人在电视机背部摸索的时候不小心将线缆插进错误的接口里?如果将HDMI线缆插到电源接口里,电视机可能无法工作,虽然让人烦恼,但也不算太可怕。但如果有人将电源线插进HDMI接口里,就会烧毁电视机的电路板。
我们所写的代码常常被其他代码调用,这有点像是一台电视机的背部。我们预计其他代码会“插入”某种东西,比如输入参数,或者在调用前将系统置于某个状态。如果将错误的东西“插入”代码,就可能造成某些破坏:系统崩溃、数据库永久性损坏或者丢失某些重要数据。即便没有造成破坏,代码也很有可能无法正常工作。我们的代码被调用是有原因的,插入不正确的内容,可能意味着一项重要的任务没有执行,或者某些古怪的行为没有引起注意。
通过编写很难或不可能被误用的代码,我们可以最大限度地提高代码持续正常工作的概率。针对这个问题,有许多实用的解决方法。第3章介绍的代码契约(类似于避免意外)是有助于编写难以误用的代码的基础技术。第7章将介绍编写难以误用的代码的一些更为具体的技术。
1.3.4 编写模块化的代码
模块化意味着一个对象或系统由可独立替换的更小的组件组成。为了说明这个概念以及模块化的好处,我们考虑图1-5中的两个玩具。
图1-5 模块化的玩具很容易重新配置,而缝合起来的玩具则极难重新配置
图1-5左侧的玩具是高度模块化的。头部、手臂、手掌和腿都很容易独立替换,而不会影响到玩具的其他部分。相反,图1-5右侧的玩具是非模块化的。没有轻松的方法可以替换头部、手臂、手掌或腿。
模块化系统(如图1-5左侧的玩具)的特征之一是,不同组件有明确定义的接口,相互作用的点尽可能少。如果我们将一只手掌当成组件,那么左侧的玩具只有一个交互点和一个简单的接口:一根钉子,以及一个与之适配的小孔。而右侧的玩具在手掌和身体其他部位之间有一个极其复杂的接口:手掌和手臂上有20多圈相互交织的线。
现在想象一下,如果我们的任务是维护这些玩具,某天经理告诉我们一个新需求:手掌上要有手指。我们更愿意应对哪一个玩具/系统?
对于左侧的玩具,我们可以制造一只新设计的手掌,轻松地替换现有的手掌。如果两周以后,经理改变了主意,我们可以恢复玩具原来的配置,而不会产生任何麻烦。
至于右侧的玩具,我们可能不得不拿出剪刀,剪掉那20多圈线,然后直接将新的手掌缝到玩具上。在这个过程中,我们可能会损坏这个玩具,如果两周后经理改变主意,我们就要同样费尽力气将玩具恢复成原有配置。
软件系统和代码库与这些玩具非常相似。将代码分解为独立模块,其中两个相邻模块的交互发生在单一位置、使用明确定义的接口,往往是很有好处的。这有助于确保代码更容易适应变化的需求,因为一项功能的变化不需要对所有地方进行大量修改。
模块化系统通常也更容易理解和推演。因为系统被分解为容易控制的小功能块,各功能块之间的交互有明确的定义和文档。这增加了代码一开始就能正常工作,并在未来持续工作的可能性——因为工程师更不容易误解代码的作用。
在第2章中,我们将了解如何创建清晰的抽象层次,这是引导我们编写出更具模块化特性的代码的一种基础技术。在第8章中,我们还将了解一系列使代码更加模块化的具体技术。
1.3.5 编写可重用、可推广的代码
可重用性和可推广性这两个概念很类似,但略有不同。
● 可重用性的含义是某个系统可在多种场景下用于解决同一个问题。手钻是一种可重用工具,因为它可以在墙、地板和天花板上钻孔。问题是相同的(需要钻一个孔),但场景不同(钻墙、钻地板和钻天花板)。
● 可推广性的含义是某个系统可用于解决多个概念相近但有细微差异的问题。手钻也是具有可推广性的工具,因为它可以用于钻孔,也可以将螺钉固定到某个物体上。制造商认识到,旋转是适用于转孔和固定螺钉的通用问题,因此它们造出可通用于这两个问题的工具。
在手钻的例子中,我们能立刻认识到这两个特性的好处。想象一下,如果我们需要4种不同的工具。
● 只能在平举状态工作的钻孔机——只能用于钻墙。
● 只能垂直向下工作的钻孔机——只能用于钻地板。
● 只能垂直向上工作的钻孔机——只能用于钻天花板。
● 用来固定螺钉的电动螺丝刀。
我们必须花很多钱购买这一套4种工具,将更多的东西带在身上,给4组电池充电——这都是浪费。幸亏有人发明了既可重用又可推广的手钻,我们只需要一种工具就能完成上述所有工作。不用猜也知道,手钻在这里又是对代码的一种类比。
创建代码需要花费时间和精力,一旦创建完毕,还需要持续投入时间和精力进行维护。创建代码也并非没有风险:尽管我们小心翼翼,编写的一些代码仍会包含缺陷,写得越多,出现缺陷的可能性越大。重点在于,我们在代码库中留下的代码行数越少越好。这听起来可能有些奇怪,我们不是通过写代码得到报酬的吗?但实际上,我们得到工资,是因为能够解决某个问题,代码只是一种手段。如果我们可以解决问题,同时花费更少的精力,降低我们不小心引入缺陷而导致其他问题的概率,就太好了。
编写可重用、可推广的代码,我们(和其他人)就可以在代码库的多个地方和场景中使用它们,解决不止一个问题。这能节约时间和精力,并使我们的代码更加可靠,因为我们往往重用已在外部经过考验的逻辑,其中的缺陷可能已经被发现和修复。
更具模块化特性的代码往往也有更好的可重用性和可推广性。与模块化相关的章节与可重用性和可推广性的主题关系紧密。此外,第9章将介绍一些提高代码可重用性、可推广性的专用技术和考虑因素。
1.3.6 编写可测试的代码并适当测试
正如我们在前面的软件开发与部署过程(见图1-2)中所见到的,在确保最终不会将有缺陷和不完善的功能投入运行的过程中,测试是至关重要的一环。它们往往是这一过程中两个关键点的主要保障(见图1-6)。
● 防止有缺陷或者不完善的功能提交到代码库。
● 确保阻止有缺陷或不完善的功能发行并投入运行。
因此,测试对确保代码可用并持续正常工作是必不可少的。
图1-6 为了最大限度地防止有缺陷和不完善的功能进入代码库,并确保它们不会对外发行,测试至关重要
在软件开发中,测试的重要性如何强调都不为过。你以前肯定多次听到这一说法,很容易将其视为老生常谈,但它确实重要。正如我们在本书的很多地方看到的那样。
● 软件系统和代码库往往太过庞大和复杂,一个人不可能了解所有细节。
● 人(即便是智力超群的工程师)都会犯错。
这或多或少都是生活中的事实。除非我们用测试来锁定代码的功能,否则这些功能就会习惯性地与我们(以及我们的代码)纠缠在一起。
代码质量的这一支柱包含两个重要的概念:“编写可测试的代码”以及“适当测试”。测试和可测试性相关,但考虑的因素不同。
● 测试——顾名思义,这与测试我们的代码或者软件有关。测试可能是人工进行,也可能是自动进行。作为工程师,我们通常努力编写测试代码来执行“真实”代码,并检查一切表现是否如同预期。测试有不同级别。你可能使用的3种最常见的测试级别如下。(请注意,这并不是完整的列表。测试有许多分类方法,不同组织往往使用不同的术语。)
❏ 单元测试——这种测试通常测试代码的小单元(如单个函数或类)。单元测试是测试工程师在日常编程中最经常使用的测试级别,也是本书唯一详细介绍的测试级别。
❏ 集成测试——系统通常由多个组件、模块或子系统组成。将这些组件和子系统连接到一起的过程称为集成。集成测试试图确保这些集成正常工作,而且一直保持正常。
❏ 端到端(E2E)测试——测试整个软件系统从头至尾的典型流程。如果待测软件是一个在线购物系统,E2E测试的一个例子是自动驱动浏览器,确保用户能够完成一次购物流程。
● 可测试性——这指的是“真实代码”(相对于测试代码),并描述该代码在测试中的表现。某个事物“可测试”的概念在子系统或系统级别上也适用。可测试性往往与模块化高度关联,模块化程度越高的代码(或系统)越容易测试。想象一下,某汽车制造商正在开发一种紧急行人防撞制动系统。如果该系统的模块化程度不高,测试它的唯一方式可能是将其安装在真实的汽车上,将车开到一个真人面前,检查车辆是否会自动停下。如果情况果真如此,那么该系统所能测试的场景有限,因为每次测试的成本非常高:制造一辆整车,租用一条测试道路,并让一个真人冒险扮演路上的行人。如果这种紧急制动系统是一个单独的模块,可在真实车辆之外运行,可测试性就更高了。现在测试可以通过如下方式进行:向该系统提供预先录制的行人走出的视频,检查它是否为紧急制动系统输出正确的信号。这样的测试非常简易、经济且安全,可以对成千上万种不同的行人状况进行测试。
如果代码不可测试,也就不可能对其进行“适当”测试了。为了确保我们编写的代码是可测试的,最好在编写代码时不断地问自己一个问题:“我们将如何测试这些代码?”因此,测试不应该是“马后炮”,而应该是编写代码各个阶段不可分割的基本组成部分。第10章和第11章介绍的都是关于测试的内容,但因为测试对编写代码必不可少,所以我们在本书的许多地方都会提到。
注意:测试驱动开发
因为测试是代码编写工作中必不可少的部分,一些工程师倡导在编写代码之前先编写测试的做法。这是测试驱动开发(Test-Driven Development,TDD)过程所支持的做法之一。我们将在10.5节中进一步讨论这个问题。
软件测试是一个很广泛的主题,坦率地说,本书无法做到面面俱到。在本书中,我们将介绍代码单元测试中重要且常被忽视的特征,因为它们在日常编程过程中通常非常有用。但请注意,直到本书的最后,我们对软件测试的介绍也只是皮毛。