这时一篇关于面向对象程序(软件对我来说还太宽泛)中组合和继承的概念比较文章翻译,原文:http://www.artima.com/designtechniques/compoinh.html,翻译的不好请见谅.
组合与继承-组织类关系的两种基本方法,Bill Venners,于1998年10月发布于javaworld
摘要
在我的Desion Technique系列的这个部分中,我对组合和继承的可扩展性及性能进行分析,并且提出几点使用这两种方法的准则.
软件系统设计的一个基本步骤是明确不同类间的关系.这其中有两种基本的方法即继承和组合,尽管在你使用继承的时候编译器和虚拟机帮你完成了绝大部分工作,但你依然可以使用组合来实现继承的效果.这篇文章就对两种方式进行了比较以及展示了一些使用它们的准则或者前提.
关于继承
在通篇文章里,我准备采用下面这个例子进行示例说明:
class Fruit {
//...
}
class Apple extends Fruit {
//...
}
这个例子里,类Apple继承于Fruit,Fruit是Apple的父类,这里我不讨论多继承的情况,我会在下个月的Desion Technique的接口设计部分中谈及.这里是关于Apple和Fruit类的uml图:
Figure 1. The inheritance relationship
关于组合
在这里的"组合"仅仅意味着一个引用其它对象的实例变量,如下:
class Fruit {
//...
}
class Apple {
private Fruit fruit = new Fruit();
//...
}
这个示例表示Apple和Fruit类为组合关系,因为Apple中的实例变量引用自一个Fruit对象,Apple类被称作"前端"类,Fruit为"后台"类,在组合关系中,"前端"包含一个对"后台"类实例的引用.uml如下图:
Figure 2. The composition relationship
动态绑定,多态和变化
当你在两个类之间确定的继承关系,你就获得了动态绑定和多态这两个优点.动态绑定意味着虚拟机在运行时决定对象需要执行的方法指令(子类或者父类),多态意味着你能用一个父类的声明引用不同子类的对象实例.动态绑定和多态的一个主要好处是代码的更改更加简单.当你有一段代码中准备使用很多父类引用(就像是Fruit类引用),你在随后的过程就能创建一批它的子类而无需更改那段引用代码,动态绑定会确保引用的实际对象方法被正确执行,即使你没有子类实例,你也可以能使用父类进行引用并且保证能正确编译,因此,继承能因为添加一个子类而达到代码更改方便的目的,然而,不是只有继承才具有这个效果.
更改父类接口
在继承关系中,父类往往是"脆弱的",因为它的一点点改动都会波及到所有包含该类型引用的地方,准确地说,父类的"脆弱"体现于它所公开的接口.如果父类采用良好面向对象设计以至于每一个接口都相当清晰和职责划分清楚,那么任何的实现改动都不应该波及其它,但是这样还是会涉及到对于该父类引用的地方,更进一步地说,父类的接口更改会破坏任何具有相同声明的子类代码.
例如,假如你更改Fruit类中的返回类型,会破坏任何对Fruit类引用或者Apple类引用的代码,更多的,它破坏了任何覆盖该方法的子类代码以至于这些地方都会编译失败,直到你更改了每一除引用以及引用的可能直接引用.有时候也会称继承提供的封装为"弱封装",因为你会由于父类的更改而导致子类的代码(Apple类).继承一个优点就是重用父类代码.就像是如果Apple如果没有实现Fruit的方法,Apple的行为就会和父类Fruit保持一致,但是Apple类仅仅是弱封装了父类Fruit的行为,以达到代码复用的目的,Fruit接口的任何更改都会破坏Apple类的行为.
从继承到组合
考虑到继承关系使父类对于接口的更改的变得困难,可以尝试着另一种方式来实现继承的目的-组合.它被证明为当你想使用代码重用时的更好的选择.
通过继承实现的代码重用
为了对继承和组合在代码复用这部分进行更好的阐述,考虑到下面的简单示例:
class Fruit {
// Return int number of pieces of peel that
// resulted from the peeling activity.
public int peel() {
System.out.println("Peeling is appealing.");
return 1;
}
}
class Apple extends Fruit {
}
class Example1 {
public static void main(String[] args) {
Apple apple = new Apple();
int pieces = apple.peel();
}
}
当你运行Example1程序,由于Apple继承了Fruit的peel()方法,它会打印出"Peeling is appealing.".如果在未来的某个时间你想把peel()方法的返回值更改为Peel类型,你就会导致Example1程序无法编译,只要你没有明确通过Fruit实例调用peel方法,任何的peel()方法改动都会出现上述问题:
class Peel {
private int peelCount;
public Peel(int peelCount) {
this.peelCount = peelCount;
}
public int getPeelCount() {
return peelCount;
}
//...
}
class Fruit {
// Return a Peel object that
// results from the peeling activity.
public Peel peel() {
System.out.println("Peeling is appealing.");
return new Peel(1);
}
}
// Apple still compiles and works fine
class Apple extends Fruit {
}
// This old implementation of Example1
// is broken and won't compile.
class Example1 {
public static void main(String[] args) {
Apple apple = new Apple();
int pieces = apple.peel();
}
}
通过组合实现的代码复用
组合提供Apple类另一种对Fruit类的方法peel()的复用.我们能通过声明一个Fruit的实例对象并且定义一个它自己的peel()方法(仅仅是对Fruit实例peel()方法的调用封装),如下代码:
class Fruit {
// Return int number of pieces of peel that
// resulted from the peeling activity.
public int peel() {
System.out.println("Peeling is appealing.");
return 1;
}
}
class Apple {
private Fruit fruit = new Fruit();
public int peel() {
return fruit.peel();
}
}
class Example2 {
public static void main(String[] args) {
Apple apple = new Apple();
int pieces = apple.peel();
}
}
在这种组合的方式里,子类成为了"前端"类,父类是"后台"类,在继承中.子类隐士继承父类所有非私有方法.相对的,在组合中"前端"类必须在它自己的实现方法中明确调用"后台"类的对应方法.这个明确的调用有时候称"后台"类的"请求定向"或者"委托".组合由于"后台"类的任何改动不会破坏"前端"类的代码而提供了更强烈的代码复用,就像是更改Fruit的peel()方法不会强制Example2的代码,一种可能的改动如下:
class Peel {
private int peelCount;
public Peel(int peelCount) {
this.peelCount = peelCount;
}
public int getPeelCount() {
return peelCount;
}
//...
}
class Fruit {
// Return int number of pieces of peel that
// resulted from the peeling activity.
public Peel peel() {
System.out.println("Peeling is appealing.");
return new Peel(1);
}
}
// Apple must be changed to accomodate
// the change to Fruit
class Apple {
private Fruit fruit = new Fruit();
public int peel() {
Peel peel = fruit.peel();
return peel.getPeelCount();
}
}
// This old implementation of Example2
// still works fine.
class Example1 {
public static void main(String[] args) {
Apple apple = new Apple();
int pieces = apple.peel();
}
}
这个示例展示了"后台"类的更改只会波及到"前端"类而导致对Apple的peel实现进行了更改,但是Example2还是无需改动的.
组合和继承的比较
结合上面的示例,下面列举一下组合和继承的优缺点:
- "后台"类相比于父类在接口的更改上更加方便.就像前面的例子一样,"后台"类的更改要求"前端"类的的实现作出相应更改,但是这种更改不应用于"前端"类的接口.依赖于"前端"类调用的那些代码直到它自己的接口更改之前都能正常工作.相反,对于父类的接口更改不仅会波及它的子类,也会设计到对于这些依赖的调用代码.
- "前端"类的接口更改较之于子类更加简单(父类的脆弱导致的子类的过分依赖),你不能在没有确保更改后的新接口的返回类型兼容于更改之前返回类型.例如,你不能声明一个和父类方法签名一样但是返回值不一样的方法.而组合,能在不影响"后台"类的前提下更改"前端"类接口
- 组合能使你延后对于"后台"类实例的创建直至他们真的需要被用到,以及动态更改在"前端"对象生命周期中类型.而对于继承,父类对象的创建是立即的,它在子类的创建时就已经被创建了而且它成为了子类声明周期的一部分(无法销毁...)
- 继承相比于组合更容易添加子类实现多态.假如你的新类中有部分行为一定会在父类中实现.这对组合来讲是不可能的,除非你通过接口进行组合逻辑.
- 组合中的明确的方法调用或者委托较之于继承会往往会导致性能损失,这里用"往往(often)"是因为影响性能的因素很多,组合的方法调用可能只占及其微小.
- 不管是组合或者继承,更改实现都是很方便的.波及范围只会局限在内部的类中(子类,"前端"类)