为什么老鸟要告诉你优先使用组合而不是继承?

大家知道,面向对象有三个特征:继承、封装和多态。现在,我们谈谈关于继承的一些问题。了解一下继承的优点、缺点,以及继承缺点的解决方案。

继承的起源,来自于多个类中相同特征和行为的抽象。子类可以通过继承父类,那么可以调用父类中定义的方法和属性,从而达到代码重用的目的。另外,子类除了重用父类的代码以外,还可以扩展自身的属性和方法,来描述子类特有的特征和行为。

例如:人类和马类这两个类,有什么共同的特征和行为呢?都有年龄,都要呼吸,都可以行动,都要吃奶。我们可以把人类和马类相同的特征和行为抽取出来,形成一个父类:哺乳动物。人类和马类只要继承哺乳动物这个父类。那么,就可以直接重用哺乳动物父类中定义的属性和行为,相同的属性和行为就可以不用再重复描述。所以,通过继承,可以达到代码很大的重用。

除此之外,人类和马类还可以再定义自己本身的特征和行为。比如,马要吃草,人可以说话。那么,人类和马类可以在继承了哺乳动物父类的同时,再定义说话或吃草的行为,来扩展自身的特征和行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Mammal{
private int age;
public void breath(){}
}
class ManKind extends Mammal {
public void speak(){}
}
……
ManKind m = new ManKind();
m.spaeak();//本类中定义的方法
m.breath();//父类中继承的方法

使用继承有很多优点,父类的大部分功能可以通过继承关系自动进入子类;修改或扩展继承而来的属性和方法较为容易。那么,如何判断两个类之间是否有继承关系呢?很简单,用”是”来判断。比如:马是动物。那么马继承于动物。玫瑰是植物。那么玫瑰继承于植物。骂人的时候,我们会说:你是畜牲,那么这个人继承于畜牲,象畜牲一样没有人性。你是猪,那么这个人继承于猪,象猪一样愚笨。这就是所谓的”is-a”。

但是,继承同样有很多缺点。人类在大千世界中,不断的认识世界,也不断的改造世界。人类曾经梦想,在天空中飞翔。那么,人类如何飞行呢?鸟可以飞行,那是因为鸟有翅膀这个特征,才会拥有飞这个行为。如果人类继承于鸟类,象鸟一样长出翅膀,那么也就可以有飞行的行为了。


《封神榜》中的雷震子和天使,其实就是人类想在天空中飞行的一种想法。那就是继承鸟这个类,拥有鸟类的翅膀和飞行的行为。

那么,人类想下海应该怎么办呢?于是人类又在想,只要将人类继承于鱼类,拥有鱼的尾巴,那么就可以下海了。随着这种想法的产生,美人鱼就出现了。

那么,如果人类既想飞行,又想下海应该怎么办呢?有人说,那还不简单,把人类同时继承于鸟类和鱼类不就行了吗。但是,不好意思,在java中,类只能单根继承。也就是说,一个类只能有一个父类,不能同时继承两个父类。所以,从这里就可以看出继承的其中一个缺点:无法通过继承的方式,重用多个类中的代码。

除此之外,继承还有第二个缺点,那就是,父类的属性和方法,子类是无条件继承的。也就是说,不管子类愿意不愿意,都必须继承父类所有的属性和方法。比如,每个人都有自己父母,如果父母比较富有,那么子女就吃得好穿得好。如果父母是穷人,那么子女吃不饱穿不暖。很显然,所有的人都希望自己是富二代,官二代,而不想成为穷二代。不过,人是无法选择自己父母的。所以,父母的一切,自己是无条件接受的。

同样道理,如果人类继承于鸟类,我们希望拥有的是:鸟的翅膀和飞的行为。但是,鸟还有吃虫的行为,鸟还有下蛋的行为。这些是我们不希望拥有的。不过,如果人类继承于鸟类的话,那么吃虫和下蛋的行为,人类就得无条件接受了。所以,使用继承很容易造成方法的污染。一旦父类的属性和方法,在子类中不能完全适用。那么,也就不应该使用继承关系了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Bird{
public void fly(){}
public void layEggs(){}
}
class ManKind extends Bird{
public void speak(){}
}
……
ManKind m = new ManKind();
m.spaeak();
m.fly();//人类希望拥有的飞行行为
m.layEggs();//人类不希望拥有的下蛋行为

还有,从父类继承而来的实现是静态的,不能在运行时发生改变,不够灵活。比如,有一个人从春熙路到天府广场去。

