「MoreThanJava」Day 5:面向对象进阶——继承详解

  • 「MoreThanJava」 宣扬的是 「学习,不止 CODE」,本系列 Java 基础教程是本身在结合各方面的知识以后,对 Java 基础的一个总回顾,旨在 「帮助新朋友快速高质量的学习」
  • 固然 不论新老朋友 我相信您均可以 从中获益。若是以为 「不错」 的朋友,欢迎 「关注 + 留言 + 分享」,文末有完整的获取连接,您的支持是我前进的最大的动力!

Part 1. 继承概述

上一篇文章 中咱们简单介绍了继承的做用,它容许建立 具备逻辑等级结构的类体系,造成一个继承树。html

Animal 继承树

继承使您能够基于现有类定义新类。 新类与现有类类似,可是可能具备其余实例变量和方法。这使编程更加容易,由于您能够 在现有的类上构建,而没必要从头开始。java

继承是现代软件取得巨大成功的部分缘由。 程序员可以在先前的工做基础上继续发展并不断改进和升级现有软件。git

面向对象以前,写代码的一些问题

若是你有一个类的源代码,你能够复制代码并改变它变成你想要的样子。在面向对象编程以前,就是这样子作的。但至少有两个问题:程序员

❶ 很难保持仅仅有条。github

假设您已经有了几十个须要的类,而且须要基于原始类创造新的一些类,再基于新的类创造出更新的类,最终您将得到数十个源文件,这些源文件都是经过其余已更改的源文件的另外版本。面试

假设如今在一个源文件中发现了错误,一些基于它的源文件须要进行修复,可是对于其余源文件来讲,并不须要!没有细致的写代码的计划,您最终会陷入混乱....编程

❷ 须要学习原始代码。后端

假设您有一个复杂的类,基本上能够完成所需的工做,可是您须要进行一些小的修改。若是您修改了源代码,即便是进行了很小的更改,也可能会破坏某些内容。所以,您必须研究原始代码以确保所作的更改正确,这可能并不容易。设计模式

Java 的自动继承机制极大地缓解了这两个问题。app

单继承

用于做为新类模板的类称为 父类 (或超类或基类),基于父类建立的类称为 子类 (或派生类)

就像上图中演示的那样,箭头从子类指向父类。(在上图中,云表示类,而矩形表示对象,这样的表示的方法来自于 Grady Booch 写的《面向对象的分析和设计》一书。而在官方的 UML-统一建模语言 中,类和对象都用矩形表示,请注意这一点)

在 Java 中,子类仅从一个父类继承特征,这被称为 单继承 (与人类不一样)

有些语言容许"孩子"从多个"父母"那里继承,这被称为 多继承。但因为具备多重继承,有时很难说出哪一个父母为孩子贡献了哪些特征 (跟人类同样..)

Java 经过使用单继承避免了这些问题。(意思 Java 只容许单继承)

is-a 关系

上图显示了一个父类 (Video 视频类),一个子类 (Movie 电影类)。它们之间的实线表示 "is-a" 的关系:电影是视频。

注意,继承是在类之间,而不是在对象之间。 (上图两朵云都表明类)

父类是构造对象时使用的蓝图,子类用于构造看起来像父对象的对象,但具备附加功能的对象。

类之间的关系简述

简单地说,类和类之间的关系有三种:is-ahas-ause-a

  • is-a 关系也叫继承或泛化,好比学生和人的关系、手机和电子产品的关系都属于继承关系;
  • has-a 关系一般称之为关联,好比部门和员工的关系、汽车和引擎的关系都属于关联关系;关联关系若是是总体和部分的关联,那么咱们称之为 聚合关系;若是总体进一步负责了部分的生命周期 (总体和部分是不可分割的,同时同在也同时消亡),那么这种就是最强的关联关系,咱们称之为 合成 关系。
  • use-a 关系一般称之为依赖,好比司机有一个驾驶的行为 (方法),其中 (的参数) 使用到了汽车,那么司机和汽车的关系就是依赖关系。

