简易Smalltalk测试框架

简易Smalltalk测试框架:
以及模式

Kent Beck,
First Class Software, Inc.
KentBeck@compuserve.com

翻译:ShiningRay @ NirvanaStudio

本软件和文档是由编码社区提供的服务。你可以随意分发。First Class Software, Inc.不提供任何形式的声明或隐含的担保。

(Transcribed to HTML by Ron Jeffries. The software is available for many Smalltalks, and for C++, on my FTP site.)

介绍

Smalltalk语言曾饱受煎熬,因为他一直缺少一种测试的习俗。这篇专栏将介绍一种简单的测试策略和框架来支持我们的Smalltalk。虽然这个测试策略和框架并不打算发展成为一个全套的解决方案,但是他相当于一个起点,任何有工业强度的工具和过程都都可以从他起进行建造。
本论文分为三个部分:

  • 原理 – 描述了书写和运行由框架体现的测试原理。阅读这一章了解基本的背景。
  • 手册 – 一个简单模式系统可以用来写你自己的测试。
  • 框架 – 测试框架的一个简单版本。阅读这一章可以深入了解框架是如何操作的。
  • 例子 – 一个使用测试框架来测试Set对象中方法的例子。

原理

我不喜欢基于用户界面的测试。在我的印象中,基于用户界面脚本的测试是十分脆弱的,难于使用。当我还在参加一个项目,我们使用了 用户界面测试,每天早上送来的测试报告中有二三十个测试失败,那是常有的。随便检查一下就会发现大多数的失败中实际上程序还是按照期望的运行的。界面上稍作一些修饰,就会导致最终的输出和期望的输出不一样。我们的测试元花费太多的时间保持测试代码和现有代码同步并且找出那些“伪失败”和“伪成功”,却没多 少时间写新的测试。

我的解决方法是直接在Smalltalk中写测试代码并检查他的结果。这个方法有一个缺点就是你的测试员必须会写一些简单的Smalltalk程序,但因此测试就变得更为可靠。

失败和错误

这个测试框架会区分失败和错误。一个失败指预期可能发生的问题。当你写测试时,你要检查预期的结果。如果你后来得到的是一个不同的结果,这是一个失败的测试。有一个错误那就更为悲惨,你可能根本没检查到有这个错误情况的可能。

测试单元

我推荐开发人员自己写自己的单元测试,每个类写一个。测试框架也支持写测试套件(TestSuite,或叫测试序列),也可以附加给一个类。我还推荐所有的类响应“testSuite”消息,返回一个包含单元测试的测试套件。另外,我还推荐开发人员花20~50%的时间开发测试代码。

集成测试

我推荐由一个单独的测试员写集成测试。哪里运行集成测试呢?最近的用户界面框架运动到更好的程序访问提供了同一个答案——驱动用户界面,但让测试来做。在VisualWorks(下面的实现也会用到这个方言),你可以打开一个应用程序模型ApplicationModel并不断往他的ValueHolders中填入值,造成各种混乱,也不会产什么麻烦。

运行测试

这是原理部分的最后一点。有种做法很有诱惑力——建立一系列测试数据,然后运行一系列测试,然后清除。在我的经历中,这个要比他代来的价值造成更多的问题。一个测试可能会结束和另一个的交互,而且一个测试中的失败可能造成后续的测试都不能够运行。测试框架让我们可以很容易的建立一系列通用测试数据,但是这些数据会在每次测试之前建立并在之后抛弃。这种做法的潜在的性能问题不会造成很大的影响因为测试可以在无人值守的情况下运行。

手册

这里是一个书写测试的简易模式系统。有以下模式:

模式

目的

装置

建立一个通用的测试装置。

测试案例

建立一个测试案例的激发器。

检查

检查测试案例的结果。

测试套件

聚合TestCase测试案例。

装置(Fixture)

你怎样开始写测试呢?

测试是一种不可能的任务。你要让他完全通过,这样你才能保证软件可以正常工作。从另一角度看,由于你的程序的可能状态太庞大,以至于你不可能测试每一种组合情况。

如果你一开始很茫然,都不知道你要测试什么,你就永远不能进入测试的状态。由一个行为可预测的配置开始则要好很多。当你对你开发的软件有更多的了解,你可以加入一个配置列表。

