详解Java的继承

“继承”是面向对象中的第二特征,体现了类与类之间的“is-a”关系。当两个类进行继承关联绑定的时候,子类自动具备来自于的父类的属性和行为,做到代码的复用和设计的分离。Java☕️作为一门面向对象的编程语言,对于继承也有相应的实现机制和语法。这篇博客我们就来详细总结一下……

Java继承的机制

类Object

首先,我们看看下面这个简单的自定义类

1
2
3
4
5
public class Student{
public void study(){
System.out.println("好好学习,天天向上。");
}
}

在这个简单类当中,我们定义了一个方法叫做study(学习),除此以外没有定义任何额外的东西。但是在使用时,我们却发现Student对象除了有study方法以外,还有一些其它的方法。

那么这些方法是哪里来的呢?这里,我们就要提到一个非常重要的类java.lang.Object❗️

Class Object is the root of the class hierarchy. Every class has Object as a superclass. All objects, including arrays, implement the methods of this class.
类 Object 是类层次结构的根类。每个类都使用 Object 作为超类。所有对象(包括数组)都实现这个类的方法。

JDKDOC文档很明确的告诉我们:Object类在整个Java的类继承体系中是处于绝对顶层的位置。它是所有类的、是祖宗。上图中,stu对象额外多出来的方法,就是来自于它。
我们在前面博客讲“继承”这个概念的时候提到过,所谓继承就是把共有的数据项和行为抽取到父类中,这样所有子类都会自动具备,从而达到复用性。那么由此可见,类Object的所有方法都是共有性最强的方法,只要是Java对象就肯定具备这些行为,这些方法的重要性也就不言而喻了。
⚠️每个Java程序员都必须掌握来自于Object的每一个方法,包括理论内容和使用细节

方法名 一句话描述 学习章节
finalize 销毁对象的方法,由GC调用 垃圾回收
equals 判断两个对象“业务”是否相等 方法重写
toString 返回对象的字符串描述 方法重写
hashCode 返回对象的哈希码值 集合框架
wait 导致当前线程等待 线程
notify 唤醒等待的单个线程 线程
notifyAll 唤醒等待的所有线程 线程
getClass 获取实例对象所属类型的类模版对象 反射
clone 克隆并返回当前对象的一个新的副本对象 原型模式

请大家在学习到相应章节的时候好好把握这些知识点。

关键字extends

在上面的学习中,我们知道了在默认情况下任意一个类都会自动继承Object类。那么,如果我们需要指定一个类继承于某个其它的父类类型呢?这个时候,我们就要用到extends关键字了。

1
2
3
4
5
6
7
public class SuperClass{
public void methodA(){......}
}
public class SubClass extends SuperClass{
public void methodB(){......}
}

这个语法非常的简单明了,大家都很容易学会,但是还是需要提示大家两点:

  1. 类SubClass的父类是类SuperClass,而类SuperClass的父类是默认的类Object;所以通过继承的传递性,SuperClass具备来自Object的所有数据和行为,SubClass具备来自于SuperClass的所有数据和行为,所以SubClass也具有来自Object的数据和行为;
  2. “extends”的英文本意是“扩展”,也就是说子类在父类的基础上是可以有变化性的。这种变化性体现为:增加子类新的属性和新的行为,或修改继承而来的行为(属性不能改!);

单继承

我们现在知道了:在默认情况下我们书写的一个Java类会自动继承Object类;如果使用”extends”关键字,我们也可以为它指定一个其它父类。那么,接下来的问题是“一个类可以指定几个父类呢”❓
在面向对象思维中只提到了有“继承”这种特性。但具体是“每个类可以同时继承多个父类(多继承)”还是“每个类只能有一个父类(单继承)”却并没有说明。所以现实当中,不同的面向对象的编程语言各有各的语法设计,而Java采用的是单继承

首先,无论“多继承”还是“单继承”各有优劣