利用类之间的这些关系,咱们能够在已有类的基础上来完成某些操做,也能够在已有类的基础上建立新的类,这些都是实现代码复用的重要手段。复用现有的代码不只能够减小开发的工做量,也有利于代码的管理和维护,这是咱们在平常工做中都会使用到的技术手段。

层级结构

上图显示了一个父类和一个子类的 层次结构,以及从每一个类构造的一些对象。这些对象用矩形表示,以表达它们比设计的类更真实。

在层次结构中,每一个类最多有一个父类,但可能有几个子类。 层次结构顶部的类没有父级。此类称为层次结构的

另外,一个类能够是另外一个子类的父类,也能够是父类的子类。就像人类同样,一我的是某些人类的孩子,也是其余人类的父母。(但在 Java 中,一个孩子只有一个父母)

Part 2. 继承的实现

从父类派生子类的语法是使用 extend 关键字:

class ChildClass extend ParentClass {
    // 子类的新成员和构造函数....
}

父类的成员 (变量和方法) 经过继承包含在子类中。其余成员将在其类定义中添加到子类。

视频观影 App 示例

Java 编程是经过建立类层次结构并从中实例化对象来完成的。您能够扩展本身的类或扩展已经存在的类。Java 开发工具包 (JDK) 为您提供了丰富的基类集合,您能够根据须要扩展这些基类。

(若是某些类已经使用 final 修饰,则没法继承)

下面演示了一个使用 Video 类做为基类的视频观影 App 的程序设计:

Video 基类:

class Video {

    private String title;   // name of video
    private int length;     // number of minutes

    // constructor
    public Video(String title, int length) {
        this.title = title;
        this.length = length;
    }

    public String toString() {
        return "title=" + title + ", length=" + length;
    }

    public String getTitle() { return title;}
    public void setTitle(String title) { this.title = title;}
    public int getLength() { return length;}
    public void setLength(int length) { this.length = length;}
}

Movie 电影类继承 Video:

class Movie extends Video {

    private String director;// name of the director
    private String rating;  // num of rating

    // constructor
    public Movie(String title, int length, String director, String rating) {
        super(title, length);
        this.director = director;
        this.rating = rating;
    }

    public String getDirector() { return director; }
    public String getRating() { return rating; }
}

这两个类均已定义:Video 类可用于构造视频类型的对象,如今 Movie 类可用于构造电影类型的对象。

Movie 类具备在 Video 中定义的成员变量和公共方法。

使用父类的构造函数

查看上方的示例,在 Movie 类的初始化构造函数中有一条 super(title, length); 的语句,是 "调用父类 Video 中带有 title、length 参数的构造器" 的简写形式。

因为 Movie 类的构造器不能访问 Video 类的私有字段,因此必须经过一个构造器来初始化这些私有字段。能够利用特殊的 super 语法调用这个构造器。

重要说明:super() 必须是子类构造函数中的第一条语句。 (这意味子类构造器老是会先调用父类的构造器) 这件事常常被忽略,致使的结果就是一些神秘的编译器错误消息。

若是子类的构造器没有显式地调用父类的构造器,将自动地调用父类的无参构造器。若是父类没有无参数的构造器,而且在子类的构造器中又没有显式地调用父类的其余构造器,Java 编译器就会报告一个错误。(在咱们的例子中 Video 缺乏无参数的构造函数,故👆上面图片代码会报错)

建立一个无参构造函数

关于构造函数的一些细节:

  1. 您能够显式为类编写无参数的构造函数。
  2. 若是您没有为类编写任何构造函数,那么将自动提供无参数构造函数 (称为默认构造函数)
  3. 若是为一个类编写了一个构造函数,则不会自动提供默认的构造函数。
  4. 所以:若是您为类编写了额外的构造函数,那么,则还必须编写一个无参数构造函数 (供子类调用)

在示例程序中,类 Video 包含构造函数,所以不会自动提供默认构造函数。 因此,Movie 类 super() 函数建议默认使用的构造函数 (会自动调用无参数构造函数) 会致使语法错误。

解决方法是将无参数构造函数显式放在类中 Video ,以下所示:

class Video {

