面向对象之继承
前言
二胖最近对面向对象特征之一的继承有点困惑,也无法找清楚前后知识点之间的联系。特别是当老师指着PPT上的一段代码问输出结果时,二胖更是丈二和尚摸不着头脑,就算看着答案,二胖也都会疑惑同样的内容为什么在这段代码中先输出而在另一段代码中又变成了后输出。二胖最后还是决定找好友Bill大神求助。正文内容即为二胖与Bill大神交流后的总结。
正文
我们为什么要使用继承?
我们现在所学的这些技术点包括继承都是用于解决生活中实际问题的,在生活中就有这样一个例子:
class Student
{
String name;
int age;
void study()
{
System.out.println(name + "...student study.." + age);
}
}
class Worker
{
String name;
int age;
void work()
{
System.out.println(name + "...worker work.." + age);
}
}
上面这段代码本身是没有问题的,但仔细一想想,这段代码的复用性不是很高,假设又多了一个也有name和age属性的Teacher类时,岂不是又要把name和age属性在Teacher类中再写一遍,这可有点不太好。经过观察,我们其实发现这些类中都有name和age属性,只是各自具体的方法不同。于是我们进一步思考有没有方法可以把这些共性属性向上抽取出来呢。这种思想其实就是继承。于是上面的代码就可以变成下面这样:
class Person
{
String name;
int age;
}
class Student extends Person
{
void study()
{
System.out.println(name + "...student study.." + age);
}
}
class Worker extends Person
{
void work()
{
System.out.println(name + "...worker work.." + age);
}
}
上面代码中的Person类就被称为基类或者父类,而Student、Worker类就被称为子类。我们可以发现,上面这段代码明显比第一段代码的复用性更好(并且它还是多态的前提)。在Java中,就是用extends关键字来表示继承。
使用继承时我们应该注意些什么?
-
在Java中,不支持多继承即一个类有多个直接父类。试想如果Java支持了多继承,那么当多个直接父类中有一个相同的方法,而作为子类在编译器不允许出现相同方法的前提下应该如何继承这个方法?继承哪一个父类中的方法呢?所以在Java中是不支持这种多继承的。在后面的学习中,我们知道Java可以利用"多实现"来弥补这一缺陷。
-
在Java中,支持多重继承,这样就出现了继承体系。其实这可以在Java的API中清楚看到:
- 继承体系也为我们学习一个类提供了思路:我们可以先查看该体系中的顶层类,了解该体系的基本功能;再创建该体系中的最子类对象,完成功能的使用。
在这种继承关系中,成员的特点是如何体现的?
成员变量
当类与类通过extends关键字产生了关系后,我们比较关心的是子父类中的成员变量同名的问题。见下面的代码:
class Fu
{
private int num = 4;
public int getNum()
{
return num;
}
}
class Zi extends Fu
{
private int num = 5;
void show()
{
System.out.println(num);
}
}
public class Test
{
public static void main(String[] args)
{
Zi z = new Zi();
z.show();
}
}
输出结果很明显(因为num前省略了this关键字),但是我们如果想要输出父类中的num值该怎么办呢。在Java中,当本类的成员变量和局部变量同名时可以用this区分,而当子父类中的成员变量同名时可以用super区分。于是,代码如下:
class Fu
{
private int num = 4;
public int getNum()
{
return num;
}
}
class Zi extends Fu
{
private int num = 5;
void show()
{
System.out.println(num + "....." + super.getNum());
}
}
public class Test
{
public static void main(String[] args)
{
Zi z = new Zi();
z.show();
}
}
成员函数
在成员函数上,我们关注的问题与成员变量很相似:当子父类中成员函数(指函数签名)一模一样时,又会出现什么情况呢?见下面的代码:
class Fu
{
public static void show()
{
System.out.println("fu show run");
}
}
class Zi extends Fu
{
public static void show()
{
System.out.println("Zi show run");
}
}
class Test
{
public static void main(String[] args)
{
Zi z = new Zi();
z.show();
}
}
通过运行结果我们可以看出,最终执行的是子类中的方法。这种现象在Java中就被称为重写(也称为覆盖)。
需要注意以下几点:
-
函数还具有另外一个特性,那就是重载,但它是在同一个类中的概念,而重写如上所述是在子父类中的概念。
-
子类方法覆盖父类方法时,子类权限必须要大于等于父类的权限。
-
当对一个类进行子类的扩展即需要定义子类中该功能的特有内容并且子类需要保留父类的某些功能声明时就可以使用重载。
构造函数
构造函数是这三者中最复杂的。首先需要明确的是:子类无法继承父类的构造函数,原因是构造函数是用来给类进行初始化的,继承下来没有意义。其次我们注意到下面这段代码的执行结果:
class Fu
{
Fu()
{
System.out.println("fu run");
}
}
class Zi extends Fu
{
Zi()
{
System.out.println("zi run");
}
}
class Test
{
public static void main(String[] args)
{
Zi z = new Zi();
}
}
我们发现在子类访问构造函数创建对象时,父类构造函数也执行了并且在子类构造函数之前执行。究其原因就是在子类构造函数中的第一行有一个默认的隐式语句"super();",我们可以这样分析:子类继承了父类,获取到了父类中的内容,在使用父类内容之前,自然要先看父类是如何对自己的内容进行初始化的。这样理解的话super语句的存在就变得很自然了。
我们需要注意以下几点:
-
其实这个隐式的super()语句访问的就是父类中的空参构造函数,而由于空参构造函数是默认提供的(当然是在不写其他带参构造函数的前提下),所以这个super语句可以不写,但如果当父类中没有定义空参构造函数(即是写了其他带参的构造函数)时,那么子类的构造函数必须用super明确要调用父类中哪个构造函数。
-
由于父类的初始化需要先完成,所以super语句只能被定义在构造函数中的第一行,这时就不能再使用this关键字了(因为this也必须定义在第一行)。
总结对象的初始化过程
有了上面和之前学的一些知识点,我们就可以总结出对象的初始化过程了。我们知道类中可能有的成员有:成员变量、构造代码块、构造函数...,而构造函数有可能要访问成员变量,成员变量还有默认初始化、显式初始化过程,如果知道了这些过程的执行顺序,那么相应地我们就能够总结出对象的初始化过程了。
- 首先,我们需要弄清成员变量显式初始化过程的执行时机,见下面测试代码:
class Fu {
Fu() {
show();
}
void show() {
System.out.println("fu show");
}
}
class Zi extends Fu {
int num = 8;
Zi() {
System.out.println("Zi constructor..." + num);
}
void show() {
System.out.println("zi show..." + num);
}
}
public class ExtendsDemo5 {
public static void main(String[] args) {
Zi z = new Zi();
z.show();
}
}
通过上面代码的执行结果,我们可以看出:Zi类num变量的显式初始化是在父类初始化完毕之后进行的。于是,我们可以总结出下面的执行顺序:
Zi() {
/*super(); (如果没有继承,就相当于没有这一步)
子类成员变量显式初始化;*/
System.out.println("Zi constructor..." + num);
}
- 然后还需要弄清构造代码块的执行时机,见下面测试代码:
class Fu {
int num = 9;
{
System.out.println("Fu constructor code block");
}
Fu() {
show();
}
void show() {
System.out.println("Fu show..." + num);
}
}
class Zi extends Fu {
int num = 8;
{
System.out.println("Zi constructor code block");
}
Zi() {
show();
}
void show() {
System.out.println("Zi show..." + num);
}
}
public class Demo {
public static void main(String[] args) {
new Zi();
}
}
通过上面代码的执行结果,我们可以看出:构造代码块是在成员变量显式初始化之后执行。于是,我们可以总结出下面的执行顺序:
Zi() {
/*super(); (如果没有继承,就相当于没有这一步)
类成员变量显式初始化;
构造代码块初始化;*/
show();
}
综上所述,对象的初始化过程可以简略地总结为下:
-
Java虚拟机首先会读取指定路径下的class文件,并将其加载进内存,并会先加载该类的父类(如果有直接父类的情况下)。
-
在堆内存中开辟空间,分配地址。
-
在对象空间中,对对象中的属性进行默认初始化。
-
调用对应的构造函数进行初始化。
-
在构造函数中,会先调用父类中的构造函数进行初始化。
-
父类初始化完毕后,再对子类中的属性进行显式初始化。
-
再进行子类构造函数中的构造代码块初始化。
-
再进行子类构造函数中的特定初始化。
-
初始化完毕后,将地址值赋值给引用变量。