1
2
3
4
5
6
7
8
9
10
11
12
class Man{
public void run(){
System.out.println("前往天府广场");
}
}
class CDMan extends Man{
}
……
CDMan c = new CDMan();
c.run();

当我们调用run()方法时,就只能打印从父类中继承的run方法。如果这个人想骑单车去天府广场应该怎么办呢?有人说,简单,在CDMan中重写run()不就行了吗?

1
2
3
4
5
6
class CDMan extends Man{
@Override
public void run(){
System.out.println("骑单车去天府广场");
}
}

但是,如果这个人想骑单车走一段,再开汽车走一段,就没法通过继承和重写来实现了。所以,无论是从父类中继承的方法,还是子类重写的父类方法,实现的都是一种静态的复用。不能在运行时发生改变,灵活性比较差。


那么,如何解决继承的这些缺点呢?荀子在《劝学》中,有这样的一段话:”假舆马者,非利足也,而致千里;假舟楫者,非能水也,而绝江河。君子生非异也,善假于物也。”就是说,人可以骑马,即使这个人跑得不快,也可以到达千里之外。人可以坐船,即使这个人不会游泳,也可以到达江河的任何位置。君子其实没什么太多特别的地方,只不过善于利用工具而已。这就是所谓的”has-a”。拥有什么,或者使用什么。

荀子的这段话,指出了解决继承缺陷办法,那就是使用聚合/组合达到代码的复用。比如,人想上天怎么办呢?可以利用飞机上天。人想下海怎么办呢,可以利用轮船下海。并不要求人要长出翅膀,人要长出鱼尾。


这样的解决方案,其实就是告诉我们,与其我们”是什么”,倒不如我们”用什么”。也就是用聚合/组合复用,去代替继承复用。把一些特征和行为抽取出来,形成工具类。然后通过聚合/组合成为当前类的属性。再调用其中的属性和行为达到代码重用的目的。

换句话说,用”has-a”(有什么或用什么)去替代”is-a”(是什么)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Plane{
public void fly(){}
}
class Ship{
public void swim(){}
}
class ManKind {
private Plane p = new Plane();
private Ship s = new Ship();
public void fly(){
p.fly();
}
public void swim(){
s.swim();
}
}

从以上代码可以看出,通过聚合/组合关系,可以解决继承的缺点。由于一个类可以建多个属性,也就是可以聚合多个类。所以,可以通过聚合/组合关系,重用多个类中的代码。

另外,我们可以选择一个类中是否应该具有某种行为,从而决定应该聚合那些类,不应该聚合那些类。这样,通过聚合/组合关系,也可以避免继承所带的方法污染问题。所以,使用聚合/组合,具有很强的代码重用性和灵活性。

聚合/组合复用也可以在运行时动态进行。新对象可以使用聚合/组合关系,将新的责任委派到合适的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//交通工具接口
interface Vehicle{
public void run();
}
//自行车实现类
class Bike implements Vehicle{
public void run(){
System.out.println("骑单车行走");
}
}
//汽车实现类
class Car implements Vehicle{
public void run(){
System.out.println("开汽车行走");
}
}
//人类
class CDMan {
private Vehicle v;
public void run(){
v.run();
}
//更换交通工具
public void changeVehicle(Vehicle v){
this.v = v;
}
}
……
CDMan c = new CDMan();
c.changeVehicle(new Bike());
c.run();//骑单车行走
c.changeVehicle(new Car());
c.run();//开汽车行走

从这里可以看出,人类可以随时改换交通工具,达到行走的目的。这种方式可以在运行期间,随时改变接口属性的实现类。从而调用不同实现类描述的具体方法,灵活性很强。

总结:

继承和聚合/组合都可以达到代码重用的目的。继承有自身的优点,父类的大部分功能可以通过继承关系自动进入子类;修改或扩展继承而来的实现较为容易。

但是,继承同样有缺点,

  1. 无法通过继承达到多个类代码的重用。
  2. 父类的方法子类无条件继承,很容易造成方法污染。
  3. 从父类中继承的方法,是一种静态的复用。不能在运行时发生改变,不够灵活。

继承可以用,但使用继承需要谨慎。一般来说,使用继承有两个条件:

  1. 父类中所有的属性和方法,在子类中都适用。
  2. 子类不需要再去重用别的类中的代码。
    如果不能满足这两个条件,那么就应该使用聚合/组合关系去替代继承,来达到代码的复用。