    private String title;   // name of video
    private int length;     // number of minutes

    // no-argument constructor
    public Video() {
        this.title = "unknown";
        this.length = 0;
    }

    // constructor
    public Video(String title, int length) {
        this.title = title;
        this.length = length;
    }

    ...
}

覆盖方法

让咱们来实例化 Movie 对象:

public class Tester {

    public static void main(String[] args) {
        Video video = new Video("视频1", 90);
        Movie movie = new Movie("悟空传", 139, "郭子健", "5.9");
        System.out.println(video.toString());
        System.out.println(movie.toString());
    }
}

程序输出:

title=视频1, length=90
title=悟空传, length=139

movie.toString() 是 Movie 类直接继承自 Video 类,它并无使用 Movie 对象具备的新变量,所以并不会打印导演和评分。

咱们须要给 Movie 类添加新的 toString() 的使用方法:

// 添加到 Movie 类中
public String toString() {
    return "title:" + getTitle() + ", length:" + getLength() + ", director:" + getDirector()
        + ", rating:" + getRating();
}

如今,Movie 拥有了本身的 toString() 方法,该方法使用了继承自 Video 的变量和本身定义的变量。

即便父类有一个 toString() 方法,子类中新定义的 toString() 也会 覆盖 父类的版本。当子类方法的 签名 (就是返回值 + 方法名称 + 参数列表) 与父类相同时,子类的方法就会 覆盖 父类的方法。

如今运行程序,Movie 打印出了咱们指望的完整信息:

title=视频1, length=90
title:悟空传, length:139, director:郭子健, rating:5.9

有些人认为 superthis 引用是相似的概念,实际上,这样比较并不太恰当。这是由于 super 不是一个对象的引用,例如,不能将值 super 赋给另外一个对象变量,它只是一个指示编译器调用父类方法的特殊关键字。

正像前面所看到的那样,在子类中能够增长字段、增长方法或覆盖父类的方法,不过,继承绝对不会删除任何字段或方法。

Part 3. 更多细节

protected 关键字

若是类中建立的变量或者方法使用 protected 描述,则指明了 "就类用户而言,这是 private 的,但对于任何继承于此类的导出类或者任何位于同一个 内的类来讲,它是能够访问的"。下面咱们就上面的例子来演示:

public class Video {
    protected String title;   // name of video
    protected int length;     // number of minutes
    ...
}

public class Movie extends Video {
    ...
    public String toString() {
        return "title:" + title + ", length:" + length + ", director:" + director
            + ", rating:" + rating;
    }
    ...
}

protected 修饰以前,若是子类 Movie 要访问父类 Video 的 title 私有变量只能经过父类暴露出来的 getTitle() 公共方法,如今则能够直接使用。

向上转型

"为新的类提供方法" 并非继承技术中最重要的方面,其最重要的方面是用来表现新类和基类之间的关系。这种关系能够用 "新类是现有类的一种类型" 这句话加以归纳。

因为继承能够确保基类中全部的方法在子类中也一样有效,因此可以向基类发送的全部信息也一样能够向子类发送。例如,若是 Video 类具备一个 play() 方法, 那么 Movie 类也将一样具有。这意味着咱们能够准确地说 Movie 对象也是一种类型的 Video(体现 is-a 关系)

这一律念的体现用下面的例子来讲明:

public class Video {
    ...
    public void play() {}
    public static void start(Video video) {
        // ...
        video.play();
    }
    ...
}

// 测试类
public class Tester {
    public static void main(String[] args) {
        Movie movie = new Movie("悟空传", 139, "郭子健", "5.9");
        Video.start(movie);
    }
}

在示例中,start() 方法能够接受 Video 类型的引用,这是在太有趣了!

在测试类中,传递给 start() 方法的是一个 Movie 引用。鉴于 Java 是一个对类型检查十分严格的语言,接受某种类型 (上例是 Video 类型) 的方法一样能够接受另一种类型 (上例是 Movie 类型) 就会显得很奇怪!

除非你认识到 Movei 对象也是一种 Video 对象