多继承优点 多继承缺点
可以同时具备来自于多个父类的特征,让子类具有更大的丰富度 如果多个父类具有相同的特征,那么子类到底继承的是哪一个的?这会带来设计上的混乱。
继承结构会变得非常复杂,可能呈现出复杂的网状结构。这不利于程序设计的清晰度,增加了复杂度。
单继承优点 单继承缺点
类继承的层次结构非常清晰,设计上更容易把握住父类具有唯一共性 设计的丰富度会降低

其次,Java只是选择了“单继承”,并且提出解决丰富度的方案;
Java认为多重继承不是一个很重要并且没有就不行的特性,但同时有多继承带来的危害却大于它的好处,所以选择了舍弃。被舍弃的不仅仅是多继承,Java还舍弃了指针、舍弃了内存管理。说白了Java就是不信任所有的程序员都一定能够理性、安全和高效率的去处理这类复杂问,所以就在语言设计上用弱化复杂度(或者说灵活性、丰富度)去换取安全性罢了。
当然,放弃了多继承,不代表Java的表现力被弱化了。Java设计了其它语法来补充这种丰富度。比如:大家后面学习到的“接口”、“内部类”,都可以达到多继承同样的效果,却不需要在无谓的复杂度上做无谓的困扰。

Java继承的模型

我们在学习基本的类与对象章节的时候就提到过:当代码中出现new某个类构造方法的时候,就会在内存的堆区中产生一个该类型的对象。

那么现在在知识点上,我们引入了“继承”这个概念(就算不写也会有一个Object类作为父类),那么现在的模型是什么情况呢?
我们先用一个例子,来看看现象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SuperClass{
public SuperClass(){
System.out.println("父类构造方法");
}
}
public class SubClass extends SuperClass{
public SubClass(){
System.out.println("子类构造方法");
}
}
public class Test{
public static void main(String[] args){
SubClass sub = new SubClass();
}
}

运行后的效果:

在这里我们很明显发现,在代码中我们只要求产生子类SubClass的对象,但是在运行的时候除了SubClass的构造方法被调用了,父类SuperClass的构造方法也被调用了,而且还是先调用父类构造后调用子类构造。那么这个时候内存中发生了什么?到底产生了几个
对象呢?
在这里我们提出“内存叠加”的模型来帮助大家理解:

  1. 当我们new一个子类的构造方法时,程序流程会进入到该子类构造方法中;
  2. 子类构造方法的第一句会有默认的调用其父类的构造方法(这里涉及到super()的知识点),程序流程转入到父类构造方法中;
  3. 执行完父类的构造方法,就会在内存里面产生父类对象,然后再返回到子类构造代码中继续执行;
  4. 执行完子类的构造方法后,会在刚才产生的父类对象的下面叠加产生子类对象的部分。这两部分合在一起构成一个既拥有父类内容又拥有子类内容的完整子类对象;
  5. 最后把这个完整的子类对象的引用赋值给引用类型的变量。

上图把1到5个步骤分别进行了描述,希望大家仔细对应看清楚。无论有多少层的继承,其中心思想都是如此。比如:A类继承B类,B类继承C类,C类继承Object;那么new A()的时候也是先产生Object对象,再叠加上C类定义的部分,再叠加B类定义的部分,再叠加A类定义的部分,最后构成一个完整的A对象。这样A对象中既有自己的内容,也有继承树上各层父类的内容,这才形成最终继承的效果!
当然,这里只是描述了最基本的情况,由于类内部的内容(属性、构造、方法、访问修饰符等等)又有很多,所以我们还需要对细节进行更进一步的探寻,并进行理解为什么会有这样的效果。

继承相关知识点细节

构造方法

父类的构造方法不会被子类继承!
第一:构造方法语法上要求方法名必须和类名保持一致。所以如果子类继承了父类的构造方法,那么很明显两个语法就冲突了(父类构造方法的名字肯定是父类名,必然和子类名不一样)。
第二:构造方法是用来产生对象的。如果子类通过继承拥有了父类的构造方法,那么子类可以控制父类对象的产生?这也是违背场景的。

构造方法不能被重写!
这就不多说了,子类没有继承父类的构造方法,当然也不可能对父类构造进行重写实现了🤷‍♂️。

构造方法的作用就是在产生对象的时候,利用内存叠加实现继承机制。

this()与super()