这样一个配置,称之为“装置”(Fixture)。下面是装置的例子:

装置

预计反馈

1.0 and 2.0

算数问题很容易给出预计的答案

到已知的机器的网络连接

对网络包进行相应

#() and #(1 2 3)

发送测试消息的结构

选择一个装置你判断哪些要测试哪些不要。一个完整的测试会有很多装置,每一个都可以以多种途径进行测试。

设计一个测试装置。

  • 创建 TestCase 的子类
  • 在装置中给每个已知的对象添加一个实例变量
  • 重写 setUp 来初始化变量

在下面的例子中,测试装置是两个Set(集合),一个为空另一个包含元素。首先我们给TestCase建立子类并给对象添加实例变量,以便我们以后进行引用:

Test Case测试案例

你已经有了一个装置,下面要做什么呢?

你如何表现测试的一个单元?

你可以预测给装置发出消息之后的结果。你需要通过某种方式表现这样一种预知的情况。

最简单的方法是用交互的方法表现。你打开装置的一个检查器(Smalltalk中的Inspector)
并且给他发送消息。这种做法有两个缺点。第一,你要一直给同一个装置发送消息。如果有一个测试刚巧把对象弄乱了,那么后面所有的测试都会失败,即使代码也许是正确的。更重要的是,你不能方便地和其他人进行交流,交换你的交互测试。如果你把你的对象给了其他人,他们唯一可以测试这些对象的办法就是把你叫来并检查他们。

通过将每一个可预测的情况表现为一个对象,每一个都有一个自己装置,没有哪两个测试会互相干扰。同时,你可以很方便地把测试给别人运行。

用一个方法来代表装置的一个可预见的反应。

  • 给TestCase的子类添加一个方法
  • 用该方法激活装置

下面的例子代码演示了这两点。我们可以预见:将“5”加入一个空的集合会得到“5”在集合中的结果。我们给我们的TestCase的子类添加一个方法。用它来激活装置:

一旦你激活了装置,你需要添加一个检查机制来确保你的预期正确发生。

检查

一个测试案例会激发一个测试装置。

你如何测试要得到预期的结果?

如果你在进行交互测试,你可以直接检查预期的结果。如果你要查看一个特定的返回之,就是用“print it”,并确保你获得正确的对象。如果你要查看一些额外的影响,请使用Inspector检查器。

由于测试是在他们自己的对象中,你需要一种直接用程序查找问题的方法。一种实现办法是使用标准错误处理机制(Object>>error:),以测试逻辑为error:信号

当你在测试的时候,你可能想要区别你要检查的错误,象二加三得六,和那些你没有预见的错误,如下标越界或者是未定义的消息。

你还没什么办法对付无法预见的错误(如果你已经对他们作了些什么,他们就不会说无法预见了,是不是?)当一个灾难性的错误降临时,测试框架会停止运行测试案例,记录错误,并直接运行下一个测试案例。由于每一个测试案例都有他自己的装置,前一个案例中的错误不会影响下一个。

测试框架简单的提供了一个“should:”方法来检测预期的值,这个方法带一个语句块作为参数。如果语句块得出的是真,那一切都好。否则,测试案例停止运行,测试失败被记录在案,然后运行下一个测试案例。

把检查的内容变成一个返回值为布尔型的语句块,并把语句块作为参数传递给“should:”。

在下面的例子中,在通过给一个空集合加“5”激活装置之后,我们希望检测并确保里面包含它。

有一个TestCase>>should: 的变体。TestCase>>shouldnt: 在语句块参数为真的情况下失败。这样你就不必使用“(…)not”了。

你一旦有了一个测试案例,你就可以运行它了。给你的TestCase的子类创建一个实例,并给出测试方法的选择器。并发送“runt”给结果对象:

如果他运行结束,那么测试通过了。如果摔了个跟头,看来有什么东西出错了。

TestSuite测试套件

你现在有若干测试案例。

你如何一下运行许多测试呢?

一旦你有两个测试案例在运行,你会希望他们能一个接一个运行而不是你去执行两次“do it”。你可以就把两块运行表达式放弃及然后执行来运行测试。然而,如果当你又需要运行“这一块案例和那一块案例”的时候呢,你就会烦了。