start() 方法中,程序代码能够对 Video 和它全部子类起做用,这种将 Movie 引用转换为 Video 引用的动做,咱们称之为 向上转型 (这样称呼是由于在继承树的画法上,基类在子类的上方...)

Object 类

全部的类均具备父类,除了 Object 类。Java 类层次结构的最顶部就是 Object 类。

若是类没有显式地指明继承哪个父类,那么它会自动地继承自 Object 类。若是一个子类继承了一个父类,那么父类要么继承它的父类,要么自动继承 Object最终,全部的类都将 Object 做为祖先。

这意味着 Java 中的全部类都具备一些共同的特征。这些特征在被定义在 Object 中:

Object 类拥有的方法

(其中 finalize() 方法在 Java 9 以后弃用了,缘由是由于它自己存在一些问题,可能致使性能问题:死锁、挂起和其余问题...)

(想看源码能够打一个 Object,而后按住 Ctrl 不放,而后点击 Object 就能够进入 JDK 源码查看了,源码有十分规范的注释和结构,你有时甚至会发现一些有趣的东西...)

Java 之父 Gosling 设计的 Object 类,是对万事万物的抽象,是在哲学方向上进行的延伸思考,高度归纳了事物的天然行为和社会行为。咱们都知道哲学的三大经典问题:我是谁?我从哪里来?我到哪里去?在 Object 类中,这些问题均可以获得隐约的解答:

  1. 我是谁? getClass() 说明本质上是谁,而 toString() 是当前的名片;
  2. 我从哪里来? Object() 构造方法是生产对象的基本方式,clone() 是繁殖对象的另外一种方式;
  3. 我到哪里去? finalize() 是在对象销毁时触发的方法;(Java 9 以后已移除)

另外,Object 还映射了社会科学领域的一些问题:

  1. 世界是否因你而不一样? hashCode()equals() 就是判断与其余元素是否相同的一组方法;
  2. 与他人如何协调? wait()notify() 就是对象间通讯与协做的一组方法;

理解方法调用

准确地理解如何在对象上应用方法调用很是重要。下面假设咱们要调用 x.f(args)x 是声明为 C 的一个对象。下面是调用过程的详细描述:

  1. 编译器查看对象的声明类型和方法名。须要注意的是:有可能存在多个名字为 f 但参数类型不同的方法。例如,可能存在 f(int)f(String)。编译器将会一一列举 C 类中全部名为 f 的方法和其父类中全部名为 f 并且能够访问的方法 (父类中的私有方法不可访问)至此,编译器一直到全部可能被调用的候选方法。
  2. 接下来,编译器要肯定方法调用中提供的参数类型。若是在全部名为 f 的方法中存在一个与所提供参数类型彻底匹配的方法,就选择这个方法。这个过程称为 重载解析 (overloading resolution)。例如,对于调用 x.f("Hello"),编译期将会挑选 f(String),而不是 f(int)。因为容许类型转换 (例如,int 能够转换成 double),因此状况可能会变得很复杂。若是编译器没有找到与参数类型匹配的方法,或者发现通过类型转换后有多个方法与之匹配,编译器就会报错。至此,编译器已经知道须要调用的方法的名字和参数类型。
  3. 若是是 private 方法、static 方法、final 方法 (有关 final 修饰符会在下面讲到) 或者构造器,那么编译器将能够明确地知道应该调用哪一个方法。这称为 静态绑定 (static binding)。与此对应的是,若是要调用的方法依赖于隐式参数的实际类型,那么必须在运行时 动态绑定。在咱们的实例中,编译器会利用动态绑定生成一个调用 f(String) 的指令。
  4. 程序运行而且采用动态绑定调用方法时,虚拟机必须调用与 x 所引用对象的实际类型对应的那个方法。假设 x 的实际类型是 D,它是 C 类的子类。若是 D 类定义了方法 f(String),就会调用这个方法;不然,将在 D 类的父类中寻找 f(String),以此类推。

