1.3 宝贵教训
当20世纪70年代末学术界开始对对象思想反思的时候,很多蹩脚的软件已经开发出来了,多到这些蹩脚软件中存在的通用模式已经非常明显了。很多论文和文档由此而来,例如1968年Dijkstra的经典文档:“Go To Statement Considered Harmful”。这些模式代表了从软件开发中获得的惨痛教训(其中有一些是刚刚触及)。
1.3.1 全局数据
持久状态变量用于记录系统的状态,当它们为全局可访问的时候经常会在意想不到的时间或者以意想不到的方式被修改,这是一个很基本的问题。如果写代码的时候假定了一个特定的状态,但是当代码执行的时候该假定状态没有达到,就会导致错误。
这样的问题在实践中很难诊断,因为它们往往是间歇性的。这中间还有一个维护的问题,当一些新的条件必须增加的时候,很难找齐数据被写入的所有地方,所以不相关的维护往往会导致新缺陷的引入。这个问题非常严重,以至于在20世纪70年代末期,每一个3GL的IDE都具有某种形式的查找/浏览功能,方便用户找到一个变量存在的所有位置。虽然对于开发人员来说,这样的功能提供了方便,但是该功能并不能真正有效地帮助管理状态数据。该问题非常显著,因此函数式编程范式被开发出来,与OO范式一样,很大程度上是为了解决状态数据持久性问题。
1.3.2 巨程序单元
之前有一个人问自己的同事某个特定编译器错误产生的原因。该同事之前没有见过这个信息,但是听起来很像编译器堆栈溢出的错误,于是他就去看了代码。出现错误的过程有1800行那么长,在同一个文件中,这样的过程有很多个。仅仅打印编译列表就用了半盒纸!由于编译器进行的是文件层的优化,因此问题非常难以思考和拆分。
当这位同事看到程序的长度时,他建议说最基本的解决方法就是“不要做这些”。这时,代码的作者花了10分钟痛斥这个编译器是多么糟糕,竟然不允许他用最可读的方式写代码,例如,长达十页的嵌套switch语句。最可悲的是这件事情发生在1985年,当时的人应该对程序了解更多。
大的程序模块会导致程序缺少内聚性。一个模块做了太多的事情,在模块内部,这些事情相互之间还存在紧密的关系。这会导致模块很难维护,因为一个功能的变更与模块中其他的功能之间不是隔离的。另一个常见的问题是抽象层次的混杂。这样的模块混合了高层次功能与低层次功能。这样导致高层次的处理被低层次的细节所掩盖,出现一个典型的“森林和树”的问题。
最糟糕的问题也许是模块越大,它与其他模块的交互往往越多,那么产生负作用的可能性就越大。所有开发具有可维护性代码的现代方法都主张缩小范围,范围的扩大会导致副作用。限制范围最简单的方法就是保持小模块。
1.3.3 软件结构
“架构”这个词20世纪70年代出现于软件文化当中,因为人们开始逐渐意识到软件应用与建筑或土豚一样具有骨架。关于结构的惨痛教训是,变更它通常需要大量工作。因为应用中有太多的其他内容挂在该结构之上。
有以下四种现象可以表明应用中的结构正在发生变化:
1)有很多小的变更;
2)变更横跨许多程序单元;
3)很难变更(例如:层次结构需要重新设计);
4)变更比以往更容易引入新缺陷、造成返工。
这些变更通常称为“重组猎枪”,在黑客时代存在大量这类变更。
1.3.4 缺乏内聚性
缺乏内聚性一个明显的早期现象是,当需求变更时许多模块都需要进行变更。其原因之一在于基本结构修改了,缺乏内聚性是另一个原因。功能分散到很多模块中实现,当该功能的需求变更时,所有相关的模块都会涉及。缺乏内聚性将会导致程序难以修改,因为相关的功能不能定位。因为不同的模块实现了一个给定功能的不同部分,所以这些实现还以一种特定的顺序依赖于其他的模块来实现该功能。尽管事发之后通常很容易判断出应用的内聚性很差,但是在应用开发的早期并没有一种系统化的实践能够很好地保证内聚性。
1.3.5 耦合
当前有很多书讨论耦合以及如何处理耦合,因此这个主题不在本书的实践范围之内。在执行摘要层次,耦合描述了程序元素之间密切联系的频率、性质和方向。逻辑耦合概念产生于对以下不同现象的协调:
“复杂管线”代码难以维护。
跨模块的耦合会导致它们之间的实现依赖。
如果客户端通过紧密关系了解服务器,那么当服务器变更时,客户端往往也需要随之变更。
如果服务器通过紧密关系了解客户端,那么当客户端变更时,服务器往往也需要随之变更。
对于两个元素同时进行变更的要求与它们之间的互相访问的性质成正比。
对于两个元素同时进行变更的要求与它们之间的互相访问的密切程度成正比。
双向的密切联系往往比单向的更糟糕。
耦合的概念由此而来,根据模块程序单元之间的交互和合作,来描述模块之间的依赖关系。然而,为了使程序运转,它的元素之间必须进行某种程度的交互。于是,开发人员往往会面临类似于热力学三定律的问题:1)不能取胜;2)不能暂停;3)不能
退出。
在考虑耦合的时候,区分逻辑耦合和物理耦合很重要。逻辑耦合描述了程序元素基于在问题解决方案中所扮演的角色,如何与其他程序元素建立关系。物理耦合从编译器的角度,描述了程序元素为了进行交互需要彼此了解哪些内容。在某种程度上,逻辑耦合是不可避免的,因为问题的解决依赖于此。但是逻辑耦合度可以通过良好的设计实践尽量
降低。
然而,物理耦合几乎完全来自硬件计算模型约束下实现3GL型系统的实际问题。为了使编译器能够在一个模块中对于处理过程产生正确的机器码,也就是说,数据通过过程调用从另一个模块传递过来,编译器需要知道数据如何由调用者实现(例如,整数与浮点数)。否则,编译器没有办法使用正确的ALU指令来处理它。所以编译器需要了解调用模块的实现,也就是由特定硬件确定的有关实现的物理依赖关系。这种耦合在“编译全世界”中表现得最为明显,尽管只有一个简单的变更,但是大应用中的许多模块都需要重新进行编译。
遗憾的是,人们之前关注的焦点一直是关于访问频率的。到了20世纪80年代,一些深入的思考开始担心耦合的问题。人们注意到,当模块依赖图是一个无环路有向图时,程序往往更加容易维护。人们还注意到,当编译时间和配置管理成为主要的问题时,那些非常大的应用就会变得不可维护。如何在程序元素之间减少和控制物理依赖是一项巨大的挑战。处理该问题的技术通常被称为依赖管理。
迄今为止,文献很少涉及耦合的性质。由于耦合的性质在判断一些MDB实践时扮演重要的角色,因此在此有必要确认耦合性质的基本分类方法(即,按照联系的密切程度),以下按照密切程度增加的顺序进行论述。
仅依赖于消息标识符:它是一则纯粹的消息,没有数据也没有行为。这种依赖导致搬石头砸到脚的机会是很小的,然而即使是这样朴素的形式,当消息到达的位置不对或者出现的时间不对时,仍然会造成问题。
按值传递数据:这种形式仍然是较为良好的,因为在这种形式下,没有影响消息发送方的方式,接收方对数据的处理可以全权控制。比起纯粹的消息,这种形式的缺点在于,当处理消息时,该数据在程序的上下文中可能已经不正确了。这种情况通常存在于异步或者分布式环境中:消息发送和处理有可能存在延时。在并行处理的线程应用中也有可能存在这样的问题。
按引用传递数据:这种情况下会产生新的问题,接收方能在发送方不知情的情况下修改发送方正在使用的数据。数据的完整性变成了一个严重的问题。实际上,按引用传递数据加剧了并行处理环境中的问题,因为接收方(或者发送者通过引用对其进行了数据传递的任一方)能够在发送方使用数据的时候改写它。
按值传递行为:这种奇特的现象出现于在消息中传递小程序(applet)的现代程序中。它与按引用传递数据类似,只是在这种情况下是接收方会受到意想不到的影响。原因在于applet在其所做事情中没有受到约束。当接收方调用行为的时候,不能对其进行有效的控制,也不能预测没有数量限制的潜在副作用。如果你不觉得applet是一个问题,那么去问一下处理网站安全性的那些人吧。
按引用传递行为:尽管以前有一些语言可以通过传递函数指针的方式做到这一点,但是这种做法是非常罕见的。在面向对象的编程语言中这通常可以通过对象引用而平平常常地实现。就像FORTRAN语言的GOTO指令一样,这在当时似乎是个好主意,但是后来结果很严重。今天我们意识到这绝对是最差的耦合形式,它打开了一个巨大的潘多拉盒子。除了调用行为时会出现的各类潜在的副作用之外,接收方还能够在发送方不知情的情况下改变对象的信息。由于类的整个公共接口暴露了出来,因此接收方可以自由地调用对象的任何一个方面,尽管这些方面可能会导致发送方的问题。
最糟糕的是,发送方的封装被破坏殆尽。因为那些被传递了引用的对象可能是发送方实现的一部分。通过这样的传递,发送方的实现打开了一扇窗户,这引入bug和维护的噩梦。(在OO中,这种做法将隐藏实现破坏了,而隐藏实现是OO的一种基本做法。)这意味着维护人员必须跟踪每一处使用引用的地方,并验证它所有使用方式的变更都不会造成任何破坏。即便如此,我们也不能保证在新一轮的维护中,人们不会因为对接收方的变更而导致实例访问不正确。即使一切都能够工作,由于物理耦合的关系,仍然需要重新构建接收方。
因为这是一个广泛的话题,所以本书中不再进一步讨论耦合。但是,重要的是要了解:耦合的概念形式化了一组可维护性问题,理解耦合是良好编程的基础,无论是否从事面向对象的开发。