改善代码设计:用接口替换具象基类
摘要
大多数优秀的设计都像避免灾难一样避免实现继承(extends关系),差不多80%的代码应当完全以接口形式来写,而不是具象的基类。GOF的设计模式中,谈了很多关于怎样用接口继承替换实现继承,这篇文章主要讲述为什么设计者倾向于这样做。
_________________________________________________________________________
这个extends 关键字是邪恶的,不论何时,只要有可能就应当避免它,GOF的设计模式书中详细地讨论了用接口继承(implements)来替换实现继承(extends)。
优秀的设计者用interface写他们大部份的代码,而不是具象的基类。这篇文章讲为什么设计者倾向于这样做,而且引入了一些基于接口的基础程序。
接口和类
我曾经参加过一个Java用户群的会议,会上 James GoslingJava的发明者)是权威发言人。比较难忘的是问答时,有人问:“如果可以重新发明java,你会改变什么呢?”,“我会不要类”他这样回答,当笑声渐渐下去时候,他解释说真正的问题并不是类本身,但是比起实现继承(extends关系),接口继承(implements关系)更优秀。只要有可能,你就应当避免实现继承。
失去了灵活性
为什么应当避免实现继承?首要的问题是使用了具象类会使你受困于特定的实现,带来一些完全没有必要的麻烦。
当代敏捷开发方法论的核心是并行设计与开发,在你没有全部完成设计时,就可以开始开发工作了,这种方法公然违抗了传统的智慧开发必须在设计全部完成之后。但是很多成功的项目证明了这种方法可以比传统方法开发出更快更有效的完成高质量的开发。并行开发的核心就是灵活性。你可以随时把新的需求加入已成形的代码中,而不需要做太大的改动。
与其把你可能需要的特性都实现,不如仅实现你确定需要的功能,而可以让它适应变化,如果不具备这样的灵活性,并行开发是不可能的。
f()
{   LinkedList list = new LinkedList();
    //...
    g( list );
}

g( LinkedList list )
{
    list.add( ... );
    g2( list )
}
看以上代码,现在有一个新的需求就是快速查找,于是LinkedList 不适应了,需要把它换成一个HashSet,在已有的代码中,这个改变可并不是局部的,你除了改f(),还需要改g()(因为g()使用了LinkedList 参数),还有任何使调用了g()的地方。
我们重写代码如下:
f()
{   Collection list = new LinkedList();
    //...
    g( list );
}

g( Collection list )
{
    list.add( ... );
    g2( list )
}
我们通过把new LinkedList() 换为new HashSet() 而实现了 LindedList HashTable的转换,这样,就不用改其它代码了。
再来举一个例子,比较这二段代码:
As another example, compare this code:
f()
{   Collection c = new HashSet();
    //...
    g( c );
}

g( Collection c )
{
    for( Iterator i = c.iterator(); i.hasNext() ;)
        do_something_with( i.next() );
}
to this:
f2()
{   Collection c = new HashSet();
    //...
    g2( c.iterator() );
}