每次调用方法都要完成这样的搜索,时间开销至关大。所以,虚拟机预先为每一个类计算了一个 方法表 (method table), 其中列出了全部方法的签名和要调用的实际方法 (存着各个方法的实际入口地址)。这样一来,在真正调用方法的时候,虚拟机仅查找这个表就好了。(如下是 Video-父类 和 Movie-子类 的方法表结构演示图)

例如咱们调用上述例子 Movie 类的 play() 方法。

public void play() {};

因为 play() 方法没有参数,所以没必要担忧 重载解析 的问题。又不是 private/ static/ final 方法,因此将采用 动态绑定 的方式。

在运行时,调用 object.play() 的解析过程为:

  1. 首先,虚拟机获取 object 的实际类型的方法表。这多是 Video、Movie 的方法表,也多是 Video 类的其余子类的方法表;
  2. 接下来,虚拟机查找定义了 play() 签名的类。此时,虚拟机已经知道应该调用哪一个方法了;(这里若是 object 实际类型为 Movie 则调用 Movie.play(),为 Video 则调用 Video.play(),若是没找到才往父类去找..)
  3. 最后,虚拟机调用这个方法。

动态绑定有一个很是重要的特性:无须对现有的代码进行修改就能够对程序进行扩展。

假设如今新增一个类 ShortVideo,而且变量 object 有可能引用这个类的对象,咱们不须要对包含调用 object.play() 的代码从新进行编译。若是 object 刚好引用一个 ShortVideo 类的对象,就会自动地调用 object.play() 方法。

警告:在覆盖一个方法时,子类的方法 不能低于 父类方法的 可见性 (public > protected > private)。特别是,若是父类方法是 public,子类方法必须也要声明为 public

final 关键字

有时候,咱们可能但愿组织人们利用某个类定义子类。不容许扩展 (被继承) 的类被称为 final 类。若是在定义类的时候使用了 final 修饰符就代表这个类是 final 类了:

public final class ShortVideo extends Video { ... }

类中的某个特定方法也能够被声明为 final。若是这样作,子类就不能覆盖这个方法 (final 类中的全部方法自动地称为 final 方法)。例如:

public class Video {
    ...
    public final void Stop() { ... }
    ...
}

若是一个 字段 被声明为了 final 类型,那么对于 final 字段来讲,构造对象以后就不容许改变它们的值了。不过,若是将一个类声明为 final,只有其中的方法自动地称为 final,而不包括字段,这一点须要注意。

将方法或类声明为 final 的主要缘由是:确保它们不会在子类中改变语义。

JDK 中的例子

  • Calendar(JDK 实现的日历类) 中的 getTimesetTime 方法都声明为了 final,这就代表 Calendar 类的设计者负责实现 Data 类与日历状态之间的转换,而不容许子类来添乱。
  • 一样的,String 类也是 final(甚至面试中也常常出现),这意味着不容许任何人定义 String 的子类,换而言之,若是有一个 String 引用,它引用的必定是一个 String 对象,而不多是其余类的对象。

内联

在早起的 Java 中,有些程序员为了不动态绑定带来的系统开销而使用 final 关键字。若是一个方法没有被覆盖而且很短,编译器就可以对它进行优化处理,这个过程为 内联 (inlining)

例如,内联调用 e.getName() 会被替换为访问字段 e.name

这是一项颇有意义的改进,CPU 在处理当前指令时,分支会扰乱预取指令的策略,因此,CPU 不喜欢分支。然而,若是 getName 在另一个类中 被覆盖,那么编译器就没法知道覆盖的代码将会作什么操做,所以也就不能对它进行内联处理了。

幸运的是,虚拟机中的 即时编译器 (JIT) 比传统编译器的处理能力强得多。这种编译器能够准确地知道类之间的继承关系,并可以检测出是否有类确实覆盖了给定的方法。

若是方法很短、被频繁调用并且确实没有被覆盖,那么即时编译器就会将这个方法进行内联处理。若是虚拟机加载了另一个子类,而这个子类覆盖了一个内联方法,那么优化器将取消对这个方法的内联。这个过程很慢,不过不多会发生这种状况。

抽象类

