Java中如何消除实现继承和面向接口编程
要想一下子把实现继承和面向接口编程这两大问题梳理和消除,并不容易,尤其是考虑到自己的知识水平。坦白说,这又是一篇关于“炒冷饭”的文章,但这“冷饭”真的不好炒。所以,看完这篇文章,你应该批判性地接受(拒绝)我的观点,即使我的观点也来源于别人的观点。
继承是面向对象中的一个重要概念。考虑到Java语言的特点,继承可以分为两种:接口继承和实现继承。这只是技术问题。即使C++中不存在接口的概念,但它的虚基类实际上等同于接口。对于OO的初学者来说,他们渴望自己的程序中有很多继承,因为这样看起来很OO。但是滥用继承会带来很多问题,虽然有时候我们不得不用继承来解决问题。
与接口继承相比,实现继承的问题更多,会带来更多的耦合问题。但是接口继承也是有问题的,这是继承本身的问题。继承的很多问题都来自于它本身的实现,所以这里重点讨论实现继承的问题。
举个例子(这个例子太老套了)。实现一个Stack类,我想当然的认为Stack类继承自ArrayList类(你也可以认为我天生想OO或者懒);现在有了实现线程安全堆栈的新需求,我定义了一个ConcurrentStack类,它继承了Stack,覆盖了Stack中的部分代码。
因为Stack继承自ArrayList,所以Stack要公开ArrayList的所有公共方法,即使其中一些方法对它来说可能是不必要的;更糟糕的是,也许这些方法中的某些方法可以改变栈的状态,而栈并不知道这些改变,这会造成栈的逻辑错误。
如果我想给ArrayList添加一个新方法,这个方法可能会在逻辑上破坏它的派生类Stack和ConcurrentStack。所以在给基类(父类)添加方法(修改代码)时,需要检查这些修改是否会影响派生类;如果有影响,派生类就必须进一步修改。如果一个类的继承系统不是一个人完成的,或者修改了别人的代码,很可能继承会产生不易察觉的bug。
还是有一些问题。有时候我们看到这样的基类,它的一些方法只是抛出异常,也就是说如果派生类支持这个方法,就会被重写;否则,像父类那样抛出异常,说明它不支持这个方法的调用。我们还可以看到它的一个变种。父类的方法是抽象的,但是并不是所有的子类都支持这个方法,不支持的方法通过抛出异常来表明自己的位置。这种做法是非常不友好和不安全的。它们只能在运行时“碰运气”,很多逃离网络的异常方法可能某天突然出现,让人不知所措。
造成上述问题的重要原因是基类和派生类之间的耦合。往往只对基类做了很小的改动,但是它们的所有派生类都要重新构造,这就是臭名昭著的“脆弱基类”问题。因为类之间的关系是存在的,所以耦合是不可避免的,甚至是必要的。但是,在设计OO的时候,当我们遇到基类和派生类之间的强耦合关系时,就不得不去思考了。我们必须继承它吗?会有其他更优雅的选择吗?如果你一定是学究,你会在很多书上看到这个原理:如果两个类之间有IS-A关系,那么就用继承;如果两个类之间的关系是Has-A,那么使用delegate。在很多情况下,这个原则是适用的,但是IS-A不能作为使用继承的绝对理由。有时候为了消除耦合带来的问题,委托等方法可以更好的封装实现细节。传承有时候会对外和向下暴露太多信息。在GOF的设计模式中,有许多模式的目的是消除继承。
当采用继承时,一个重要的原则是确定方法是否可以共享。比如DAO,一般的CRUD方法可以设置在一个抽象的DAO中,所有具体的DAO都是从这个抽象类中派生出来的。严格地说,抽象道和派生道实现之间没有关系。我们做出这个技术选择是为了避免重复的方法定义和实现。可以说,使用接口或抽象类的原理是,如果多个派生类的方法内容之间没有共同点,就把接口作为抽象;如果多个派生类的方法有共同点,就用抽象类做抽象。当这个原则不适用于接口继承时,如果发生接口继承,相应地就会有实现继承(基类是更抽象的类)。
现在谈谈面向接口的编程。在众多敏捷方法中,面向接口编程一直被大师们反复强调。面向接口的编程实际上是面向抽象的编程,将抽象的概念从具体的实现中分离出来。这个原则使我们能够有一个更高层次的抽象模型。当面对不断变化的需求时,只要抽象模型做得好,修改代码就容易多了。然而,面向接口的编程并不意味着一个接口必须对应一个类。过多不必要的接口也可能带来更多的工作量和维护难度。
与继承相比,面向对象中多态性的概念更为重要。一个接口可以对应多个实现类。对于声明为接口类型的类的方法参数和字段,比实现类更容易扩展和稳定,这也是多态的优势。如果我用实现类作为方法参数定义了一个叫void do something (ArrayList)的方法,但是如果有一天领导觉得ArrayList不如LinkedList有用,我就要把方法重新构造成void doSomething(LinkedList)了。相应的,调用这个方法的地方都要修改参数类型(可惜我用ArrayList = NewArrayList()创建对象,会大大增加我的修改工作量)。如果领导觉得用list存储数据不如再次set,我会再次重构方法,但这次我变聪明了。我将方法定义为void doSomething(Set set),并将创建对象的方式改为set Set set = new HashSet()。但这仍然不够。领导要求把套改回单子怎么办?所以我应该把方法重构为void do something (Collection集合),这是集合的抽象程度,更容易替换具体的实现类。即使我需要List或Set的固有特性,我也可以进行向下类型转换来解决问题,尽管这样做并不安全。
面向接口编程最重要的价值在于隐藏实现,封装抽象的实现细节,不对外开放。封装对于Java EE中的分层设计和框架设计尤为重要。但即使在编程中使用了接口,我们也需要将接口与实现对应起来,这就导致了如何创建对象的问题。在创造性的设计模式中,singleton、工厂方法(模板方法)和抽象工厂都是很好的解决方案。现在流行的控制反转(也叫依赖注入)模式以声明的方式将抽象与实现连接起来,不仅减少了单调的工厂类,也使得单元测试更加容易。
做个总结。虽然我尽力批判不好的继承,提倡好的接口,但这也不是绝对的。继承和滥用接口会带来问题。很多做Java EE开发的朋友抱怨在DAO和Service中实现一个接口一个类,虽然这似乎已经成为业界的惯例之一。也许排除接口会让程序更薄,但是“薄”不一定“好”,要看项目的具体情况。至于继承和接口的做法,还是需要自己积累和总结经验。
0条评论