this() super()
作用: 调用本类的其它构造方法;从而达到本类构造方法的代码可以复用的效果。 与继承无关。 调用父类的指定构造方法;从而达到使用指定的父类构造方法构造子类中的父类对象部分。 与继承有关。
位置: 构造方法的第一句;与super()互斥。 构造方法的第一句;与this()互斥。
默认: 没有 有,就是无参的super()

属性

父类的属性会被子类继承,无论访问修饰符!
在继承机制中已经讲过,产生子类对象会先产生父类对象部分,然后叠加子类特有部分,从而构成完整子类对象。所以父类的属性都会放入到父类对象部分,那么也就在完整子类对象中。

父类的private属性会被继承,但无法访问!
private修饰符其本意为:私有。父类如果把该属性定义为了private,即意味着该属性只能让父类自己在内部直接访问;而子类从语意上就不应该能够访问它(没有得到父类的允许)。场景上,你爸的钱是拿给你继承的,但是他老人家不允许你直接用,你小子就是用不了😏。

子类可以拥有和父类同名属性,不会发生覆盖。
1
2
3
4
5
6
7
public class SuperClass{
public int num = 10;
}
public class SubClass extends SuperClass{
public String num = "hello";
}

此时,在SubClass类的对象身上会有两个叫num的属性。来自父类的存放在父类对象部分,来自子类的存放在子类特有部分。这个时候如果用子类引用调用num属性,是操作的子类中的字符串num;用父类引用调用num属性,操作的是从父类继承而来的整型num。

1
2
3
4
5
6
7
8
public class Test{
public static void main(String[] args){
SubClass sub = new SubClass();//子类引用指向子类对象
System.out.println(sub.num);//打印:hello
SuperClass sup = (SuperClass)sub;//父类引用指向同一个子类对象
System.out.println(sup.num);//打印:10
}
}

只new了一次,在对象没有变仅仅是引用变化的情况下,很明显看到了两个属性的共存。
⚠️这种父类和子类各拥有一个同名属性的情况往往只发生在面试中。在真实场景中,如果是同一个属性,那么子类没有必要再定义一次;如果是两个属性,就应该把名字区分开。

方法

父类的方法会被子类继承,无论访问修饰符!

父类的private方法会被继承,但无法访问!
这两点与上面属性的效果一致,所以不再累述。😤

子类可以拥有和父类同名方法,符合重写规范的话会发生覆盖。
方法重写的规范包括:

  1. 方法名相同;
  2. 参数列表相同(包括参数个数、类型、顺序三者都相同);
  3. 返回类型相同;
  4. 子类方法的访问修饰符不能小于父类方法的访问修饰符;
  5. 子类方法的方法声明不能比父类方法的方法声明抛出更多的异常。

只有这5点均满足的情况下,才会发生重写;否则会报错或两个方法共存(重载)。

this.与super.

this. super.
位置: 类的构造方法或普通方法中均可书写; 类的构造方法或普通方法中均可书写;
作用: 访问本类定义的所有属性和行为,以及从父类继承而来且被访问修饰符允许的属性或行为; 访问从父类继承而来且被访问修饰符允许的属性或行为;
含义: 当前对象,生活化寓意就是“我”; 当前对象的父类对象部分,生活化寓意就是“我爹”;

通过这个对比我们可以看到,凡是用super能够操作到的属性或行为,this一定能够操作;而用this能够操作到的,super却不一定能够操作到;所以尽可能在代码中使用this来进行调用。
那么,什么时候使用super.操作呢?就是在调用重写方法的时候,this.重写方法()得到的是重写后的效果(即子类中定义的效果);用super.重写方法()得到的是重写前的效果(即父类中定义的效果)。

final

final这个关键字的含义为:最终的、不变的。
用final修饰的变量,由于赋予了不变的特性,所以就只能为常量了;
用final修饰的方法,同样由于赋予不变特性,所以子类就不能再重写该方法了;
用final修饰的类,是给这个类赋予不变特性,所以这个类不能再生成子类(子类完全可以看成是对父类的扩展和变化)。
🤔思考一下:为啥final不能修饰构造方法呢