测试框架提供了一个对象来表示“一系列测试”——测试套件TestSuite。一个TestSuite运行一套测试案例并且把他们的运行结果同时汇报上来。根据多态的优点,TestSuite同样可以包含其他TestSuite,所以你可以把Joe的测试和Tammy的测试放在一个创建一个更高一级的套件。

把测试案例组合到测试套件中。

给一个TestSuite发送了“run”的结果是一个TestResult对象。他记录了所有的测试案例引发的失败或错误,和套件运行的时间。

所有这些对象都是可以通过ObjectFiler或者BOSS进行储存。你可以很方便的保存一个套件,然后调入并运行,和以前的结果进行比较。

Framework框架

这一部分展示了测试框架的代码。如果你对框架的实现很好奇,或者你想要修改它,那么就在这一章了。当你和一个测试员交谈时,他们谈论的测试的最小单元是测试案例。TestCase是一个用户的对象,代表了一个单个测试案例。

测试员谈论如何设置一个“测试装置”,这是一个带有可预测响应结构的对象,他既容易建立也容易推导。几个不同的测试案例可以针对同一个测试装置。

这个差别表现在框架中是通过给每个TestCase一个可插入的选择器。选择器调用的变量行为便是测试代码。同一个类的所有实例共享同一个装置。

TestCase class>>selector: 是一个 Complete Creation Method

TestCase>>setSelector: 是 Creation Parameter Method

TestCase的子类需要相应改写钩子方法setUp和tearDown来建立和销毁测试装置。TestCase本身提供了两个什么也不做的基本方法。

运行一个TestCase最简便的方法就是向他发送“run”消息。Run调用设置代码,运行选择器,然后运行拆卸代码。注意拆卸代码不管执行测试的时候有没有错误都会执行。调用setUp和tearDown可以封装成Execute Around Method,不过由于他们不是公共接口的一部分,他们必须在这里编写。

PerformTest 仅仅执行一下选择器

单个的TestCase一点意思也没有,一旦你让他运行起来了之后。然后,你就会想一次运行很多很多测试案例。测试员谈论运行测试“套件”。TestSuite是一个用户对象。它是测试案例的组合。

TestSuites 是有名称得对象。因此可以很方便判断他们的身份,这样他们可以被储存在存储器中,也可以从中读取。一下是完整的构造方法和构造参数方法。

testCases实例变量是在TestSuite>>setName: 中初始化的因为我不需要让他变成其他类型的集合。

测试套件有一个名称的访问方法,这样可以在用户界面上显示他们。

当你行一个TestSuite,你可能想要运行它所有的TestCase。但他不是这样简单的。如果你有一个代表应用程序的验收测试侧套件,在运行之后,你可能还想知道套件运行了多久,哪个案例有问题。这些是你可能想要储存以便将来引用的信息。

TestResult是一个TestSuite的结果对象。运行一个TestSuite返回一个TestResult,记录了上面描述的信息——起始时间和中止时间,套件名称和所有的失败和错误。

TestCase>>run 和 TestSuite>>run 在多态上并不相同。这是需要在以后的框架中解决的问题。一种做法是用一个可以按微秒度量的TestCaseResult来做性能回退测试(performance regression testing.)。

默认的TestResult由TestSuite构造,使用一个 Default Class.

一个TestResult Complete Creation Method 需要一个 TestSuite.

TestResult可以通过发送start和stop消息来做时间标记。由于start和stop需要成对执行,他们必须隐藏在一个Execute Around Method方法中。这也是以后要做的。

当一个TestSuite运行时给出一个TestResult,它仅仅将其中每一个TestCase带着TestResult运行。

#run: 是TestSuite和TestCase中的组合选择器,这样你可以构造包含其他TestSuite的TestSuite,替代或者补充TestCase。

当一个TestCase运行时给出了一个TestResult,它应该要么安静无误得运行,向TestResult中添加一个错误,或者添加一个失败。捕获错误简单的使用了系统提供的errorSignal。捕获失败必须由TestCase本身提供。首先,我们需要一个 Class Initialization Method 来创建一个信号。

现在我们需要一个 Accessing Method.