在类的自下而上的继承层次结构中,位于上层的类更具备通常性,也更加抽象。从某种角度看,祖先类更具备通常性,人们一般只是将它做为派生其余类的基类,而不是用来构造你想使用的特定的实例。

考虑一个 Person 类的继承结构:

每一个人都有一些属性,如名字。学生与员工都有名字。

如今,假设须要增长一个 getDescription() 的方法,它返回对一我的简短的描述,学生类能够返回:一个计算机在读的学生,员工能够返回 一个在阿里就任的后端工程师 之类的。这在 Student 和 Employee 类中实现很容易,可是在 Person 类中应该提供什么内容呢? 除了姓名,Person 类对这我的一无所知。

有一个更好的方法,就是使用 abstract 关键字,把该方法定义为一个 抽象方法,这意味着你并不须要实现这个方法,只须要定义出来就行了:(如下代码为 Person 类中的抽象定义)

public abstract String getDescription() {}

为了提升程序的清晰度,包含一个或多个抽象方法的类自己必须被声明为抽象的:

public abstract class Person {
    ...
    public abstract String getDescription() {}
    ...
}

《阿里Java开发规范》强制规定抽象类命名 使用 AbstractBase 开头,这里只是作演示因此就简单用 Person 代替啦~

抽象方法充当着占位方法的角色,它们在子类中被继承并实现。

扩展抽象类能够由两种选择。一种是在子类中保留抽象类中的部分或全部抽象方法仍未实现,这样就必须将子类标记为抽象类 (由于还有抽象方法);另外一种作法就是实现所有方法,这样一来,子类就不是抽象的了。

(即便不包含抽象方法,也能够将类声明为抽象类)

抽象类不能实例化,也就是说,若是将一个类声明为 abstract,就不能建立这个类的实例,例如:new Person(); 就是错误的,但能够建立具体子类的对象:Person p = new Student(args);,这里的 p 是一个抽象类型 Person 的变量,它引用了一个非抽象子类 Student 的实例。

Part 4. 为何不推荐使用继承?

先别急着奇怪和愤懑,刚学习完继承以后,就告诉说不推荐使用,这是 有缘由的!

在面向对象编程中,有一条很是经典的设计原则:组合优于继承。使用继承有什么问题?组合相比继承有哪些优点?如何判断该用组合仍是继承?下面咱们就围绕这三个问题,来详细讲解一下。

如下内容大部分引用自:https://time.geekbang.org/column/article/169593

使用继承有什么问题?

上面说到,继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,能够解决代码复用的问题。虽然继承有诸多做用,但继承层次过深、过复杂,也会影响到代码的可维护性。咱们经过一个例子来讲明一下。

假设咱们要设计一个关于鸟的类,咱们将 “鸟类” 这样一个抽象的事物概念,定义为一个抽象类 AbstractBird。全部更细分的鸟,好比麻雀、鸽子、乌鸦等,都继承这个抽象类。

咱们知道,大部分鸟都会飞,那咱们可不能够在 AbstractBird 抽象类中,定义一个 fly() 方法呢?答案是否认的。尽管大部分鸟都会飞,但也有特例,好比鸵鸟就不会飞。鸵鸟继承具备 fly() 方法的父类,那鸵鸟就具备“飞”这样的行为,这显然不符合咱们对现实世界中事物的认识。固然,你可能会说,我在鸵鸟这个子类中重写 (override) fly() 方法,让它抛出 UnSupportedMethodException 异常不就能够了吗?具体的代码实现以下所示:

public class AbstractBird {
  //...省略其余属性和方法...
  public void fly() { //... }
}

public class Ostrich extends AbstractBird { //鸵鸟
  //...省略其余属性和方法...
  public void fly() {
    throw new UnSupportedMethodException("I can't fly.'");
  }
}

这种设计思路虽然能够解决问题,但不够优美。由于除了鸵鸟以外,不会飞的鸟还有不少,好比企鹅。对于这些不会飞的鸟来讲,咱们都须要重写 fly() 方法,抛出异常。

这样的设计,一方面,徒增了编码的工做量;另外一方面,也违背了咱们以后要讲的最小知识原则 (Least Knowledge Principle,也叫最少知识原则或者迪米特法则),暴露不应暴露的接口给外部,增长了类使用过程当中被误用的几率。

你可能又会说,那咱们再经过 AbstractBird 类派生出两个更加细分的抽象类:会飞的鸟类 AbstractFlyableBird 和不会飞的鸟类 AbstractUnFlyableBird,让麻雀、乌鸦这些会飞的鸟都继承 AbstractFlyableBird,让鸵鸟、企鹅这些不会飞的鸟,都继承 AbstractUnFlyableBird 类,不就能够了吗?具体的继承关系以下图所示:

从图中咱们能够看出,继承关系变成了三层。不过,总体上来说,目前的继承关系还比较简单,层次比较浅,也算是一种能够接受的设计思路。咱们再继续加点难度。在刚刚这个场景中,咱们只关注“鸟会不会飞”,但若是咱们关注更多的问题,例如 “鸟会不会叫”、”鸟会不会下单“ 等... 那这个时候,咱们又该如何设计类之间的继承关系呢?

总之,继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。这也是为何咱们不推荐使用继承。那刚刚例子中继承存在的问题,咱们又该如何来解决呢?

组合相比继承有哪些优点?

实际上,咱们能够利用组合 (composition)、接口、委托 (delegation) 三个技术手段,一起来解决刚刚继承存在的问题。

咱们前面讲到接口的时候说过,接口表示具备某种行为特性。针对“会飞”这样一个行为特性,咱们能够定义一个 Flyable 接口 (至关于定义某一种行为,下方会有代码说明),只让会飞的鸟去实现这个接口。对于会叫、会下蛋这些行为特性,咱们能够相似地定义 Tweetable 接口、EggLayable 接口。咱们将这个设计思路翻译成 Java 代码的话,就是下面这个样子:

public interface Flyable {
  void fly();
}
public interface Tweetable {
  void tweet();
}
public interface EggLayable {
  void layEgg();
}
public class Ostrich implements Tweetable, EggLayable {//鸵鸟
  //... 省略其余属性和方法...
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}
public class Sparrow impelents Flayable, Tweetable, EggLayable {//麻雀
  //... 省略其余属性和方法...
  @Override
  public void fly() { //... }
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}

不过,咱们知道,接口只声明方法,不定义实现。也就是说,每一个会下蛋的鸟都要实现一遍 layEgg() 方法,而且实现逻辑是同样的,这就会致使代码重复的问题。那这个问题又该如何解决呢?

咱们能够针对三个接口再定义三个实现类,它们分别是:实现了 fly() 方法的 FlyAbility 类、实现了 tweet() 方法的 TweetAbility 类、实现了 layEgg() 方法的 EggLayAbility 类。而后,经过 组合和委托 技术来消除代码重复。具体的代码实现以下所示:

public interface Flyable {
  void fly();
}
public class FlyAbility implements Flyable {
  @Override
  public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility

public class Ostrich implements Tweetable, EggLayable {//鸵鸟
  private TweetAbility tweetAbility = new TweetAbility(); //组合
  private EggLayAbility eggLayAbility = new EggLayAbility(); //组合
  //... 省略其余属性和方法...
  @Override
  public void tweet() {
    tweetAbility.tweet(); // 委托
  }
  @Override
  public void layEgg() {
    eggLayAbility.layEgg(); // 委托
  }
}

固然啦,也可使用 JDK 1.8 以后支持的接口默认方法:

public interface Flyable {
    default void fly() {
        // fly的 的默认实现
    }
}

咱们知道继承主要有三个做用:表示 is-a 关系,支持多态特性,代码复用。而这三个做用均可以经过其余技术手段来达成。好比:

  • is-a 关系,咱们能够经过组合和接口的 has-a 关系来替代;
  • 多态特性咱们能够利用接口来实现;
  • 代码复用咱们能够经过组合和委托来实现;

因此,从理论上讲,经过组合、接口、委托三个技术手段,咱们彻底能够替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。

如何判断该用组合仍是继承?

尽管咱们鼓励多用组合少用继承,但组合也并非完美的,继承也并不是一无可取。从上面的例子来看,继承改写成组合意味着要作更细粒度的类的拆分。这也就意味着,咱们要定义更多的类和接口。类和接口的增多也就或多或少地增长代码的复杂程度和维护成本。因此,在实际的项目开发中,咱们仍是要根据具体的状况,来具体选择该用继承仍是组合。

若是类之间的继承结构稳定 (不会轻易改变),继承层次比较浅 *(好比,最多有两层继承关系),继承关系不复杂,咱们就能够大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,咱们就尽可能使用组合来替代继承。

除此以外,还有一些 设计模式 会固定使用继承或者组合。好比,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了 组合关系,而 模板模式(template pattern)使用了 继承关系

前面咱们讲到继承能够实现代码复用。利用继承特性,咱们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的。可是,有的时候,从业务含义上,A 类和 B 类并不必定具备继承关系。好比,Crawler 类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具备继承关系 (既不是父子关系,也不是兄弟关系)仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。若是不熟悉背后设计思路的同事,发现 Crawler 类和 PageAnalyzer 类继承同一个父类,而父类中定义的却只是 URL 相关的操做,会以为这个代码写得莫名其妙,理解不了。这个时候,使用组合就更加合理、更加灵活。具体的代码实现以下所示:

public class Url {
  //...省略属性和方法
}

public class Crawler {
  private Url url; // 组合
  public Crawler() {
    this.url = new Url();
  }
  //...
}

public class PageAnalyzer {
  private Url url; // 组合
  public PageAnalyzer() {
    this.url = new Url();
  }
  //..
}

还有一些特殊的场景要求咱们必须使用继承。若是你不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承来实现。好比下面这样一段代码,其中 FeignClient 是一个外部类,咱们没有权限去修改这部分代码,可是咱们但愿能重写这个类在运行时执行的 encode() 函数。这个时候,咱们只能采用继承来实现了。

public class FeignClient { // Feign Client框架代码
  //...省略其余代码...
  public void encode(String url) { //... }
}

public void demofunction(FeignClient feignClient) {
  //...
  feignClient.encode(url);
  //...
}

public class CustomizedFeignClient extends FeignClient {
  @Override
  public void encode(String url) { //...重写encode的实现...}
}

// 调用
FeignClient client = new CustomizedFeignClient();
demofunction(client);

尽管有些人说,要杜绝继承,100% 用组合代替继承,可是个人观点没那么极端!之因此 “多用组合少用继承” 这个口号喊得这么响,只是由于,长期以来,咱们过分使用继承。仍是那句话,组合并不完美,继承也不是一无可取。只要咱们控制好它们的反作用、发挥它们各自的优点,在不一样的场合下,恰当地选择使用继承仍是组合,这才是咱们所追求的境界。

要点回顾

  1. 继承概述 / 单继承 / is-a 关系 / 类之间的关系 / 层级结构;
  2. 继承的实现 / 覆盖方法 / protedcted / 向上转型;
  3. Object 类 / 方法调用 / final / 内联 / 为何不推荐使用继承;

练习

暂无;

参考资料

  1. 《Java 核心技术 卷 I》
  2. 《Java 编程思想》
  3. 《码出高效 Java 开发手册》
  4. 设计模式之美 - 为什么说要多用组合少用继承?如何决定该用组合仍是继承? - https://time.geekbang.org/column/article/169593
  5. Introduction to Computer Science using Java - http://programmedlessons.org/Java9/index.html#part02
  • 本文已收录至个人 Github 程序员成长系列 【More Than Java】,学习,不止 Code,欢迎 star:https://github.com/wmyskxz/MoreThanJava
  • 我的公众号 :wmyskxz,我的独立域名博客:wmyskxz.com,坚持原创输出,下方扫码关注,2020,与您共同成长!

很是感谢各位人才能 看到这里,若是以为本篇文章写得不错,以为 「我没有三颗心脏」有点东西 的话,求点赞,求关注,求分享,求留言!

创做不易,各位的支持和承认,就是我创做的最大动力,咱们下篇文章见!

相关文章
相关标签/搜索