作者:Allen Holub 翻译: ShiningRay @ Nirvana Studio
摘要
大多数优秀的设计师避免出现继承(
extends
描述的关系),就像躲避瘟疫似的。你的代码80%应该完全以接口的方式来书写,而不是继承具体的基类。其实,Gang of Four 这本关于设计模式的书(以下简称GoF)很大程度上关于如何把类继承转变成接口实现。本文将叙述为什么设计师们会有这种古怪的信条。 ( 2,300 words; 2003 年 8月 1日 )
译注:本文其实已经有人翻译,当时没有具体了解就开始翻译了,如果另一位译者看到这篇文章,希望不要理解为我抄袭的。
extends
关键字是很有害的;也许不仅仅是在Charles Mason的级别上,还坏到了只要可能都应该避免的程度。GoF中详细讨论了把类继承( extends
)如何转变成接口实现( implements)。
优秀的设计师的大部分代码都是根据接口写的,而不是根据具体的基类。本文将会讲述为什么设计师们会有这种古怪的癖好,同时也将介绍一些基于接口的编程基础。
我曾经参加了一个Java用户小组会议,那次刚好是James Gosling (Java的发明者)作特邀演讲人。在那次难忘的Q&A对话(提问)上,一个人问他:“如果你可以重新将Java搞一遍,你会做哪些修改?”“我会去掉类,”他回答道。在笑声渐渐消失之后,他解释了真正的问题不是类的本质,而是类继承( extends
关系)。接口实现( implements
关系)却是完美的。只要有可能,你们就应该避免类继承。
为什么你应该避免类继承?第一个问题是明确的使用具体类的名称会把你框在特定的实现中,让以后的更改会十分困难。
当代,敏捷开发方法学的核心是设计和开发同步。你在完全详细描述程序之前,就开始编写代码了。这种技术完全违背了传统的理念——设计应该在编程之前完成——但是很多成功的项目已经证实了,用这个方法,你可以比传统流水线作业更快速地开发高质量的代码(同时付出很有效)。然而,在并行开发的核心是,弹性的概念。你必须以这种方式来写你的代码,以便你可以尽可能以无痛的方式加入新发现的需求到现有的代码中。
你只要实现 确实 需要的特性,而不是实现那些 可能 需要的特性,但要用一种可以适应变化的方法。如果你没有这种弹性,并行开发明显是不行的。
接口编程正是这个弹性接口的核心。要了解为什么,先让我们看看如果你不使用接口会发生什么。考虑以下代码:
f()
{ LinkedList list = new LinkedList();
//...
g( list );
}
g( LinkedList list )
{
list.add( ... );
g2( list )
}
现在假设一个紧急的新需求,需要进行更快速的查找,已经暴露出来了,这样 LinkedList
就达不到要求了,你就要把它换成 HashSet
。在现有的代码中,因为你必须同时修改 f()
还有 g()
(它用一个 LinkedList
作为参数),因此更改不是局限在一处的,还有一切传列表给 g()
的地方。
现在把代码改成这样:
f()
{ Collection list = new LinkedList();
//...
g( list );
}
g( Collection list )
{
list.add( ... );
g2( list )
}
现在我们要把链表改成哈希表就只把 new LinkedList()
改成 new HashSet()
。就完成了。不需要更改其他的地方。
另外一个例子,比较一下代码:
f()
{ Collection c = new HashSet();
//...
g( c );
}
g( Collection c )
{
for( Iterator i = c.iterator(); i.hasNext() ;)
do_something_with( i.next() );
}
以及:
f2()
{ Collection c = new HashSet();
//...
g2( c.iterator() );
}
g2( Iterator i )
{ while( i.hasNext() ;)
do_something_with( i.next() );
}
g2()
方法现在遍历 Collection
的派生对象以及你从 Map
中得到键和值。事实上,你可以写一个不断产生数据的迭代子而不是遍历一个集合。你可以写很多不同的迭代子,比如可以从测试台中或者一个文件中不断给出信息。这就是这里最重要的弹性所在。
关于类继承的一个更加关键的问题是耦合——程序中不期望的一个部分对另一个部分的依赖。全局变量提供了一个经典的例子来说明为什么强耦合会造成很多问题。例如,如果你更改了全局变量的类型,所有使用这个变量的函数(也就是,对这个变量有 耦合 )就会受到影响,这样所有这样的代码必须被检查、修改和重新测试。此外,所有使用这个变量的函数也会通过这个变量产生耦合。也就是,一个函数可能会不正确地更改了这个变量从而造成了其他函数的行为,如果变量的值在某些特殊的时间被更改的话。这个问题在多线程的程序中特别突出。
作为一个设计者,你应该 力争做到最低的耦合度。当然你不可能完全消除耦合,因为一个类的对象调用另一个对象就是一种松散耦合的形式。你不可能写出一个一点耦合都没有的程序。尽管这样,你可以通过绝对服从OO的原则(最重要的是一个对象的实现细节应该对使用它的对象是隐藏的)来相当可观地最小化耦合。例如,一个对象的实例变量(非常量的成员字段),总是应该为 私有private
。这没有任何例外的情况(你可以偶尔很有效地使用protected方法,但是protected实例变量是相当讨厌的) 同样的原因,你也绝不能使用 set/get 函数——他们只是另一种让字段变成公共的稍复杂方式而已。(虽然返回处理过的对象而不是一个基本类型的值的访问函数在某些情况下还是合理的,如果返回的对象的类是设计中的一个关键的抽象的话。)
这里我不是在卖弄学问。我发现了一个OO方式的严格性、快速代码开发、和简单的代码维护之间的直接的相关性。无论什么时候我违反了一个核心的OO原则比如隐藏实现细节,我只能结束代码的修改(通常是因为这个代码不可能进行调试)。我没有时间重写程序,所以我只能遵循这些规则。我关注的是完全实际的内容——我对为了设计而设计没有兴趣。
现在,我们把耦合的概念应用到继承上。在一个使用实现-继承系统中,派生类对基类有十分紧密的耦合,同时这个闭合的连接是不受欢迎的。设计师们因此给这种行为起了一个绰号——“脆基类问题”。基类是被认为十分脆弱的,因为你可以通过一个表面上十分安全的方法修改一个基类,但这个新的行为,当被派生类继承的时候,可能会造成派生类运行出错。你不能简单孤立地通过检查基类的方法来判断你对基类的改变是不是安全;你也必须查看(并测试)所有的派生类。此外,你必须检查所有同时使用了基类和派生类对象的代码,因为这些代码可能会被新的行为所破坏。对关键的基类的小小的改变都会导致整个程序无法运行。
我们来一起检验这个脆基类和基类耦合这两个问题。下面的类扩展了Java的 ArrayList
类,来模拟栈的行为:
class Stack extends ArrayList
{ private int stack_pointer = 0;
public void push( Object article )
{ add( stack_pointer++, article );
}
public Object pop()
{ return remove( --stack_pointer );
}
public void push_many( Object[] articles )
{ for( int i = 0; i < articles.length; ++i )
push( articles[i] );
}
}
甚至像这样简单的一个类,都存在着问题。思考一下如果用户利用继承直接使用 ArrayList
的 clear()
方法来把所有的元素都从栈中弹出去,会发生什么:
Stack a_stack = new Stack();
a_stack.push("1");
a_stack.push("2");
a_stack.clear();
这个代码可以成功地编译,但是由于基类并不知道任何关于栈指针的信息, Stack
对象现在处在一个不确定的状态。下面再调用 push()
会把新的条目放到索引2种(栈指针 stack_pointer
当前的值),这样栈看上去就有三个元素了——但底下两个已经被垃圾收集了。(Java类库中的 Stack
类就是这种问题,所以不要用)
对于不需要的方法继承,一种解决方式是,对于 Stack
要重写所有 ArrayList
的可能修改数组状态的方法,这样覆盖的函数可以正确处理栈指针或者抛出一个异常。( removeRange()
方法是一个较好的抛出异常的候选。)
这个方法有两个缺点。第一,如果你覆盖所有的东西,基类就实际上成为了一个接口,而不是一个类。如果你不使用任何继承的方法,类继承就毫无意义。 第二,也是更为重要的一点,你并不希望一个栈能支持所有 ArrayList
的方法。比如,那个讨厌的 removeRange()
方法没什么用处。实现一个没用的方法的唯一合理的方式,就是让他抛出一个异常,这样他就不可能被调用了。这个方法却将一个编译时错误变成了运行时错误。这并不好。如果方式只是没有被声明,那么编译器会直接扔出一个“未找到方法”的错误。如果这个方法存在但是他抛出异常,你就不会发现错误直到程序运行的时候。
一个更好的解决方法是封装一个数据结构而不使用继承。这下面是一个 Stack
的改进过的新版本:
class Stack
{ private int stack_pointer = 0;
private ArrayList the_data = new ArrayList();
public void push( Object article )
{ the_data.add( stack_pointer++, article );
}
public Object pop()
{ return the_data.remove( --stack_pointer );
}
public void push_many( Object[] articles )
{ for( int i = 0; i < o.length; ++i )
push( articles[i] );
}
}
目前为止还不错,但是还要考虑到脆基类的问题。让我们假设你想创建一个 Stack
的变体,可以跟踪运行一段时间之后栈出现过的最大值。一种可能的实现如下:
class Monitorable_stack extends Stack
{
private int high_water_mark = 0;
private int current_size;
public void push( Object article )
{ if( ++current_size > high_water_mark )
high_water_mark = current_size;
super.push(article);
}
public Object pop()
{ --current_size;
return super.pop();
}
public int maximum_size_so_far()
{ return high_water_mark;
}
}
新的类运行得很好,至少目前这样。但不幸的是,代码暴露了 push_many()
方法是通过调用 push()
来完成它的任务的。首先,这个细节看起来还不算一个糟糕的选择。他简化了代码,同时你可以获得派生类的 push()
版本,即使当 Monitorable_stack
是通过一个 Stack
类型的引用也能完成,所以, high_water_mark
的更新是正确的。
某一天,有个人也许会运行一个测试工具并且发现了 Stack
还不够快,而且他要被频繁地使用。你可以重写一个不使用 ArrayList
的 Stack
,由此改进 Stack
的性能。下面是最新的版本:
class Stack
{ private int stack_pointer = -1;
private Object[] stack = new Object[1000];
public void push( Object article )
{ assert stack_pointer < stack.length;
stack[ ++stack_pointer ] = article;
}
public Object pop()
{ assert stack_pointer >= 0;
return stack[ stack_pointer-- ];
}
public void push_many( Object[] articles )
{ assert (stack_pointer + articles.length) < stack.length;
System.arraycopy(articles, 0, stack, stack_pointer+1,
articles.length);
stack_pointer += articles.length;
}
}
注意 push_many()
不再是重复调用 push()
,而是采用了块传送。新版本的 Stack 运行很好;事实上,它要比原先的版本更 好 。但很不幸,派生类 Monitorable_stack
就不能再正常工作了,因为如果调用的是 push_many()
那么他不能正确跟踪栈的使用情况了(派生类的 push()
版本不再被 继承了的 push_many()
所调用,所以他不会再更新 high_water_mark
)。现在 Stack
就是一个脆基类。正如上面显示的,事实上不可能仅仅靠小心就能消除这类问题。
值得注意的是如果你使用接口继承,就不会有这种问题,因为不会继承任何功能,就不会产生不良影响。如果 Stack
是一个接口,同时通过 Simple_stack
和 Monitorable_stack
来实现,那么代码就会更加强壮。
在表0.1种,我提供了一个基于接口的解决方案。这个方法和类继承的方法有相等的弹性:你可以根据 Stack
抽象来写你的代码而不用担心你要具体处理那种类型的栈。由于这两种实现都必须提供公共接口中的所有方法,要出现问题也很难。我也有且仅有一次从写相同的基类代码中获益,因为我使用了封装而不是派生。从负面,我必须通过一个封装类中的细小的访问器方法去访问默认的实现。(例如 Monitorable_Stack.push(...)
(41行)必须要调用 Simple_stack
的中等价的方法。)程序员总是抱怨写这种一行就完了的代码,但是仅仅就写这么额外的一行的代价就可以消除潜在的巨大Bug。
1| import java.util.*;
2|
3| interface Stack
4| {
5| void push( Object o );
6| Object pop();
7| void push_many( Object[] source );
8| }
9|
10| class Simple_stack implements Stack
11| { private int stack_pointer = -1;
12| private Object[] stack = new Object[1000];
13|
14| public void push( Object o )
15| { assert stack_pointer < stack.length;
16|
17| stack[ ++stack_pointer ] = o;
18| }
19|
20| public Object pop()
21| { assert stack_pointer >= 0;
22|
23| return stack[ stack_pointer-- ];
24| }
25|
26| public void push_many( Object[] source )
27| { assert (stack_pointer + source.length) < stack.length;
28|
29| System.arraycopy(source,0,stack,stack_pointer+1,source.length);
30| stack_pointer += source.length;
31| }
32| }
33|
34|
35| class Monitorable_Stack implements Stack
36| {
37| private int high_water_mark = 0;
38| private int current_size;
39| Simple_stack stack = new Simple_stack();
40|
41| public void push( Object o )
42| { if( ++current_size > high_water_mark )
43| high_water_mark = current_size;
44| stack.push(o);
45| }
46|
47| public Object pop()
48| { --current_size;
49| return stack.pop();
50| }
51|
52| public void push_many( Object[] source )
53| {
54| if( current_size + source.length > high_water_mark )
55| high_water_mark = current_size + source.length;
56|
57| stack.push_many( source );
58| }
59|
60| public int maximum_size()
61| { return high_water_mark;
62| }
63| }
64|
关于脆基类的讨论如果不提到基于框架的编程,就不会是完整的讨论。像MFC(微软基础类库)这种框架已经成为一种建立类库的流行手段。虽然MFC他正在急流勇退,但MFC的结构已经 深深扎根在无数微软的车间——这里面的程序员都认为微软的方法是最好的方法。
一个基于框架的系统,一般都是以半成品类的库作为起始,这些半成品的类不会完成所有事情,而是要依赖于派生类提供未完成的功能。Java中的一个典型的例子就是 Component
的 paint()
方法,它其实只算一个占位符;而派生类则必须提供真正的版本。
你可以,但是一个整个类框架都依赖于基于派生的自定义是极其脆弱的。基类也很脆弱。当我用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 ) 查询更多关于他的信息。