g2( Iterator i )
{   while( i.hasNext() ;)
        do_something_with( i.next() );
}
这个g2() 方法越过了Collection,同时也可以做为 Map 的关键字来取值,实际上,你可以写 iterators 来生成数据以替换对collectioin的遍历,这样就有了更大灵活性。
耦合
实现继承一个更重要的问题是耦合性----一部份程序依赖另一部份实在是一件不愉快的事。为什么强耦合会造成麻烦,全局变量提供了最好的例子。如果你改变了全局变量的类型,所有使用到了这个变量的functions都会受到影响,于是所有的这些代码都要被检测、修改、重新测试,而且,修改所有使用了这个变量的function影响到的其它地方,就这样,一个变量值的改变影响了一个方法,又造成另一个方法的错误。在程序中,这样问题实在是可怕。
做为一个设计者,你应当努力减小耦合关系。不能完全消除耦合,因为从一个类对像到另一个对像调用是松耦合方式的。不可能有哪个程序完全没有耦合,不过,你可以完全依照OO的设计原则那样来降低耦合(OO最重要的原则就是一个对像的实现应当对它的使用者完全隐藏实现细节),例如,一个对像的实例变量(非静态的域成员),应当是private,这一点没有疑义(你也可以偶尔用protected会更高效,但是protected实例变量是令人讨厌的),同样原因,你也应当不用get/set 方法---它们使一个public域变的过度复杂。()
在这里我并不是书生意气,我已经在自己的工作中发现严格按OO方法、快速的代码开发和轻松的维护是直接相关的。只要违反了OO的原则例如实现隐藏,我马上重新写那段代码(通常因为代码不能调试),我没有时间重新写整个的程序,所以我遵守原则,我所关心的开发实效----我没有兴趣为代码的纯净而去纯净。
脆弱的基类问题
现在,我们把耦合的观念用于继承,在一个实现继承的系统中,用户使用extendsderived classes 就紧紧耦合于base classes,设计者可以采用“脆弱的基类问题”来描述这种行为。基类被认为是很脆弱的,因为你修改了一个基类时,表面上看起来是安全的,但是这种行为很可能造成继承了它的derived classes 发生错误。你不能仅因为检测到基类的方法是孤立就,就判断对它的修改是否安全。你必须考虑到并且测试所有的derived classes,而且,你必须检测所有使用到了基类和derived classes的对像,因为这些代码可能被新的修改破坏了。对一个关键基类的改变可能会由传递效应造成整个系统的不可维护。
我们现在来一起测试一下脆弱的基类和基类耦合问题,下面的类继承了java ArrayList类,现在让它来实现stack的功能:
这段代码可以成功编译,但是由于基类不知道stack指针的任何情况,Stack 对像现在处于未定义状态,下一次调用push()把新的item放入第二个位置(stack指针的当前值)的时候,所以在Stack的效力范围内三个元素底部二个就是垃圾(JavaStack类没有这个问题)
解决这个由继承引发的问题,一个方案就是override 所有修改了数组状态的ArrayList 方法,overrides 可以stack指针指向正确,或者抛出一个异常(对于抛出异常来讲,removeRange()方法是一个很好的选择)。
这种方法有二个缺点,首先,假如你override 了所有的,基类可能真的是一个interface,不是一个class,如果你不用任何继承方法,就没有任何点来实现继承。第二点,也是更重要的,你并不想一个stack支持了ArrayList的所有方法,令人讨厌removeRange()方法是无用的,它存在的理由仅是为了抛出一个异常,由于它可能从未被调用,它却比较有效地把一个编译期错误移动运行期,这样并不好,如果一个方法没有定义,编译器抛出一个method-not-found 错误,如果方法已经定义但是它抛出一个异常,你可能直到程序真正运行起来都找不到它的调用。 [ 这一段有问题]
一个更好的解决方法是发布的基类封装数据结构体来代替继承,这里有一个新的改进过的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上创建一个版本来跟踪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()的时候起副作用了,起先, 这个细节看起来并不是一个坏事,它使代码变得简单,而且你得到了derived class 版本的 push(),甚至当Monitorable_stack 通过一个Stack reference 访问它的时候,high_water_mark 正确地更新了它。   [这一段译不通]
有一天,有人在运行优化时注意到 Stack 并不像它所能达到的那样快速工作,它看起来很沉重。你可以重写Stack,不用ArrayList 从而改善了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 这个drived class无论如何不能正常运行了,因为一旦 push_many() 被调用它就不能正常跟踪(因为新的derived-class 版本中,push不再被push_many()调用,所以push_many 再不能更新high_water_mark ,Stack 是一个脆弱的基类,当它创建的时候,就不可能避免这类问题,哪怕我们非常的仔细。
Listing 0.1. 中,我提供了一个基于接口的方法,当实现继承方案的时候,这个方法有相同的脆弱性问题:你可以以提取Stack 的方式写代码而不用去考虑正在操作的是哪一种具象的stack,由于二个实现都必须提供public interface 中的定义,它几乎不会出错,我仍然可以像写base-class那样只写一次代码,因为我使用封装而不是derivation,另一方面,我必须在封装类中通过一个小的accessor方法来访问默认的实现 Monitorable_Stack.push(...)41,必须在Simple_stack中调用相同的方法),程序员抱怨说写了这么多俏皮话,但是额外多写一行代码,对于消除潜在的重大错误只是杯水车薪。
Listing 0.1. Eliminate fragile base classes using interfaces
   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的光环已逐渐褪去,但是它深深地影响了无数的程序员认为MS的方法就是最好的。
一个基于框架的系统,最典型的开始于大量不完整的类库,它们并不能按需工作,而依赖于derived class 来提供所缺功能,Java中一个很好的例子就是 Component paint()方法,它有很多预留位,一个derived class 必须提供真正实现。
在一定程度上,你可以侥幸逃脱这类问题,但是一个整套的依赖于自定制的类框架是特别脆弱的,基类也是脆弱的。当我用MFC编程的时候,每次MS发布一个新版本,我就不得不重写所有的application。代码通常是可以编译通过,但是并不能正常工作,因为一些基类的方法已经改变了。
所有的java package 都工作的很出色,你不需要扩展什么事来完善它们的功能,这种works-out-of-the-box的结构要比derivation-based框架更好些,它易于使用和维护,而且不会让你的代码不会有什么由于Sun公司的支持类改变而造成的风险。
脆弱基类的小结
通常来讲,最好避免具像类和extends关系,而用interface implements 关系,凭我的经验,至少80%的代码可以完全以接口的形式写出,例如,我不用引用一个HashMap,而是引用一个Map Interface,(这里的interface 是一个宽泛的概念,InputStream 是一个很有效的接口,尽管它在java中是以abstract calss来实现的)
你增加的abstraction越多,就会获得越大的灵活性,在今天的商业环境中,在程序开发的时候需求规则就可能改变,这样的灵活性是必须的,而且,在所有的敏捷开发方法(例如XP),代码必须以抽象的形式来写。[此处可能有误]
如果你仔细读了GOF的设计模式,你将会看到他们提供了许多方法来减少实现继承,而用接口继承,这是一个所有模式公共的特点。重要的一点是:模式是被发现的,而不是被发明的,当你在看一个写的很好的,容易维护而且工作正常的代码时,模式就表现出来了。在很大地方它都告诉我们,避免实现继承,书写设计良好的、易于维护的代码。
 
译记:  只打了一遍,今天来不及检查和润色了。好多地方可能译的非常不好。  这是四天前发的那篇 why extends is evil .     回头再对照改改。