现在,当TestCase用一个TestResult运行时,它要捕获错误和失败然后通知TestResult,同时它必须运行tearDown代码不管测试是否正确执行。者造成了这是框架中最丑的方法,因为有两个嵌套的错误处理器和方法中的valueNowOrOnUnwindDo:。这里少解释了一个模式在TestCase>>run 关于使用ensure:确保安全运行Execute Around Method 的第二个停止。

当一个TestResult被告知有一个错误或者失败发生了,他在他其中一个集合中记录这个事实。为了简单起见,记录只是两个数组,但是他可能应该是一个类对象包含一个时间标签和问题更详细的信息。

一旦在测试方法中出现一个未捕获的错误(例如,无法辨认的消息),就调用错误的情况。失败的情况如何调用呢?TestCase提供了两个方法简化了失败检查。第一个是should: aBlock,如果aBlock执行后返回假便发出失败消息。第二个,shouldnt: aBlock,和前面刚好相反。

测试方法将会运行代码来激发测试装置,然后在should:shouldnt:块中检查结果。

例子

Ok, 这就是他的工作原理,你怎么来用它呢?这里有一个简短的例子测试Set支持的一些消息。首先我们建立TestCase的子类,因为我们总想有好几对有趣的集合可玩。

现在我们需要初始化这些变量,所以我们重写setUp。

现在我们需要一个测试方法。让我们测试看看给一个集合添加元素究竟能否成功。

