第 1 章 软件质量和待解决问题
本章内容
● 从不同的视角和不同的目标来评估软件
● 区分内部软件质量和外部软件质量
● 区分功能性软件质量和非功能性软件质量
● 评估不同方面的软件质量间的关系和取舍
本书的核心思想是通过对不同方面的代码质量(又称非功能需求)进行比较,使你了解经验丰富的开发者的思维模式。这些质量大多数(例如性能或可读性)是通用的,适用于任何软件。为了强调这一事实,每一章都会重复使用相同的示例:一个用来表示水容器系统的简单的类。
本章将介绍本书涉及的软件质量以及水容器示例的规范,然后进行初步的代码实现。
1.1 软件质量
在本书中,你应该将“质量”一词理解为软件或有或无的特征,而不是其整体价值。这就是为什么我会谈论多个方面的质量。不过并非所有特征都称得上是质量。例如,编写软件所使用的开发语言无疑是该软件的特征,但不是质量。质量是可以按某种尺度进行分级的特征,至少在原则上如此。
和所有产品一样,人们最感兴趣的软件质量是能衡量系统对自身需求的满足程度的那些。不幸的是,仅仅描述(更不用说完全满足)软件的需求也并非易事。事实上,整个需求分析领域都致力于此。为什么呢?难道系统可靠、稳定地提供用户所需的服务还不够吗?
首先,用户往往不知道自己需要什么服务,他们需要时间和帮助才能明白。其次,系统要做的根本不只是满足这些需求。它们提供的服务有快有慢;有的准确、有的不准确;有的需要用户经过长时间的训练,也有的让用户一看就懂(良好设计的UI);等等。另外,随着时间的推移,你需要修改、修复或改进系统,这带来了更多质量上的变数:了解系统的内部工作原理有多容易?修改和扩展它而不破坏其他部分有多容易?这样的例子不胜枚举。
要在这众多标准中找到一些规律,专家们建议根据两类特征进行组织:内部的与外部的,功能性的与非功能性的。
1.1.1 内部质量与外部质量
最终用户在与系统交互时可以感知到外部质量,但内部质量只能通过查看源代码来评估。不过两者之间并不是泾渭分明,最终用户也可以间接感知到一些内部质量。反之亦然,所有的外部质量从根本上来说都依赖于源代码。
软件质量标准
标准化机构ISO和IEC已在1991年的9126标准中定义了软件质量,该标准在2011年被25010标准代替。
例如,可维护性(修改、修复或扩展软件的难易程度)是内部质量,但是如果一个缺陷被发现后,程序员需要花费很长时间才能修复,则最终用户就会感知到它。相反,对错误输入的稳健性通常被认为是外部质量,但是当软件(也许是一个类库)没有直接暴露给最终用户,而是仅仅与系统的其他模块交互时,这种稳健性就会成为内部质量。
1.1.2 功能性质量与非功能性质量
第二个分类方式是根据软件能做什么(功能性质量)和软件做得如何(非功能性质量)来分类(见图1-1)。“内部-外部”二分法也适用于这种分类方式:如果软件执行了某些操作,那么其影响(无论以何种方式)对最终用户是否可见?因此,所有功能性质量都是外部的。另外,非功能性质量可以是内部的,也可以是外部的,这取决于它们是与代码本身更相关,还是与外在表现更相关。接下来的几节会包含这两种类型的示例。同时请看图1-2,它将本章介绍的所有方面的质量都放在了一个二维象限中。横轴表示内部和外部的区别,纵轴表示功能性和非功能性的区别。
图1-1 功能性需求和非功能性需求从不同方面影响软件的侧重点,你需要进行取舍
图1-2 将软件质量按照两个二分法进行分类:内部与外部(横轴),功能性与非功能性(纵轴)。书中特别强调的质量在图中用粗边框来表示
下一节会介绍最终用户可以直接评估的主要软件质量。
1.2 主要的外部软件质量
软件的外部质量属于程序的可观察行为,因此自然成了软件开发过程中的核心关注点。除了将这些质量归于软件的属性之外,我还将结合一个普通的老式烤面包机来讨论这些质量,试图以最通用和直观的方式来描述它们。接下来的几节介绍了一些最重要的软件外部质量。
1.2.1 正确性
遵守既定的目标,亦称需求或规格。
对于烤面包机来说,正确性意味着它必须可以烘烤切片面包,直到面包变得金黄、酥脆为止。对于软件来说,正确性意味着它必须提供向客户承诺的功能。这就是功能性质量的定义。
正确性没有秘诀,但是大家首先会采用各种最佳实践和软件开发流程,来提高编写正确软件的可能性,以及事后发现缺陷的可能性。本书将聚焦每个开发者在工作中都可以采用的小技巧,与其公司采用的具体开发流程无关。
首先,如果开发人员对目标需求的理解不清楚,那么就不可能有正确性。第5章探讨了一个有效的方法:以契约的方式来思考需求,并采取保障措施来执行这些契约。缺陷是不可避免的,捕获它们的主要方法是模拟软件交互,即测试。第6章讨论了设计测试用例和评估其有效性的系统性方法。最后,采用代码可读性的最佳实践对正确性也是有益的,可以帮助代码作者及其同事在测试暴露错误之前和之后发现问题,从而提高正确性。第7章会介绍一些最佳实践。
1.2.2 稳健性
对错误的输入或无效(无法预料)的外部条件(例如某些资源的缺失)的容错能力。
正确性和稳健性有时被一起称为可靠性。稳健的烤面包机不会因为将百吉饼、叉子放进去,或什么都不放而着火。它也会具有防止过热等保护措施。1
1烤面包机的稳健性可不是开玩笑的。据估计,全世界每年约有700人死于与烤面包机相关的安全事故。
稳健的软件会做很多事情,比如检查输入是否有效。如果输入无效,那么它将发出错误信号并做出响应。如果错误是致命的,那么稳健的程序会在中断前尽可能多地挽救用户数据或已执行完的计算。第5章将通过加强方法契约和类不变式的严格规范和运行时监控来提高稳健性。
1.2.3 易用性
对学习如何使用软件并达到其目的所需的工作量的衡量标准;使用的方便程度。
现代的弹出式烤面包机非常易于使用,不需要用推杆将面包推入并开始烘烤,也不需要用旋钮调节烘烤量。软件的易用性和它的用户界面(UI)设计息息相关,并通过人机交互和用户体验(UX)设计等学科来解决。本书不会谈及易用性,因为本书关注的是不直接暴露给最终用户的软件系统。
1.2.4 效率
适当的资源消耗。
烤面包机的效率指的是它完成烤面包任务需要花费多长时间和电力。对软件而言,时间和空间(内存)是所有程序都需要消耗的两个资源。第3章和第4章分别讨论了时间效率和空间效率。许多程序还需要网络带宽、数据库连接和众多其他资源。不同的资源间通常需要进行权衡取舍。功率更强的烤面包机可能更快,但需要消耗更多(峰值)电力。类似地,一些程序可能更快,但需要消耗更多内存(稍后会详述)。
尽管我将效率列为外部质量,但其真正的本质还是模棱两可的。例如,最终用户能明显觉察到执行速度,尤其是在执行速度较慢的情况下。但是,其他资源的消耗,比如网络带宽,对用户并不可见,只能通过专用工具或分析源代码来评估。这也是我将效率放在图1-2中稍微靠中间位置的原因。
大多数情况下,效率属于非功能性质量,因为用户通常不关心服务的响应时间是1 ms还是2 ms,也不关心网络传输流量是1 KB还是2 KB。但在下面两种场景中,它会成为功能性质量。
● 在性能敏感的应用中:在这种情况下,保证性能是需求规范的一部分。设想一个与物理传感器和执行器进行交互的嵌入式设备,其软件的响应时间必须遵守严格的超时时间。否则,轻则导致功能的不一致,重则在工业、医疗或汽车应用中威胁生命安全。
● 当效率差到影响正常操作时:即使对于面向消费者的、没有那么关键的程序,用户对响应延迟和内存占用的容忍度也是有限的。如果超过了这个限度,效率不足就会上升为一个功能性缺陷。
1.3 主要的内部软件质量
查看程序的源代码比运行它能更好地评估其内部质量。接下来的几节介绍了一些最重要的内部质量。
1.3.1 可读性
对其他开发者来说清晰易懂。
谈论烤面包机的可读性似乎有些奇怪,不过要意识到,对于所有的内部质量,我们讨论的其实都是结构和设计。事实上,软件质量的相关国际标准将这个特征称为可分析性。所以,可读性良好的烤面包机在被打开检查时,是很容易分析的:它有清晰的内部布局,加热原件和电子设备进行了很好的分离,电源电路和定时器很易于识别,等等。
可读性良好的程序很容易被其他程序员理解,或者其作者过了一段时间再回头看时还能理解。可读性是极其重要的,而且其价值经常被低估。第7章将介绍这个主题。
1.3.2 可复用性
复用代码来解决类似问题的难易程度,以及所需的改动量,又称为适应性。
如果制造烤面包机的公司能够将其设计和零件用于制造其他电器,那么你可以认为这款烤面包机是可复用的。例如,它的电源线很可能是标准的,因此可以和类似的小型电器兼容;也许它的定时器可以被用在微波炉中;等等。
在历史上,代码复用是面向对象(object-oriented,OO)编程范式的一大亮点。经验证明,使用大量可复用的软件组件来构建复杂系统的愿景被夸大了。相反,现代编程趋势更喜欢专为可复用性而设计的库和框架。在这些库和框架之上,是一层不那么薄的、不考虑可复用性的应用相关代码。第9章会介绍可复用性。
1.3.3 可测试性
为程序编写测试的能力,以及编写测试是否容易。它能够触发所有相关的程序行为,并观察其结果。
在讨论烤面包机的可测试性之前,让我们尝试弄清楚对烤面包机的测试大概是什么样子的。2一个合理的测试程序会将温度计插入插槽,并开始烘烤。你可以通过观察经过一段时间后温度是否十分接近预设值来判断成功与否。可测试的烤面包机使此过程易于重复执行和自动执行,尽可能不需要人工干预。例如,通过按下按钮启动的烤面包机比需要拉动操纵杆的烤面包机更容易测试,因为对于机器来说,按下按钮比拉动操纵杆要更容易。
2根据一些报道,“如何测试烤面包机”是软件工程的工作面试中反复出现的问题。
可测试的代码提供一个API,允许调用者验证所有期望的行为。例如,与有返回值的方法相比,void方法(又称为过程)的可测试性更低。第6章会介绍测试技术和可测试性。
1.3.4 可维护性
易于发现和修复bug,以及改进软件。
可维护的烤面包机易于拆卸和维修。它的原理图可以轻易获得,并且组件是可以更换的。类似地,可维护的软件是可读且模块化的,不同模块具有明确定义的职责,并以明确定义的方式进行交互。第6章和第7章讨论的可测试性和可读性是保证可维护性的主要因素。
FURPS模型
具有浓厚技术传统的大公司为它们的软件开发过程制定了自己的质量模型。例如,惠普公司开发了著名的FURPS模型,将软件特征分成了五类:功能性(functionality)、易用性(usability)、可靠性(reliability)、性能(performance)和可支持性(supportability)。
1.4 软件质量之间的关系
某些方面的软件质量代表了截然不同的目标,而另一些则相辅相成。结果就是对所有工程专业而言都不陌生的取舍行为。数学家给这类问题起了个名字:多准则优化(multi-criteria optimization),即针对多个相互竞争的质量标准找到最佳解决方案。与抽象的数学问题不同,软件质量可能无法量化(试想一下可读性)。幸运的是,你并不需要找到真正的最佳解决方案,只需一个足以满足目标的解决方案即可。
表1-1总结了本书所考量的四种质量之间的关系。时间效率和空间效率都可能会妨碍可读性,追求最佳的性能会牺牲抽象能力并需要编写较底层的代码。在Java中,这可能意味着需要使用基本类型而不是对象,使用普通数组而不是集合(collection),或者在极端情况下使用较底层的语言(例如C)编写对性能要求苛刻的部分并使用Java本地接口(Java Native Interface)将它们与主程序相连接。
表1-1 代码质量之间的典型关系:“↓”代表“不利于”,“-”代表“无影响”。本表受到《代码大全》中图20-1的启发(见1.10节)
追求尽可能少地使用内存也会导致使用基本类型以及一些难以理解的代码,比如通过使用单个值表示不同事物来节省空间。(你将在4.4节中看到一个例子。)这些技术都会牺牲可读性,从而牺牲可维护性。相反,可读性高的代码会使用更多的临时变量和支持方法,从而避免了为提高性能而编写底层代码。
时间效率和空间效率也相互冲突。例如,提高性能的常用策略是将一些额外的信息存储在内存中,而不是每次需要时都对其进行计算。一个典型的例子是单向链表和双向链表之间的区别。即使原则上可以通过遍历整个链表来计算每个节点的“上一个节点”,但是存储和维护双向链接可以让删除任意节点保持常数时间复杂度。4.4节中的例子就是以增加运行时间来换取更高的空间效率。
要追求稳健性最大化,就需要添加代码来检查异常情况并以适当的方式进行处理。这种检查会产生性能开销,不过通常非常有限。空间效率则不会受到任何影响。同样,原则上,追求稳健性也不应该降低可读性。
软件指标
软件质量与软件指标(metrics)息息相关,后者是软件的可量化属性。学术界已经提出了数百种指标度量标准,其中最常见的两个是代码行数(LOC)和圈复杂度(对嵌套和分支总量的度量)。这些指标提供了评估和监控项目的客观方法,旨在支持与项目开发相关的决策。例如,圈复杂度高的方法可能需要更多的测试工作。
现代的IDE可以原生地或通过插件自动计算常见的软件指标。这些指标的相对优势、它们与本章所述软件质量的关系,以及它们的有效用法是软件工程中争议很大的话题。第6章会用到代码覆盖率指标。
有一股力量与这些软件质量都无法共存,那就是开发时间。业务原因推动人们快速地编写软件,但最大限度地提高软件质量则需要花费大量的精力和时间。即使管理层很能理解“精心设计的软件能给未来带来收益”,评估究竟需要多少时间才能获得高质量的结果也依然很棘手。各种各样的开发流程为该问题提出了许多解决方案,其中一些主张使用上面提到的软件指标。
本书不涉及软件开发过程的辩论(有时称其为“战争”更合适),而是专注于那些对“有固定API的单个类”组成的小型软件单元仍然有意义的软件质量,包括时间效率和空间效率,以及可靠性、可读性和通用性。本书不会涉及易用性或安全性等其他方面的软件质量。
1.5 特殊的质量
除了前面各节描述的质量属性外,我们还将探讨类的两个属性:线程安全和简洁性。
1.5.1 线程安全
类在多线程环境中正常工作的能力。
线程安全并不是通常意义上的软件质量,因为它仅适用于多线程程序这个特定的上下文。尽管如此,这样的上下文已经变得无处不在,而且线程同步问题非常棘手,以至于了解基本的并发原语是任何程序员都应该掌握的一项宝贵的技能。
线程安全很容易被归类为内部质量,但这是一个错误。确实,用户并不知道程序是顺序执行的还是多线程的。在多线程编程领域,线程安全是正确性的基本前提,所以它显然是个质量因素。顺便说一句,线程安全问题在表象上的随机性以及不易重现性,往往会导致一些极难发现的错误。这就是图1-2将线程安全与正确性和稳健性放在同一区域中的原因。第8章致力于确保线程安全,同时避免常见的并发陷阱。
1.5.2 简洁性
为给定任务编写尽可能短的程序。
通常意义上来说,简洁性根本不是代码质量。相反,它容易导致糟糕、晦涩的代码。附录A中有一个趣味练习,它挑战了语言的极限,也挑战了你的Java(或你选择的任何编程语言)知识。
尽管如此,你仍然可以找到以简洁为目标的实用场景。手机和信用卡中的智能卡等低端嵌入式系统可能由于配备的内存太少,以至于程序不仅必须在运行时占用很少的内存,而且在持久化的存储器中存储时只能占用很小的空间。确实,如今大多数智能卡只有4 KB的RAM和512 KB的持久化存储空间。在这种情况下,控制字节码指令的数量就成为一个重要问题,而减少源代码可以缓解这个问题。
1.6 演进示例:水容器系统
本节描述你将在本书其余部分中反复解决的编程问题,每次都针对不同的软件质量目标。你将先学习所需的API,然后了解一个简单的用例和初步实现。
假设你需要为一个新的社交网络实现核心基础框架。人们可以注册,当然也可以彼此联系。连接是对称的,也就是说,如果我与你建立了连接,那么你将自动与我建立连接,就像Facebook那样。并且,该网络的一项特殊功能是用户可以向所有与其连接(不论直接或间接)的用户发送消息。本书将介绍此场景的基本功能,并将其置于更简单的背景中,在这里我们不必关心消息的内容或人员的属性。
你在这里要处理的不是人员,而是一组水容器。假定它们完全相同,并且容量是无限的。在任何时间,一个容器可容纳一定量的水,任何两个容器都可以通过管道永久连接。你可以将水倒入容器,或从容器中取水(代替发送消息)。无论何时连接两个或多个容器,它们都将成为连通容器。一旦连通,它们会将其中的水均分。
1.6.1 API
本节描述水容器所需的API。至少需要构建一个Container类,并为其赋予一个不带任何参数的公有构造函数。该构造函数创建一个空容器。这个类还拥有以下三个方法。
● public double getAmount():返回此容器中的当前水量。
● public void connectTo(Container other):将此容器永久连接到另一个容器(other)。
● public void addWater(double amount):将一定量的水(amount)倒入此容器中。此方法在所有直接或间接连接到该容器的容器间自动均分其中的水。
你也可以在使用此方法时传入负数,从该容器中取出水。在这种情况下,一组相连的容器应有足够的水以满足要求(你不希望在容器中留存的水量变为负数)。
接下来几章中介绍的大部分实现完全符合这个API,除了几个明确标明的例外情况。在这些例外情况中,我调整了API来帮助优化某个特定方面的软件质量。
两个容器之间的连接是对称的:水可以来回流动。一组通过对称链接来连接的容器形成了计算机科学中所谓的无向图。请参考下面的资料以了解有关无向图的基本概念。
无向图
在计算机科学中,由成对连接的项组成的网络称为图(如图1-3所示)。图中的项也称为节点,其连接称为边。如果连接是对称的,则该图称为无向图,因为连接没有特定的方向。直接或间接连接的一组节点称为连通分量(connected component)。在本书中,最大的连通分量简称为组。
图1-3 计算机科学中图的要素
要在水容器方案中实现恰当的addWater方法,需要知道已连通的分量,因为必须在所有已连接的容器之间平均分配(或移除)水。实际上,该场景背后的主要算法问题是在创建节点(new Container)和插入边(connectTo方法)时维护连通分量的信息,这是图的动态连通性问题。
此类问题是许多涉及网络的应用程序的核心:在社交网络中,连通分量代表一组因有朋友关系而联系在一起的人;在图像处理中,相同颜色像素的相连(在相邻的意义上)区域有助于识别场景中的对象;在计算机网络中,发现和维护连通分量是路由的一个基本步骤。第9章将探讨此类问题的一个具体应用。
1.6.2 用例
本节介绍一个简单的用例,它体现了上一节描述的API。你将创建四个容器,向其中两个容器中加一些水,然后逐步将它们连接起来,直到它们形成一个组(见图1-4)。在这个初步的例子中,会先放入水,然后再将容器连接起来。一般来说,可以自由交错地执行这两个操作。而且,可以在任何时候创建新的容器。
图1-4 用例的四个步骤:从四个独立的空容器到一个相互连接的容器组
我将用例(在线代码库中的UseCase类)分为四部分,这样就可以很容易地在其他章节中参考具体的点,并研究不同的实现如何满足相同的需求。这四个步骤如图1-4所示。在第一部分中,只需创建四个容器,如下面的代码片段所示。最初,它们是空的、孤立的(没有连接)。
Container a = new Container(); Container b = new Container(); Container c = new Container(); Container d = new Container();
接下来,向第一个和最后一个容器中加入水,并将前两个容器用管道连接起来。最后,把每个容器中的水量打印到屏幕上,来检查是否一切都是按需求规范进行的。
a.addWater(12); d.addWater(8); a.connectTo(b); System.out.println(a.getAmount()+" "+b.getAmount()+" "+ c.getAmount()+" "+d.getAmount());
在上面代码片段的结尾,容器a和容器b是连在一起的,所以它们共享你放进a的水,而容器c和容器d是隔离的。下面是println的期望输出。
6.0 6.0 0.0 8.0
让我们继续,将c连接到b,检查添加一个新的连接是否会自动将水在所有连接的容器中重新分配。
b.connectTo(c); System.out.println(a.getAmount()+" "+b.getAmount()+" "+ c.getAmount()+" "+d.getAmount());
这时,c与b相连,并间接地与a相连。此时a、b和c都是相互连通的容器,所有容器中的水的总量在它们之间平均分配。容器d不受影响,导致了如下输出。
4.0 4.0 4.0 8.0
要特别注意用例中的当前点,因为在接下来的章节中,我们将用它作为一个标准的场景来展示不同的实现如何在内存中表示相同的情况。
最后,将d连接到b,使所有的容器形成一个连接组。
b.connectTo(d); System.out.println(a.getAmount()+" "+b.getAmount()+" "+ c.getAmount()+" "+d.getAmount());
因此,在最后的输出中,所有容器的水量是相等的。
5.0 5.0 5.0 5.0
1.7 数据的模型和表示
现在已经明确知道了水容器类的需求,可以开始设计一个实际的实现了。需求规范中已经定义了公有API,所以下一步就是确定每个Container对象需要哪些字段,可能还有类本身(又名静态字段)需要的字段。后面章节中的例子表明,根据所追求的质量目标,可以选择大量不同的字段,数量之多令人惊讶。本节将介绍一些通用的观察结果,无论具体的质量目标是什么,这些观察结果都是适用的。
首先,对象必须包含足够的信息,以提供需求规范所要求的服务。一旦满足了这个基本要求,就还有两类决定要做。
(1) 是否要存储任何额外的信息,即使不是严格意义上的必要信息?
(2) 如何对所有要存储的信息进行编码?哪些数据类型或结构是最合适的?又由哪个(些)对象来负责?
关于问题(1),想存储一些不必要的信息可能出于两个原因。首先是为了提高性能。在这种情况下,也可以从其他字段中计算这些信息,但更希望这些信息是已准备好的,因为计算信息比维护它更昂贵。想想看,一个链表会把它的长度存储在一个字段中,即使这个信息可以通过遍历链表并计算节点的数量来即时计算。其次,有时会存储额外的信息,为将来的扩展留下余地。1.7.2节中有一个这样的例子。
一旦确定了要存储什么信息,就该通过给类和对象指定适当的字段类型来回答问题(2)了。即使是在像水容器这样相对简单的场景中,这一步可能也并非小事。正如整本书试图证明的那样,可能存在着几种相互竞争的解决方案,这些解决方案在不同的上下文中,以及考虑不同的质量目标时都是有效的。
在我们的场景中,一个容器当前状态信息的描述由两个方面组成:容器中的水量,以及它和其他容器的连接。接下来的两节将分别讨论这两个方面。
1.7.1 存储水量
首先,getAmount方法存在的前提是需要容器“知道”它们中的水量。我所说的“知道”,并不是说一定要将这些信息储存在容器中。现在谈这个还为时过早。我的意思是容器应该有某种方式来计算并返回这个值。此外,API规定了水量必须用double(双精度)来表示。一个很自然的实现是在每个容器中真正包含一个double类型的水量字段。仔细观察一下就会发现,一组相连容器的每一个容器中的水量是一样的。因此,最好将这些容器中的水量只存储一次,可以存储在一个独立的表示一组容器的对象中。这样一来,当调用addWater时,只需要更新一个对象就可以了,即使当前容器与许多其他容器相连。
最后,除了使用一个独立的对象,还可以将容器组的水量存储在其中一个特殊的容器(作为其容器组的代表)中。总结一下,目前为止至少有三种可行的方法。
(1) 每个容器都持有一个最新的“水量”字段。
(2) 一个独立的“容器组”对象持有这个“水量”字段。
(3) 每个组中只有一个容器(代表)持有最新的“水量”值,该值适用于该组中的所有容器。
在下面的章节中,不同的实现将分别使用这三种方式(以及一些额外的方式),我们将详细讨论每种方式的优劣。
1.7.2 存储连接
向容器中加水时,水必须被平均分配到所有与该容器(直接或间接)相连的容器中。因此,每个容器必须能够识别所有与它相连的容器。一个重要的决定是如何区分直接连接和间接连接。a和b之间的直接连接只能通过调用a.connectTo(b)或b.connectTo(a)来建立,而间接连接则是直接连接的结果3。
3在数学术语中,间接连接对应于直接连接的传递闭包。
选择要存储的信息
我们的需求规范要求的操作没有区分直接连接和间接连接,所以可以只存储更通用的:间接连接。但是,假设在未来的某个时候,希望添加一个disconnectFrom的操作,其意图是撤销之前的connectTo操作。如果没有把直接连接和间接连接加以区分,就不可能正确实现disconnectFrom方法。
事实上,考虑一下图1-5所示的两种情况,直接连接用容器间的连线来表示。如果只在内存中存储间接连接,那么这两种情况是无法区分的:在这两种情况下,所有的容器都是相互连接的。因此,在这两种情况下,如果执行一系列顺序相同的操作,那么它们必然会有同样的反应。此外,考虑一下如果客户端执行以下操作,则会发生什么情况。
a.disconnectFrom(b); a.addWater(1);
如果在第一种情况下(见图1-5左图)执行这两行代码,三个容器仍然是相连的,所以增加的水量必然会被平均分配给所有的容器。相反,在第二种情况下(见图1-5右图),断开a与b的连接,会使容器a被隔离,所以增加的水必然只会加到a中。由此可见,只存储间接连接并不能兼容未来的disconnectFrom操作。
图1-5 两种“三个容器”的场景。容器间的连线表示直接连接
总结一下,如果认为未来可能会增加disconnectFrom的操作,那么可能就需要将直接连接与间接连接明确地分开存储。但是,如果不知道关于该软件未来演进方向的具体信息,就应该警惕这种诱惑。众所周知,程序员容易过度泛化,他们往往更多的是权衡假设性的利益,而不是随之而来的某些代价。考虑到一个新功能的成本并不仅限于开发的时间,因为每个不必要的类成员都需要像其他必要的类成员一样进行测试、编写文档和维护。
另外,对于可能想增加的额外信息的数量则没有限制。如果以后想删除所有超过一小时的连接怎么办?应该存储每个连接的建立时间!如果想知道有多少个线程创建了连接,该怎么办?应该存储所有曾经创建过连接的线程的set 4,等等。在下面的章节中,我一般会坚持只存储目前需要的信息5,有几个明确标注的例外。
选择表达方式
最后,假设只满足于存储间接连接,下一步就是为它们挑选一个实际的表示方式。在这一点上,初步有两个选择:一是显式使用一个新的类(比如叫Pipe),来表示两个容器之间的连接;二是直接在容器对象内部存储相应的信息(隐式表示)。
第一种选择更符合正统的OO设计。在现实世界中,容器是由管道连接起来的,而管道是真实的物体,与容器有明显的区别。因此,按理说,它们应该分开建模。不过,本章的规范中并没有提到任何Pipe对象,所以它们可以仍旧隐藏在容器中,不被客户端所感知。此外,更重要的是,这些管道对象包含很少的行为。每个管道对象将持有两个相连容器的引用,没有其他属性和重要的方法。
在权衡了这些原因后,似乎引入这个额外的类的好处并不大,所以还不如选择实用的、隐式的方案,完全避免引入这个额外的类。容器无须使用专门的“管道”对象就可以访问它们的同伴。但是,到底要如何组织相连容器的引用呢?语言内核及其API提供了多种解决方案:普通数组、列表、set。这里就不分析了,因为其中很多都会在下面的章节(尤其是第4章和第5章)针对不同的代码质量进行优化时自然而然地出现。
4为了与collection(本书中译为“集合”)区分,set不做翻译。特殊名词中的set除外,如整数集(set of integers)和多重集(multi-set)。——编者注
5极限编程运动已为此原则取了一个“你不需要它”(You aren't gonna need it,YAGNI)的口号。
1.8 你好,容器(Novice)
从本节开始,我们将考虑一个Container的实现,这个实现可以由一个接触过C语言等结构化语言后刚刚接触Java的、没什么编程经验的程序员来编写。这个类是整本书中你会遇到的众多版本中的第一个。我给每个版本起了一个名字,以帮助浏览和比较它们。这个版本的名字是Novice,它在代码库中的全称是eis.chapter1.novice.Container。
1.8.1 字段和构造函数
即使是经验丰富的专业人士,在某个时刻也曾是初学者,在新语言的语法中摸爬滚打,对隐藏在角落里的众多API并不了解。起初,可以选择数组这种数据结构,但解决语法错误的要求太高,以至于不能考虑什么编码风格的问题。经过一番试错后,初学编程的人拼凑出了一个类,可以编译通过,而且似乎还能满足需求。也许开始时候的代码有点儿像代码清单1-1所示的那样。
代码清单1-1 Novice:字段和构造函数
public class Container { Container[] g; ❶ 一组相连的容器 int n; ❷ 容器组的实际大小 double x; ❸ 该容器中的水量 public Container() { g = new Container[1000]; ❹ 注意:这是一个魔法数 g[0] = this; ❺ 将该容器放入容器组中 n = 1; x = 0; }
这几行代码包含大量的轻微和不太轻微的缺陷。让我们把重点放在那些容易修复的表面缺陷上,因为其他的缺陷在随后章的版本中会逐渐浮现出来。
这三个实例字段的用途如下。
● g是一个数组,用于保存连接到这个容器的所有容器,包括当前容器(在构造函数中可以看出)。
● n为g中的容器数量。
● x是该容器中的水量。
唯一明显让这段代码显得业余的地方是选择的变量名:非常短,而且完全没有表达出应有的信息。即使一个专家被犯罪分子要挟用60 s的时间“黑”进一个超级安全的水容器系统,他也不会给一个组起名为g的。玩笑归玩笑,有意义的命名是代码可读性的首要原则,第7章会讨论可读性。
然后就是可见性问题。字段应该是私有的(private),而不是默认的(default)。回想一下,默认可见性比私有性更开放;它允许同一包中的其他类访问。信息隐藏(又名封装)是一个基本的OO原则,它使类可以不用关心其他类的内部实现,并通过一个定义良好的公有接口(一种分离关注点的形式)与它们交互。这进而使得类可以修改其内部实现,而不影响已有的客户端。
关注点分离的原则也为本书提供了基础。以下各章介绍的许多实现都符合同样的公有API,因此,客户端原则上可以互换使用各个版本的实现。使用这种方法,API的每一个实现细节对外部都是不可见的,这要归功于可见性标识符。从更深的层面来看,单独优化不同方面的软件质量本身就是一种极端的关注点分离。它过于极端了,事实上只是一种说教的工具,而不应该是在实践中追求的方法。
继续往下看,如代码清单1-1中的第六行代码所示,数组的大小由一个所谓的魔法数(magic number)定义,即一个没有被赋予任何名称的常数。最佳实践要求你把所有的常量分配给某个final变量,一来变量的名字可以表示这个常量的含义,二来把这个常量的赋值集中在单个点上,如果多次使用这个常量,那么这一点特别有用。
这里选择使用普通数组并不是很合适,因为它对连接的容器的最大数量有一个预先确定的边界:如果边界太小,那么程序必然会失败;太大的边界又会浪费空间。此外,使用数组迫使我们不得不手动跟踪组中实际的容器数量(此处为字段n)。在Java API中还有更好的选择,将在第2章中讨论。尽管如此,普通数组也将在第5章中派上用场,那里的主要目标是节省空间。
1.8.2 getAmount和addWater方法
接下来看看前两个方法的源代码,如代码清单1-2所示。
代码清单1-2 Novice:getAmount和addWater方法
public double getAmount() { return x; } public void addWater(double x){ double y = x / n; for (int i=0; i<n; i++) g[i].x = g[i].x + y; }
getAmount只是一个简单的getter,addWater则显示了变量x和y的常见命名问题,而i作为数组索引的传统名称是可以接受的。如果代码清单的最后一行使用+=运算符,就不会重复g[i].x两次,也就不必来回查看,以确保语句实际上是在递增同一个变量。
注意,addWater方法没有检查它的参数是否为负值。在这种情况下,表示并没有考虑容器组是否有足够的水量。像这样的稳健性问题,将在第6章中专门讨论。
1.8.3 connectTo方法
最后,我们的新手程序员实现了connectTo方法,它的任务是用一个新的连接合并两组容器。在这个操作之后,两组中的所有容器都会持有相同的水量,因为它们都成了连通器。首先,该方法将计算出两组中的总水量和两组中容器的总数。合并之后,每个容器的水量,就是简单地用前者除以后者。
还需要更新两个组中所有容器的数组。一种朴素的方法是将第二组中的所有容器附加到属于第一组的所有数组,反之亦然。代码清单1-3就是这样做的,使用了两个嵌套循环。最后,该方法更新了所有受影响的容器的大小字段n和水量字段x。
代码清单1-3 Novice:connectTo方法
public void connectTo(Container c) { double z = (x*n + c.x*c.n) / (n + c.n); ❶ 合并后,每个容器的水量 for (int i=0; i<n; i++) ❷ 遍历第一组中的每个容器 for (int j=0; j<c.n; j++) { ❸ 遍历第二组中的每个容器 g[i].g[n+j] = c.g[j]; ❹ 将c.g[j]添加到g[i]组中 c.g[j].g[c.n+i] = g[i]; ❺ 将g[i]添加到c.g[j]组中 } n += c.n; for (int i=0; i<n; i++) { ❻ 更新大小和水量 g[i].n = n; g[i].x = z; } }
如你所见,connectTo方法是命名问题最严重的地方。所有这些单字母的名字很难让人理解。为了进行明显的比较,你可能会想先跳过,去看一下第7章中的可读性优化的版本。
如果用增强型for循环(C#中的foreach语句)替换掉三个for循环,可读性也会有所改善,但基于固定大小数组的表示方式使其有点儿麻烦。确实如此,想象一下,用下面的语句替换代码清单1-3中的最后一个循环。
for (Container c: g){ c.n = n; c.x = z; }
这个新的循环当然可读性更强,但是一旦c变量超出了实际存储容器引用的数组单元格(cell)6,就会出现NullPointerException。补救方法很简单,只要检测到一个null引用,就立即退出循环。
6本书中数组的cell统一翻译为“单元格”,从而在某些上下文中和“元素”(element)等进行区分。——译者注
for (Container c: g){ if (c==null) break; c.n = n; c.x = z; }
尽管完全不可读,但代码清单1-3中的connectTo方法在逻辑上是正确的,只是有一些限制。事实上,思考一下this和c在方法调用之前就已经相连的情况。更具体地说,假设下面的用例,涉及两个全新的容器。
a.connectTo(b); a.connectTo(b);
你能看出会发生什么吗?方法能容忍调用者的这种轻微失误吗?请在继续阅读之前仔细思考一下。我会等着你。
答案是,连接两个已经连接的容器会破坏它们的状态。容器a的容器组数组中最后会有两个指向自己的引用和两个指向b的引用,并且大小字段n是4而不是2。类似的事情也会发生在b上。更糟糕的是,即使this(当前容器)和c只是间接连接,也会出现这种缺陷,这不能被认为是调用者的使用不当。我说的情况如下所示(再强调一次,a、b和c是三个全新的容器)。
a.connectTo(b); b.connectTo(c); c.connectTo(a);
在最后一行代码之前,容器a和c已经连接起来了,尽管是间接的(见图1-5右图)。最后一行代码增加了它们之间的直接连接,根据需求规范,这是有效的。这导致了图1-5左图所示的情况。但是代码清单1-3中的connectTo方法却给所有容器组数组添加了所有三个容器的第二个副本,同时错误地将所有组的大小设置为6而不是3。
此实现的另一个明显缺陷是,如果合并后的组中包含超过1000个成员(那个魔法数),则代码清单1-3这两行的其中之一:
g[i].g[n+j] = c.g[j]; c.g[j].g[c.n+i] = g[i];
会抛出一个ArrayIndexOutOfBoundsException异常,并导致程序崩溃。
下一章将介绍一个参考的实现,它解决了这里指出的大部分表面问题,同时在不同方面的代码质量之间取得了平衡。
1.9 小结
● 可以将软件质量分为内部软件质量和外部软件质量,也可以分为功能性软件质量和非功能性软件质量。
● 有些方面的软件质量是相互对立的,有些则是相辅相成的。
● 本书以一个水容器系统作为统一的示例来探讨软件质量。
1.10 扩展阅读
本书试图将各种不同的主题浓缩进二三百页里,而这些主题很少被放在一起来讲解。因此,每个主题只能浅尝辄止。这就是为什么每一章的结尾都会提供一个简短的资源列表,你可以参考本节的内容,以了解更多关于本章内容的信息。
Steve McConnell的《代码大全》
一本关于编码风格和优秀软件方方面面的宝贵图书,也讨论了各种代码质量及其关系。
Diomidis Spinellis的《代码质量》
它会带你体验一次质量属性的旅程。与我们在本书中看到的不一样,它有一个几乎相反的指导原则:没有使用单个演进示例,而是使用了取自各种流行开源项目的大量代码片段。
Stephen H. Kan的《软件质量工程的度量与模型》
Kan提供了一个系统、深入的软件指标的处理方法,包括使用统计学上的合理方法来评估,并利用它们来监控和管理软件开发流程。
Christopher W. H. Davis的《敏捷度量实战:如何度量并改进团队绩效》
该书第8章讨论了软件质量和可以使用的评估指标。