4.4 为什么说继承是把双刃剑

4.4 为什么说继承是把双刃剑

继承其实是把双刃剑:一方面继承是非常强大的;另一方面继承的破坏力也是很强的。

继承广泛应用于各种Java API、框架和类库之中,一方面它们内部大量使用继承,另一方面它们设计了良好的框架结构,提供了大量基类和基础公共代码。使用者可以使用继承,重写适当方法进行定制,就可以简单方便地实现强大的功能。

但,继承为什么会有破坏力呢?主要是因为继承可能破坏封装,而封装可以说是程序设计的第一原则;另外,继承可能没有反映出is-a关系。下面我们详细来说明。

4.4.1 继承破坏封装

什么是封装呢?封装就是隐藏实现细节,提供简化接口。使用者只需要关注怎么用,而不需要关注内部是怎么实现的。实现细节可以随时修改,而不影响使用者。函数是封装,类也是封装。通过封装,才能在更高的层次上考虑和解决问题。可以说,封装是程序设计的第一原则,没有封装,代码之间会到处存在着实现细节的依赖,则构建和维护复杂的程序是难以想象的。

继承可能破坏封装是因为子类和父类之间可能存在着实现细节的依赖。子类在继承父类的时候,往往不得不关注父类的实现细节,而父类在修改其内部实现的时候,如果不考虑子类,也往往会影响到子类。我们通过一些例子来说明。这些例子主要用于演示,可以基本忽略其实际意义。

4.4.2 封装是如何被破坏的

我们来看一个简单的例子,基类Base如代码清单4-10所示。

代码清单4-10 继承破坏封装:基类Base
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Base {
private static final int MAX_NUM = 1000;
private int[] arr = new int[MAX_NUM];
private int count;
public void add(int number){
if(count<MAX_NUM){
arr[count++] = number;
}
}
public void addAll(int[] numbers){
for(int num : numbers){
add(num);
}
}
}

Base提供了两个方法add和addAll,将输入数字添加到内部数组中。对使用者来说, add和addAll就是能够添加数字,具体是怎么添加的,不用关心。

子类代码Child如代码清单4-11所示。

代码清单4-11 继承破坏封装:子类Child
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Child extends Base {
private long sum;
@Override
public void add(int number) {
super.add(number);
sum+=number;
}
@Override
public void addAll(int[] numbers) {
super.addAll(numbers);
for(int i=0; i<numbers.length; i++){
sum+=numbers[i];
}
}
public long getSum() {
return sum;
}
}

子类重写了基类的add和addAll方法,在添加数字的同时汇总数字,存储数字的和到实例变量sum中,并提供了方法getSum获取sum的值。使用Child的代码如下所示:

1
2
3
4
5
public static void main(String[] args) {
Child c = new Child();
c.addAll(new int[]{1,2,3});
System.out.println(c.getSum());
}

使用addAll添加1、2、3,期望的输出是1+2+3=6,实际输出为12!为什么是12呢?查看代码不难看出,同一个数字被汇总了两次。子类的addAll方法首先调用了父类的add-All方法,而父类的addAll方法通过add方法添加,由于动态绑定,子类的add方法会执行,子类的add也会做汇总操作。

可以看出,如果子类不知道基类方法的实现细节,它就不能正确地进行扩展。知道了错误,现在我们修改子类实现,修改addAll方法为:

1
2
3
4
@Override
public void addAll(int[] numbers) {
super.addAll(numbers);
}

也就是说,addAll方法不再进行重复汇总。这次,程序就可以输出正确结果6了。

但是,基类Base决定修改addAll方法的实现,改为下面代码:

1
2
3
4
5
6
7
public void addAll(int[] numbers){
for(int num : numbers){
if(count<MAX_NUM){
arr[count++] = num;
}
}
}

也就是说,它不再通过调用add方法添加,这是Base类的实现细节。但是,修改了基类的内部细节后,上面使用子类的程序却错了,输出由正确值6变为了0。

从这个例子,可以看出,子类和父类之间是细节依赖,子类扩展父类,仅仅知道父类能做什么是不够的,还需要知道父类是怎么做的,而父类的实现细节也不能随意修改,否则可能影响子类

更具体地说,子类需要知道父类的可重写方法之间的依赖关系,具体到上例中,就是add和addAll方法之间的关系,而且这个依赖关系,父类不能随意改变

即使这个依赖关系不变,封装还是可能被破坏。还是上面的例子,我们先将addAll方法改回去,这次,我们在基类Base中添加一个方法clear,这个方法的作用是将所有添加的数字清空,代码如下:

1
2
3
4
5
6
public void clear(){
for(int i=0; i<count; i++){
arr[i]=0;
}
count = 0;
}

基类添加一个方法不需要告诉子类,Child类不知道Base类添加了这么一个方法,但因为继承关系,Child类却自动拥有了这么一个方法。因此,Child类的使用者可能会这么使用Child类:

1
2
3
4
5
6
7
public static void main(String[] args) {
Child c = new Child();
c.addAll(new int[]{1,2,3});
c.clear();
c.addAll(new int[]{1,2,3});
System.out.println(c.getSum());
}

先添加一次,之后调用clear清空,又添加一次,最后输出sum,期望结果是6,但实际输出是12。因为Child没有重写clear方法,它需要增加如下代码,重置其内部的sum值:

1
2
3
4
5
@Override
public void clear() {
super.clear();
this.sum = 0;
}

可以看出,父类不能随意增加公开方法,因为给父类增加就是给所有子类增加,而子类可能必须要重写该方法才能确保方法的正确性

总结一下:对于子类而言,通过继承实现是没有安全保障的,因为父类修改内部实现细节,它的功能就可能会被破坏;而对于基类而言,让子类继承和重写方法,就可能丧失随意修改内部实现的自由

4.4.3 继承没有反映is-a关系

继承关系是设计用来反映is-a关系的,子类是父类的一种,子类对象也属于父类,父类的属性和行为也适用于子类。就像橙子是水果一样,水果有的属性和行为,橙子也必然都有。

但现实中,设计完全符合is-a关系的继承关系是困难的。比如,绝大部分鸟都会飞,可能就想给鸟类增加一个方法fly()表示飞,但有一些鸟就不会飞,比如企鹅。

在is-a关系中,重写方法时,子类不应该改变父类预期的行为,但是这是没有办法约束的。还是以鸟为例,你可能给父类增加了fly()方法,对企鹅,你可能想,企鹅不会飞,但可以走和游泳,就在企鹅的fly()方法中,实现了有关走或游泳的逻辑。

继承是应该被当作is-a关系使用的,但是,Java并没有办法约束,父类有的属性和行为,子类并不一定都适用,子类还可以重写方法,实现与父类预期完全不一样的行为。

但对于通过父类引用操作子类对象的程序而言,它是把对象当作父类对象来看待的,期望对象符合父类中声明的属性和行为。如果不符合,结果是什么呢?混乱。

4.4.4 如何应对继承的双面性

继承既强大又有破坏性,那怎么办呢?
1)避免使用继承;
2)正确使用继承。

我们先来看怎么避免继承,有三种方法:

  • 使用final关键字;
  • 优先使用组合而非继承;
  • 使用接口。

1.使用final避免继承

在4.2节,我们提到过final类和final方法,final方法不能被重写,final类不能被继承,我们没有解释为什么需要它们。通过上面的介绍,我们就应该能够理解其中的一些原因了。

给方法加final修饰符,父类就保留了随意修改这个方法内部实现的自由,使用这个方法的程序也可以确保其行为是符合父类声明的。

给类加final修饰符,父类就保留了随意修改这个类实现的自由,使用者也可以放心地使用它,而不用担心一个父类引用的变量,实际指向的却是一个完全不符合预期行为的子类对象。

2.优先使用组合而非继承

使用组合可以抵挡父类变化对子类的影响,从而保护子类,应该优先使用组合。还是上面的例子,我们使用组合来重写一下子类,如代码清单4-12所示。

代码清单4-12 使用组合实现子类Child
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Child {
private Base base;
private long sum;
public Child(){
base = new Base();
}
public void add(int number) {
base.add(number);
sum+=number;
}
public void addAll(int[] numbers) {
base.addAll(numbers);
for(int i=0; i<numbers.length; i++){
sum+=numbers[i];
}
}
public long getSum() {
return sum;
}
}

这样,子类就不需要关注基类是如何实现的了,基类修改实现细节,增加公开方法,也不会影响到子类了。但组合的问题是,子类对象不能当作基类对象来统一处理了。解决方法是使用接口。接口是什么呢?我们留待下章介绍。

3.正确使用继承

如果要使用继承,怎么正确使用呢?使用继承大概主要有三种场景:
1)基类是别人写的,我们写子类;
2)我们写基类,别人可能写子类;
3)基类、子类都是我们写的。

第1种场景中,基类主要是Java API、其他框架或类库中的类,在这种情况下,我们主要通过扩展基类,实现自定义行为,这种情况下需要注意的是:

  • 重写方法不要改变预期的行为;
  • 阅读文档说明,理解可重写方法的实现机制,尤其是方法之间的依赖关系;
  • 在基类修改的情况下,阅读其修改说明,相应修改子类。

第2种场景中,我们写基类给别人用,在这种情况下,需要注意的是:

  • 使用继承反映真正的is-a关系,只将真正公共的部分放到基类;
  • 对不希望被重写的公开方法添加final修饰符;
  • 写文档,说明可重写方法的实现机制,为子类提供指导,告诉子类应该如何重写;
  • 在基类修改可能影响子类时,写修改说明。

第3种场景,我们既写基类也写子类,关于基类,注意事项和第2种场景类似,关于子类,注意事项和第1种场景类似,不过程序都由我们控制,要求可以适当放松一些。

至此,关于继承就介绍完了,本章最后,我们提到了一个概念:接口,接口到底是什么呢?让我们下章探讨。