现在我们可以通过执行”(SetTestCase selector: #testAdd) run”来运行一个测试案例。

这里有一个使用shouldnt:的情况。他这么念的”after removing 5 from full, full should include #abc and it shouldn’t include 5.”

这里又有一个特殊情况,确保在你要使用索引访问的时候发出错误信号。

现在我们可以把它们一起放在一个测试套件里面。

这里是这个套件的对象浏览器图TestResult是我们运行之后得到的。 

以上所展示的测试方法仅仅覆盖了Set中的一小部分功能。给Set中所有的公共方法写测试确实是一个很让人郁闷的任务。尽管如此,就像 Hal Hildebrand 在使用了这个框架的早期一个版本之后告诉我的那样。“如果根本的对象不能正常工作,其他一切都免提。你必须写测试来确保所有的东西都能正常工作!”

JavaScript中的类继承

JavaScript中的类继承

DouglasCrockford
www.crockford.com

翻译 ShiningRay @ www.nirvanastudio.org

And you think you’re so clever and classless and free
–John Lennon

JavaScript一种没有类的,面向对象的语言,它使用原型继承来代替类继承。这个可能对受过传统的面向对象语言(如C++和Java)训练的程序员来说有点迷惑。JavaScript的原型继承比类继承有更强大的表现力,现在就让我们来看看。

Java

JavaScript

强类型

弱类型

静态

动态

基于类

基于原型

函数

构造器

函数

方法

函数

但首先,为什么我们如此关心继承呢?主要有两个原因。第一个是类型有利。我们希望语言系统可以自动进行类似类型引用的转换cast。小类型安全可以从一个要求程序显示地转换对象引用的类型系统中获得。这是强类型语言最关键的要点,但是这对像JavaScript这样的弱类型语言是无关的,JavaScript中的类引用无须强制转换。

第二个原因是为了代码的复用。在程序中常常会发现很多对象都会实现同一些方法。类让建立单一的一个定义集中建立对象成为可能。在对象中包含其他对象也包含的对象也是很常见的,但是区别仅仅是一小部分方法的添加或者修改。类继承对这个十分有用,但原型继承甚至更有用。

要展示这一点,我们要介绍一个小小的“甜点”可以主我们像一个常规的类语言一样写代码。我们然后会展示一些在类语言中没有的有用的模式。最后,我们会就会解释这些“甜点”。

类继承

首先,我们建立一个Parenizor类,它有成员 valuegetset方法,还有一个会将value包装在括号内的toString方法。

这个语法可能没什么用,但它很容易看出其中类的形式。method方法接受一个方法名和一个函数,并把它们放入类中作为公共方法。

现在我们可以写成

正如期望的那样,myString"(0)"

现在我们要建立另一个继承自Parenizor的类,它基本上是一样的除了toString方法将会产生"-0-"如果value是零或者空。

inherits方法类似于Java的extends uber方法类似于Javasuper。它令一个方法调用父类的方法(更改了名称是为了避免和保留字冲突)。

我们可以写成这样

这次, myString"-0-".

JavaScript 并没有类,但我们可以编程达到这个目的。

多继承

通过操作一个函数的prototype对象,我们可以实现多继承。混合多继承难以实现而且可能会遭到名称冲突的危险。我们可以在JavaScript中实现混合多继承,但这个例子我们将使用一个较规范的形式称为瑞士继承SwissInheritance.

假设有一个NumberValue类有一个setValue方法用来检查 value是不是在一个指定范围内的一个数,并在适当的时候抛出异常。我们只要它的setValuesetRange方法给我们的ZParenizor。我们当然不想要它的toString方法。这样,我们写到:

这个将仅仅添加需要的方法。

寄生继承

这是另一个书写 ZParenizor类的方法。并不从 Parenizor继承,而是写了一个调用了Parenizor构造器的构造器,并对结果修改最后返回这个结果。这个构造器添加的是特权方法而非公共方法。

类继承是一种“是……”的关系,而寄生继承是一个关于“原是……而现在是……”的关系。构造器在对象的构造中扮演了大量的角色。注意uber (代替super关键字)对特权方法仍有效。

类扩展

JavaScript的动态性让我们可以对一个已有的类添加或替换方法。我们可以在任何时候调用方法。我们可以随时地扩展一个类。继承不是这个方式。所以我们把这种情况称为“类扩展”来避免和Java的extends──也叫扩展,但不是一回事──相混淆。

对象扩展

在静态面向对象语言中,如果你想要一个对象和另一个对象有所区别,你必须新建立一个类。但在JavaScript中,你可以向单独的对象添加方法而不用新建类。这会有巨大的能量因为你就可以书写尽量少的类,类也可以写得更简单。想想JavaScript的对象就像哈希表一样。你可以在任何时候添加新的值。如果这个值是一个函数,那他就会成为一个方法。

这样在上面的例子中,我完全不需要 ZParenizor类。我只要简单修改一下我的实例就行了。

我们给 myParenizor实例添加了一个 toString方法而没有使用任何继承。我们可以演化单独的实例因为这个语言是无类型的。

小甜点

要让上面的例子运行起来,我写了四个“甜点”方法。首先,method方法,可以把一个实例方法添加到一个类中。

这个将会添加一个公共方法到 Function.prototype中,这样通过类扩展所有的函数都可以用它了。它要一个名称和一个函数作为参数。

它返回 this。当我写一个没有返回值的方法时,我通常都会让它返回this。这样可以形成链式语句。

下面是 inherits方法,它会指出一个类是继承自另一个类的。它必须在两个类都定义完了之后才能定义,但要在方法继承之前调用。

再来,我们扩展 Function类。我们加入一个 parent类的实例并将它做为新的prototype。我们也必须修正constructor字段,同时我们加入uber方法。

uber方法将会在自己的prototype中查找某个方法。这个是寄生继承或类扩展的一种情况。如果我们是类继承,那么我们要找到parentprototype中的函数。return语句调用了函数的apply方法来调用该函数,同时显示地设置this并传递参数。参数(如果有的话)可以从arguments数组中获得。不幸的是,arguments数组并不是一个真正的数组,所以我们又要用到apply来调用数组中的slice方法。

最后,swiss方法

The swiss方法对每个参数进行循环。每个名称,它都将parent的原型中的成员复制下来到新的类的prototype中。

总结

JavaScript可以像类语言那样使用,但它也有一种十分独特的表现层次。我们已经看过了类继承、瑞士继承、寄生继承、类扩展和对象扩展。这一等系列代码复用的模式都能来自这个一直被认为是很小、很简单的JavaScript语言。

类对象属于“硬的”。给一个“硬的”对象添加成员的唯一的方法是建立一个新的类。在JavaScript中,对象是“软的”。要给一个“软”对象添加成员只要简单的赋值就行了。

因为JavaScript中的类是这样地灵活,你可能会还想到更复杂的类继承。但深度继承并不合适。浅继承则较有效而且更易表达。

为什么继承是有害的?

通过把具体的基类转变成接口来改进你的代码

作者:Allen Holub  翻译:ShiningRay @ Nirvana Studio

摘要

大多数优秀的设计师避免出现继承(extends描述的关系),就像躲避瘟疫似的。你的代码80%应该完全以接口的方式来书写,而不是继承具体的基类。其实,Gang of Four 这本关于设计模式的书(以下简称GoF)很大程度上关于如何把类继承转变成接口实现。本文将叙述为什么设计师们会有这种古怪的信条。(2,300 words;2003 年 8月 1日)

译注:本文其实已经有人翻译,当时没有具体了解就开始翻译了,如果另一位译者看到这篇文章,希望不要理解为我抄袭的。

extends关键字是很有害的;也许不仅仅是在Charles Mason的级别上,还坏到了只要可能都应该避免的程度。GoF中详细讨论了把类继承(extends)如何转变成接口实现(implements)。

优秀的设计师的大部分代码都是根据接口写的,而不是根据具体的基类。本文将会讲述为什么设计师们会有这种古怪的癖好,同时也将介绍一些基于接口的编程基础。

接口 VS 类

我曾经参加了一个Java用户小组会议,那次刚好是James Gosling(Java的发明者)作特邀演讲人。在那次难忘的Q&A对话(提问)上,一个人问他:“如果你可以重新将Java搞一遍,你会做哪些修改?”“我会去掉类,”他回答道。在笑声渐渐消失之后,他解释了真正的问题不是类的本质,而是类继承(extends关系)。接口实现(implements关系)却是完美的。只要有可能,你们就应该避免类继承。

弹性的丧失

为什么你应该避免类继承?第一个问题是明确的使用具体类的名称会把你框在特定的实现中,让以后的更改会十分困难。

当代,敏捷开发方法学的核心是设计和开发同步。你在完全详细描述程序之前,就开始编写代码了。这种技术完全违背了传统的理念——设计应该在编程之前完成——但是很多成功的项目已经证实了,用这个方法,你可以比传统流水线作业更快速地开发高质量的代码(同时付出很有效)。然而,在并行开发的核心是,弹性的概念。你必须以这种方式来写你的代码,以便你可以尽可能以无痛的方式加入新发现的需求到现有的代码中。

你只要实现确实需要的特性,而不是实现那些可能需要的特性,但要用一种可以适应变化的方法。如果你没有这种弹性,并行开发明显是不行的。

接口编程正是这个弹性接口的核心。要了解为什么,先让我们看看如果你不使用接口会发生什么。考虑以下代码:

现在假设一个紧急的新需求,需要进行更快速的查找,已经暴露出来了,这样LinkedList就达不到要求了,你就要把它换成HashSet。在现有的代码中,因为你必须同时修改f()还有g()(它用一个LinkedList作为参数),因此更改不是局限在一处的,还有一切传列表给g()的地方。

现在把代码改成这样:

现在我们要把链表改成哈希表就只把new LinkedList()改成new HashSet()。就完成了。不需要更改其他的地方。

另外一个例子,比较一下代码:

以及:

g2()方法现在遍历Collection的派生对象以及你从Map中得到键和值。事实上,你可以写一个不断产生数据的迭代子而不是遍历一个集合。你可以写很多不同的迭代子,比如可以从测试台中或者一个文件中不断给出信息。这就是这里最重要的弹性所在。

耦合

关于类继承的一个更加关键的问题是耦合——程序中不期望的一个部分对另一个部分的依赖。全局变量提供了一个经典的例子来说明为什么强耦合会造成很多问题。例如,如果你更改了全局变量的类型,所有使用这个变量的函数(也就是,对这个变量有耦合)就会受到影响,这样所有这样的代码必须被检查、修改和重新测试。此外,所有使用这个变量的函数也会通过这个变量产生耦合。也就是,一个函数可能会不正确地更改了这个变量从而造成了其他函数的行为,如果变量的值在某些特殊的时间被更改的话。这个问题在多线程的程序中特别突出。

作为一个设计者,你应该力争做到最低的耦合度。当然你不可能完全消除耦合,因为一个类的对象调用另一个对象就是一种松散耦合的形式。你不可能写出一个一点耦合都没有的程序。尽管这样,你可以通过绝对服从OO的原则(最重要的是一个对象的实现细节应该对使用它的对象是隐藏的)来相当可观地最小化耦合。例如,一个对象的实例变量(非常量的成员字段),总是应该为私有private。这没有任何例外的情况(你可以偶尔很有效地使用protected方法,但是protected实例变量是相当讨厌的)同样的原因,你也绝不能使用 set/get 函数——他们只是另一种让字段变成公共的稍复杂方式而已。(虽然返回处理过的对象而不是一个基本类型的值的访问函数在某些情况下还是合理的,如果返回的对象的类是设计中的一个关键的抽象的话。)

这里我不是在卖弄学问。我发现了一个OO方式的严格性、快速代码开发、和简单的代码维护之间的直接的相关性。无论什么时候我违反了一个核心的OO原则比如隐藏实现细节,我只能结束代码的修改(通常是因为这个代码不可能进行调试)。我没有时间重写程序,所以我只能遵循这些规则。我关注的是完全实际的内容——我对为了设计而设计没有兴趣。

脆基类问题

现在,我们把耦合的概念应用到继承上。在一个使用实现-继承系统中,派生类对基类有十分紧密的耦合,同时这个闭合的连接是不受欢迎的。设计师们因此给这种行为起了一个绰号——“脆基类问题”。基类是被认为十分脆弱的,因为你可以通过一个表面上十分安全的方法修改一个基类,但这个新的行为,当被派生类继承的时候,可能会造成派生类运行出错。你不能简单孤立地通过检查基类的方法来判断你对基类的改变是不是安全;你也必须查看(并测试)所有的派生类。此外,你必须检查所有同时使用了基类和派生类对象的代码,因为这些代码可能会被新的行为所破坏。对关键的基类的小小的改变都会导致整个程序无法运行。

我们来一起检验这个脆基类和基类耦合这两个问题。下面的类扩展了Java的ArrayList类,来模拟栈的行为:

甚至像这样简单的一个类,都存在着问题。思考一下如果用户利用继承直接使用ArrayListclear()方法来把所有的元素都从栈中弹出去,会发生什么:

这个代码可以成功地编译,但是由于基类并不知道任何关于栈指针的信息,Stack对象现在处在一个不确定的状态。下面再调用push()会把新的条目放到索引2种(栈指针stack_pointer当前的值),这样栈看上去就有三个元素了——但底下两个已经被垃圾收集了。(Java类库中的Stack类就是这种问题,所以不要用)

对于不需要的方法继承,一种解决方式是,对于Stack要重写所有ArrayList的可能修改数组状态的方法,这样覆盖的函数可以正确处理栈指针或者抛出一个异常。(removeRange()方法是一个较好的抛出异常的候选。)

这个方法有两个缺点。第一,如果你覆盖所有的东西,基类就实际上成为了一个接口,而不是一个类。如果你不使用任何继承的方法,类继承就毫无意义。 第二,也是更为重要的一点,你并不希望一个栈能支持所有ArrayList的方法。比如,那个讨厌的removeRange()方法没什么用处。实现一个没用的方法的唯一合理的方式,就是让他抛出一个异常,这样他就不可能被调用了。这个方法却将一个编译时错误变成了运行时错误。这并不好。如果方式只是没有被声明,那么编译器会直接扔出一个“未找到方法”的错误。如果这个方法存在但是他抛出异常,你就不会发现错误直到程序运行的时候。

一个更好的解决方法是封装一个数据结构而不使用继承。这下面是一个Stack的改进过的新版本:

目前为止还不错,但是还要考虑到脆基类的问题。让我们假设你想创建一个Stack的变体,可以跟踪运行一段时间之后栈出现过的最大值。一种可能的实现如下:

新的类运行得很好,至少目前这样。但不幸的是,代码暴露了push_many()方法是通过调用push()来完成它的任务的。首先,这个细节看起来还不算一个糟糕的选择。他简化了代码,同时你可以获得派生类的push()版本,即使当Monitorable_stack是通过一个Stack类型的引用也能完成,所以,high_water_mark的更新是正确的。

某一天,有个人也许会运行一个测试工具并且发现了Stack还不够快,而且他要被频繁地使用。你可以重写一个不使用ArrayListStack,由此改进Stack的性能。下面是最新的版本:

注意push_many()不再是重复调用push(),而是采用了块传送。新版本的Stack运行很好;事实上,它要比原先的版本更 。但很不幸,派生类 Monitorable_stack 就不能再正常工作了,因为如果调用的是push_many()那么他不能正确跟踪栈的使用情况了(派生类的push()版本不再被继承了的push_many()所调用,所以他不会再更新high_water_mark)。现在Stack就是一个脆基类。正如上面显示的,事实上不可能仅仅靠小心就能消除这类问题。

值得注意的是如果你使用接口继承,就不会有这种问题,因为不会继承任何功能,就不会产生不良影响。如果Stack是一个接口,同时通过Simple_stackMonitorable_stack来实现,那么代码就会更加强壮。

在表0.1种,我提供了一个基于接口的解决方案。这个方法和类继承的方法有相等的弹性:你可以根据Stack抽象来写你的代码而不用担心你要具体处理那种类型的栈。由于这两种实现都必须提供公共接口中的所有方法,要出现问题也很难。我也有且仅有一次从写相同的基类代码中获益,因为我使用了封装而不是派生。从负面,我必须通过一个封装类中的细小的访问器方法去访问默认的实现。(例如Monitorable_Stack.push(...)(41行)必须要调用Simple_stack的中等价的方法。)程序员总是抱怨写这种一行就完了的代码,但是仅仅就写这么额外的一行的代价就可以消除潜在的巨大Bug。

表 0.1. 使用接口消除脆基类

框架(Frameworks)

关于脆基类的讨论如果不提到基于框架的编程,就不会是完整的讨论。像MFC(微软基础类库)这种框架已经成为一种建立类库的流行手段。虽然MFC正在急流勇退,但MFC的结构已经深深扎根在无数微软的车间——这里面的程序员都认为微软的方法就是最好的方法。

一个基于框架的系统,一般都是以半成品类的库作为起始,这些半成品的类不会完成所有事情,而是要依赖于派生类提供未完成的功能。Java中的一个典型的例子就是Componentpaint()方法,它其实只算一个占位符;而派生类则必须提供真正的版本。

你可以,但是一个整个类框架都依赖于基于派生的自定义是极其脆弱的。基类也很脆弱。当我用MFC编程的时候,每次微软发布一个新版本的MFC,我都必须重写自己的应用程序。代码会经常编译但接下来却不能正确工作因为一些基类的方法改变了。

所有的Java包都可以即开即用(Out of box)且运行良好。你无需扩展任何东西来让他们执行功能。 即开即用的结构比基于派生的框架要好。它更容易维护和使用,并且即使Sun提供的类改变了他的实现也不会让你的代码处于危险中。

脆基类的总结

一般来说,最好能避免继承具体基类和extends关系,而使用接口和implements关系。凭我的经验,代码中最少有80%应该是完全用接口的方式来写。比如,我从来不引用一个HashMap ;我通常会用指向Map接口的引用。(这里的“接口”是广义的。一个 InputStream也算一个有效的接口,你可以看看是如何使用它的,虽然他在Java中是描述为一个抽象类。)

你加入越多的抽象,弹性就会越好。在今天的商业环境中,通常在程序开发时需求就在不断变化,弹性是十分必要的。 此外,大多数敏捷开发方法(比如Crystal方法和极限编程)完全不能正常运作,除非代码是先用抽象写得。

如果你仔细研究Gang of Four的模式,你就会发现他们中很多都是提供了各种消除类继承的方法,而偏重于使用接口,这也是大多数模式的一个共性。我们一开始就要明白一个重要的事实:模式是被发现的,而不是被发明的。当你看了那些写得好的、易于维护可以很好运行的代码,模式自然就会浮现出来。这告诉我们这么多优秀的代码都会以各种方式避免类继承。

本文是从我即将发表的书中节选出来的,书暂时命名为《 Holub on Patterns: Learning Design Patterns by Looking at Code 》,将会在今年秋季通过Apress (www.apress.com)出版。

关于作者

Allen Holub 从1979年开始从事计算机产业的工作。他目前是一个顾问,通过对行政人员提供建议、培训以及设计、编程服务,来帮助企业不需要再软件上浪费钱。他撰写了8本书籍,包括 Taming Java Threads (Apress, 2000) 和 Compiler Design in C (Pearson Higher Education, 1990), 并且在加州大学伯克利分校教学。请在他的网站 ( http://www.holub.com ) 查询更多关于他的信息。


资源