Java编程的逻辑 (18) - 为何说继承是把双刃剑

本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营连接http://item.jd.com/12299018.htmlhtml


继承是把双刃剑
编程

经过前面几节,咱们应该对继承有了一个比较好的理解,但以前咱们说继承实际上是把双刃剑,为何这么说呢?一方面是由于继承是很是强大的,另外一方面是由于继承的破坏力也是很强的。 swift

继承的强大是比较容易理解的,具体体如今:数组

  • 子类能够复用父类代码,不写任何代码便可具有父类的属性和功能,而只须要增长特有的属性和行为。
  • 子类能够重写父类行为,还能够经过多态实现统一处理。
  • 给父类增长属性和行为,就能够自动给全部子类增长属性和行为。

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

但,继承为何会有破坏力呢?主要是由于继承可能破坏封装,而封装能够说是程序设计的第一原则,另外一方面,继承可能没有反映出"is-a"关系。下面咱们详细来讲明。微信

继承破坏封装框架

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

继承可能破坏封装是由于子类和父类之间可能存在着实现细节的依赖。子类在继承父类的时候,每每不得不关注父类的实现细节,而父类在修改其内部实现的时候,若是不考虑子类,也每每会影响到子类。
函数

咱们经过一些例子来讲明。这些例子主要用于演示,能够基本忽略其实际意义。post

封装是如何被破坏的

咱们来看一个简单的例子,这是基类代码:

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就是可以添加数字,具体是怎么添加的,应该不用关心。

下面是子类代码:

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的代码以下所示:

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方法首先调用了父类的addAll方法,而父类的addAll方法经过add方法添加,因为动态绑定,子类的add方法会执行,子类的add也会作汇总操做。

能够看出,若是子类不知道基类方法的实现细节,它就不能正确的进行扩展。知道了错误,如今咱们修改子类实现,修改addAll方法为:

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

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

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

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,这个方法的做用是将全部添加的数字清空,代码以下:

public void clear(){
    for(int i=0;i<count;i++){
        arr[i]=0;
    }
    count = 0;
}

基类添加一个方法不须要告诉子类,Child类不知道Base类添加了这么一个方法,但由于继承关系,Child类却自动拥有了这么一个方法!所以,Child类的使用者可能会这么使用Child类:

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值:

@Override
public void clear() {
    super.clear();
    this.sum = 0;
}

以上,能够看出,父类不能随意增长公开方法,由于给父类增长就是给全部子类增长,而子类可能必需要重写该方法才能确保方法的正确性。

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

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

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

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

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

继承是应该被当作"is-a"关系使用的,可是,Java并无办法约束,父类有的属性和行为,子类并不必定都适用,子类还能够重写方法,实现与父类预期彻底不同的行为。

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

如何应对继承的双面性?

继承既强大又有破坏性,那怎么办呢?

  1. 避免使用继承
  2. 正确使用继承

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

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

使用final避免继承

在上节,咱们提到过final类和final方法,final方法不能被重写,final类不能被继承,咱们没有解释为何须要它们。经过上面的介绍,咱们就应该可以理解其中的一些缘由了。

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

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

优先使用组合而非继承

使用组合能够抵挡父类变化对子类的影响,从而保护子类,应该被优先使用。仍是上面的例子,咱们使用组合来重写一会儿类,代码以下:

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;
    }
}

这样,子类就不须要关注基类是如何实现的了,基类修改实现细节,增长公开方法,也不会影响到子类了。

但,组合的问题是,子类对象不能被当作基类对象,被统一处理了。解决方法是,使用接口。

使用接口

关于接口咱们暂不介绍,留待下节。

正确使用继承

若是要使用继承,怎么正确使用呢?使用继承大概主要有三种场景:

  1. 基类是别人写的,咱们写子类。
  2. 咱们写基类,别人可能写子类。
  3. 基类、子类都是咱们写的。 

第一种场景中,基类主要是Java API,其余框架或类库中的类,在这种状况下,咱们主要经过扩展基类,实现自定义行为,这种状况下须要注意的是:

  1. 重写方法不要改变预期的行为。
  2. 阅读文档说明,理解可重写方法的实现机制,尤为是方法之间的调用关系。
  3. 在基类修改的状况下,阅读其修改说明,相应修改子类。

第二种场景中,咱们写基类给别人用,在这种状况下,须要注意的是:

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

第三种场景,咱们既写基类、也写子类,关于基类,注意事项和第二种场景相似,关于子类,注意事项和第一种场景相似,不过程序都由咱们控制,要求能够适当放松一些。

小结

本节,咱们介绍了继承为何是把双刃剑,继承虽然强大,但继承可能破坏封装,而封装能够说是程序设计第一原则,继承还可能被误用,没有反映真正的"is-a"关系。

咱们也介绍了如何应对继承的双面性,一方面是避免继承,使用final避免、优先使用组合、使用接口。若是要使用继承,咱们也介绍了使用继承的三种场景下的注意事项。

本节提到了一个概念,接口,接口究竟是什么呢?

----------------

未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),从入门到高级,深刻浅出,老马和你一块儿探索Java编程及计算机技术的本质。用心写做,原创文章,保留全部版权。

-----------

更多相关原创文章

计算机程序的思惟逻辑 (13) - 类

计算机程序的思惟逻辑 (14) - 类的组合

计算机程序的思惟逻辑 (15) - 初识继承和多态

计算机程序的思惟逻辑 (16) - 继承的细节

计算机程序的思惟逻辑 (17) - 继承实现的基本原理

计算机程序的思惟逻辑 (19) - 接口的本质

计算机程序的思惟逻辑 (20) - 为何要有抽象类?

计算机程序的思惟逻辑 (21) - 内部类的本质

相关文章
相关标签/搜索