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

继承是把双刃剑

通过前面几节,我们应该对继承有了一个比较好的理解,但之前我们说继承其实是把双刃剑,为什么这么说呢?一方面是因为继承是非常强大的,另一方面是因为继承的破坏力也是很强的。

继承的强大是比较容易理解的,具体体现在:

  • 子类可以复用父类代码,不写任何代码即可具备父类的属性和功能,而只需要增加特有的属性和行为。
  • 子类可以重写父类行为,还可以通过多态实现统一处理。
  • 给父类增加属性和行为,就可以自动给所有子类增加属性和行为。

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

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

继承破坏封装

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

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

我们通过一些例子来说明。这些例子主要用于演示,可以基本忽略其实际意义。

封装是如何被破坏的

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

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避免、优先使用组合、使用接口。如果要使用继承,我们也介绍了使用继承的三种场景下的注意事项。

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

原文地址:https://www.cnblogs.com/ivy-xu/p/12387329.html

时间: 2024-10-08 11:26:30

Java编程的逻辑 (18) - 为什么说继承是把双刃剑的相关文章

计算机程序的思维逻辑 (18) - 为什么说继承是把双刃剑【转】

继承是把双刃剑 通过前面几节,我们应该对继承有了一个比较好的理解,但之前我们说继承其实是把双刃剑,为什么这么说呢?一方面是因为继承是非常强大的,另一方面是因为继承的破坏力也是很强的. 继承的强大是比较容易理解的,具体体现在: 子类可以复用父类代码,不写任何代码即可具备父类的属性和功能,而只需要增加特有的属性和行为. 子类可以重写父类行为,还可以通过多态实现统一处理. 给父类增加属性和行为,就可以自动给所有子类增加属性和行为. 继承被广泛应用于各种Java API.框架和类库之中,一方面它们内部大

Effective Java 第三版——18. 组合优于继承

Tips <Effective Java, Third Edition>一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将近8年的时间,但随着Java 6,7,8,甚至9的发布,Java语言发生了深刻的变化. 在这里第一时间翻译成中文版.供大家学习分享之用. 18. 组合优于继承 继承是实现代码重用的有效方式,但并不总是最好的工具.使用不当,会导致脆弱的软件. 在包中使用继承是安全的,其中子类和父类的实现都在同一个程序员的控制之

Java编程的逻辑 (17) - 继承实现的基本原理

第15节我们介绍了继承和多态的基本概念,而上节我们进一步介绍了继承的一些细节,本节我们通过一个例子,来介绍继承实现的基本原理.需要说明的是,本节主要从概念上来介绍原理,实际实现细节可能与此不同. 例子 这是基类代码: public class Base { public static int s; private int a; static { System.out.println("基类静态代码块, s: "+s); s = 1; } { System.out.println(&qu

Java编程的逻辑 (87) - 类加载机制

上节,我们探讨了动态代理,在前几节中,我们多次提到了类加载器ClassLoader,本节就来详细讨论Java中的类加载机制与ClassLoader. 类加载器ClassLoader就是加载其他类的类,它负责将字节码文件加载到内存,创建Class对象.与之前介绍的反射.注解.和动态代理一样,在大部分的应用编程中,我们不太需要自己实现ClassLoader. 不过,理解类加载的机制和过程,有助于我们更好的理解之前介绍的内容,更好的理解Java.在反射一节,我们介绍过Class的静态方法Class.f

Java编程的逻辑 (19) - 接口的本质

数据类型的局限 之前我们一直在说,程序主要就是数据以及对数据的操作,而为了方便操作数据,高级语言引入了数据类型的概念,Java定义了八种基本数据类型,而类相当于是自定义数据类型,通过类的组合和继承可以表示和操作各种事物或者说对象. 但,这种只是将对象看做属于某种数据类型,并按该类型进行操作,在一些情况下,并不能反映对象以及对对象操作的本质. 为什么这么说呢?很多时候,我们实际上关心的,并不是对象的类型,而是对象的能力,只要能提供这个能力,类型并不重要.我们来看一些生活中的例子. 要拍个照片,很多

Java编程的逻辑 (24) - 异常 (上)

之前我们介绍的基本类型.类.接口.枚举都是在表示和操作数据,操作的过程中可能有很多出错的情况,出错的原因可能是多方面的,有的是不可控的内部原因,比如内存不够了.磁盘满了,有的是不可控的外部原因,比如网络连接有问题,更多的可能是程序的编程错误,比如引用变量未初始化就直接调用实例方法. 这些非正常情况在Java中统一被认为是异常,Java使用异常机制来统一处理,由于内容较多,我们分为两节来介绍,本节介绍异常的初步概念,以及异常类本身,下节主要介绍异常的处理. 我们先来通过一些例子认识一下异常. 初始

Java编程的逻辑 (35) - 泛型 (上) - 基本概念和原理

之前章节中我们多次提到过泛型这个概念,从本节开始,我们就来详细讨论Java中的泛型,虽然泛型的基本思维和概念是比较简单的,但它有一些非常令人费解的语法.细节.以及局限性,内容比较多. 所以我们分为三节,逐步来讨论,本节我们主要来介绍泛型的基本概念和原理,下节我们重点讨论令人费解的通配符,最后一节,我们讨论一些细节和泛型的局限性. 后续章节我们会介绍各种容器类,容器类可以说是日常程序开发中天天用到的,没有容器类,难以想象能开发什么真正有用的程序.而容器类是基于泛型的,不理解泛型,我们就难以深刻理解

Java编程的逻辑 (38) - 剖析ArrayList

从本节开始,我们探讨Java中的容器类,所谓容器,顾名思义就是容纳其他数据的,计算机课程中有一门课叫数据结构,可以粗略对应于Java中的容器类,我们不会介绍所有数据结构的内容,但会介绍Java中的主要实现,并分析其基本原理和主要实现代码. 前几节在介绍泛型的时候,我们自己实现了一个简单的动态数组容器类DynaArray,本节,我们介绍Java中真正的动态数组容器类ArrayList. 我们先来看它的基本用法. 基本用法 新建ArrayList ArrayList是一个泛型容器,新建ArrayLi

Java编程的逻辑 (91) - Lambda表达式

在之前的章节中,我们的讨论基本都是基于Java 7的,从本节开始,我们探讨Java 8的一些特性,主要内容包括: 传递行为代码 - Lambda表达式 函数式数据处理 - 流 组合式异步编程 - CompletableFuture 新的日期和时间API 本节,我们先讨论Lambda表达式,它是什么?有什么用呢? Lambda表达式是Java 8新引入的一种语法,是一种紧凑的传递代码的方式,它的名字来源于学术界的λ演算,具体我们就不探讨了. 理解Lambda表达式,我们先回顾一下接口.匿名内部类和