5.继承与多态
5.1为什么要继承
最近我儿子迷上了一款吃鸡游戏《香肠派对》,无奈给他买了许多玩具枪,我数了下,有一把狙击枪AWM,一把步枪AK47,一把重机枪加特林(Gatling)。假如我们把这些玩具枪抽象成类,类图的示意图大致如下:
我们发现,这3者之间有很多相同的属性和方法(红色部分)。有没有什么办法能够减少这种编写重复代码的办法呢?Java提供了继承来解决这个问题。我们可以在更高一层抽象一个枪类,在枪类里面编写这些重复的属性和方法,然后其余的枪都继承自枪类,它们只需要编写各自独有的属性和方法即可,使用继承优化后的类图设计如下:
在Java中,使用extends关键字来实现继承,我们把代码示例如下:
package com.javadss.javase.ch05; // 枪类 class Gun { private String name; private String color; public String getName() { return this.name; } public String getColor() { return this.color; } public void shoot() { System.out.println("单发"); } public void loadBullet() { System.out.println("装弹"); } } // AWM类 class AWM extends Gun { private String gunsight; private String gunstock; // 安装瞄准器 public void loadGunsight(String gunsight) { this.gunsight = gunsight; } // 安装支架 public void loadGunstock(String gunstock) { this.gunstock = gunstock; } } // AK47类 class AK47 extends Gun { private String gunsight; // 安装瞄准器 public void loadGunsight(String gunsight) { this.gunsight = gunsight; } // 连发 public void runingShoot() { System.out.println("连发"); } } // 加特林类 class Gatling extends Gun { private String gunstock; // 安装支架 public void loadGunstock(String gunstock) { this.gunstock = gunstock; } // 连发 public void runingShoot() { System.out.println("连发"); } }
我们看到,类AWM、AK47、Gatling的定义都加上了extends Gun,表示它们都继承Gun类。在面向对象的术语中,我们把Gun叫做超类(superclass)、基类(base class)、父类(parent class),把AWM、AK47、Gatling叫做子类(subclass)、派生类(derived class)、孩子类(child class)。不过在Java中,我们一般习惯用超类和子类的方式来称呼。
5.2继承层次
事实上,继承是可以多层次的,上面我们的AWM继承自Gun,狙击AWM其实还有一些变种,例如AWP,我们可以再编写一个AWP继承自AWM。这种继承可以无限下去。事实上,在Java中,有一个顶级超类java.lang.Object,任何没有明确使用extends关键字的类,都是继承自Object类的。
由一个公共超类派生出来的所有类的集合称为继承层次,在继承层次中,从某个类到其祖先的路径称为该类的继承链。下图演示了Object类在本示例的部分继承层次:
在Java中是不支持多继承的,也就是说一个类只能继承自一个类,不过可以通过接口变相的多继承,关于接口的讨论我们将会在后面进行。
5.3构造子类
我们现在来构造一把AWM,我们另外编写一个ExtendTest类专门用来测试,代码如下:
public class ExtendTest { public static void main(String[] args) { AWM awm = new AWM(); } }
这段代码并没有什么问题,编译通过。但是我们观察一下,超类Gun和AWM类中都没有编写构造方法,表示都使用的默认构造器,现在假如我们给Gun增加一个构造方法如下:
public Gun(String name, String color) { this.name = name; this.color = color; }
这时候,我们发现,Eclipse会提示我们AWM类有个错误:
Implicit super constructor Gun() is undefined for default constructor. Must define an explicit constructor
意思是超类没有隐式的定义默认构造函数Gun(),AWM类必须显式的定义构造器。这是因为子类在构造的时候,必须要同时构造超类。要么显式的在子类构造器调用超类构造方法,否则编译器会自动的在子类构造器第一句话调用超类的默认构造器。
前面Gun类没有显式定义构造器的时候,代码不报错,是因为系统会自动给Gun添加一个默认构造器,然后在构造AWM类时候,系统自动调用AWM的默认构造器并且自动帮我们调用Gun类的默认构造器。后面Gun增加了一个带参构造器后,就没有默认构造器了。这时候构造AWM的时候,系统调用AWM默认的构造器,并且尝试帮我们调用Gun的默认构造器,但是发现Gun并没有默认构造器,因此报错。为了不报错,那么就必须在构造AWM的时候,调用Gun新增的带参数的构造器,为此,我们也编写一个带参数的AWM构造器,那么如何在子类中调用超类的构造器呢?使用super关键字。代码如下:
public AWM(String name, String color, String gunsight) { super(name, color); this.gunsight = gunsight; }
这里需要注意,使用super调用超类的构造器,必须是子类构造器的第一条语句。
5.4访问超类属性和方法
构造子类搞定了,如何访问超类的属性和方法呢?讨论这个问题之前,我们先把在讨论包作用域的时候讨论的4种修饰符的作用范围表列出来:
|
同一个类 |
同一个包 |
不同包子类 |
不同包非子类 |
public |
√ |
√ |
√ |
√ |
protected |
√ |
√ |
√ |
|
default |
√ |
√ |
|
|
private |
√ |
|
|
|
上面我们说过,继承的目的之一是把公共的属性和方法放到超类中,节省代码量。对于外部来说,虽然AWM类没有定义name和color属性,但是应该相当于拥有name和color属性。上面我们通过AWM的构造方法传入了name和color属性。那么当外部需要访问的时候怎么办呢?因为Gun的getName方法和getColor方法是public修饰的,因此可以直接调用:
public class ExtendTest { public static void main(String[] args) { AWM awm = new AWM("awm", "绿色", "4倍镜"); String name = awm.getName();// 返回awm String color = awm.getColor();// 返回绿色 } }
如果我们想给AWM增加一个修改颜色的方法,该怎么办呢?因为相当于拥用color属性,能直接this.color访问吗?答案是否定的。因为AWM类相当于拥有color属性,那也仅仅是对外部来说相当于而已,最终color属性还是属于超类的,并且是private修饰的,因此子类是不能直接访问的,有办法修改吗?有,并且有3种。
一种是给Gun类增加一个public的setColor方法,这个就类似getColor方法一样,结果显而易见。采用这种方式的话,Gun的所有子类就都拥有了setColor方法。
如果只想单独让AWM类开放修改颜色的方法,另一种方法是将Gun类的color属性修改成protected修饰的,然后给AWM增加一个setColor方法,代码如下:
public void setColor(String color) { super.color = color;//使用super关键字调用超类的属性 }
我们又一次看到了super关键字,使用super.属性可以访问父类的可见属性(因为Gun类的color属性是protected修饰的)。不过这种方法有一个不好的地方,就是Gun的color属性被定义为protected的,任何人都可以编写子类,然后直接访问color属性,违背了封装性原则。另外,对于同一个包下其他类,也是可以直接访问的。一般情况下不推荐把属性暴露为protected。
第三种方法,就是给Gun类增加一个protected修饰的setColor方法,然后给AWM类开放一个setColor方法,代码分别如下:
Gun类的方法:
protected void setColor(String color) { this.color = color; }
AWM类的方法:
public void setColor(String color) { super.setColor(color);// 使用super关键字调用超类的方法 }
我们再一次看到了super关键字,使用super.方法可以访问父类的可见方法。最后,我们总结一下:
- 对于超类public的属性和方法,外部可以直接通过子类访问。
- 对于超类protected的属性和方法,子类中可以通过super.属性和super.方法来访问,外部不可见
- 对于超类private的属性和方法,子类无法访问。
5.5到底继承了什么
引入这个问题,是因为笔者在写上面这些知识点的时候,也翻阅了很多资料,参看了很多网文和教程,最后发现,对于继承属性这块,居然存在着一些分歧:
- 超类的pubilc、protected属性会被子类继承,其他的属性不能被继承。理由是pubilc、protected的属性,子类都可以随意访问,即可以像上面我们讨论的用super.属性访问,其实还可以直接使用this.属性访问,就像使用自己的属性一样。但是private的属性,子类无法访问。
- 超类的所有属性都会被子类继承,只不过针对不同的修饰符,对于访问的限制不同而已。
对于继承属性这一块,事实上官方的指南的原文如下:
A subclass does not inherit the
private
members of its parent class. However, if the superclass has public or protected methods for accessing its private fields, these can also be used by the subclass.
笔者其实更喜欢从内存角度看待问题,前面的一些章节也多次从内存角度分析问题。前面我们看到,实例化一个子类的时候,必须要先实例化超类。当我们执行完下列语句:
AWM awm = new AWM("awm", "绿色", "4倍镜");
内存如下图:
我们看到,实际上在awm的内部,存在着一个Gun对象。name和color属性都是Gun对象的。awm对象实际上只拥有gunsight和gunstock属性。this关键字指向的是awm对象本身,super关键字指向的是内部的Gun对象。事实上,不管Gun中的属性是如何修饰的,最终都是存在于Gun对象中。
对于外部来说,只知道存在一个AWM对象实例awm,并不知道awm内部还有一个Gun对象。外部能看见的属性就是AWM和Gun所有的public属性,因此只能使用awm.属性访问这些能看见的属性。
对于awm来说,自身的属性不用说了,能看见的是超类Gun中的public和protected属性,假如Gun和AWM同包的话,AWM还能看见Gun中的默认修饰属性。对于这些能看见的属性,即可以用super.属性访问,也可以用this.属性访问。
因此笔者觉得,没必要去抠字眼,只要心中长存一副内存图,走到哪里都不怕。另外,对于方法,和属性类似,这些我相信读者自己就能分析明白。不过有一点要记住,构造方法是不能被继承的,例如Gun有一个构造方法:
public Gun(String name, String color) { this.name = name; this.color = color; }
AWM有一个构造方法:
public AWM(String name, String color, String gunsight) { super(name, color); this.gunsight = gunsight; }
AWM并不能继承Gun的2个参数的构造方法,因此外部无法通过语句:new AWM("awm", "绿色");来创建一个AWM实例。
5.6覆盖超类的属性
既然从内存上,超类和子类是相对独立存在的,那么我们思考一个问题,子类可以编写和超类同样名字的属性吗?答案是可以。我们看代码(隐藏了部分无关代码)
class Gun { private String name; private String color; public Gun(String name, String color) { this.name = name; this.color = color; } public String getColor() { return this.color; } } class AWM extends Gun { private String gunsight; private String gunstock; public String color; public AWM(String name, String color, String gunsight) { super(name, "黄色"); this.color = color; this.gunsight = gunsight; } }
我们看到,AWM类也定义了一个和Gun同名的属性color,然后修改了AWM的构造方法,注意第一句话,传入给Gun的颜色是“黄色”。我们用一段代码测试一下:
public class ExtendTest { public static void main(String[] args) { AWM awm = new AWM("awm", "绿色", "4倍镜"); System.out.println(awm.getColor()); System.out.println(awm.color); } }
输入结果是:
黄色
绿色
结果是不是有点意外?我们照例还是用内存图来分析,结果就一目了然了:
我们看到,这样做有一个非常不好的地方,就是对于外部来说,只认为AWM有一个color属性和一个getColor()方法,但是实际上存在着2个color属性,维护起来很费劲,一旦出现失误(例如本例),就出出现让外部难以理解的问题。
另外,本例中Gun的color是private,AWM的color是public。假如把Gun的color定义为public,AWM的color定义为private,这样外部就看不见color属性了,因此都无法使用awm.color来访问color属性了。
事实上,我们在子类中定义和超类同名的属性,有4种情况:
- 子类和超类都是成员属性
- 子类和超类都是静态属性
- 子类是静态属性,超类是成员属性
- 子类是成员属性,超类是静态属性
不管是以上哪种情况,都会隐藏超类同名属性,大家可以编写代码自己试验。在实际应用中,非常不建议这样编写代码。
5.7类型转换
5.7.1向上转型
中国历史上有一段非常有名的典故:白马非马。说的是公孙龙通过一番口才辩论,把白马不是马说的头头是道。有兴趣的朋友可以自行去网上查阅完整的故事。这里我们想讨论的是,AWM是Gun吗?废话不多说,直接用代码验证:
public class ExtendTest { public static void main(String[] args) { Gun gun = new AWM("awm", "绿色", "4倍镜"); } }
我们发现,Gun类型的变量是可以引用一个AWM对象的。也就是说AWM是Gun,换句话说,也就是超类变量是可以引用子类对象的。其实理由很充分,因为对外部来说,AWM拥有全部Gun类的可见属性和方法,外部可以用变量gun调用所有的Gun类的可见属性和方法。在Java中,我们把这种子类对象赋值给超类变量的操作称为向上转型。向上转型是安全的。
但是这里要注意,当AWM对象转型为Gun后,对外部来说,就看不见AWM类中特有的属性和方法了,因此变量gun将无法调用AWM可见的属性和方法。例如AWM的安装瞄准器的方法:
// 安装瞄准器 public void loadGunsight(String gunsight) { this.gunsight = gunsight; }
采用下面语句调用将会报错:
gun.loadGunsight("4倍镜");
虽然上面我们说向上转型是安全的,但是实际上在数组的运用中会有一个坑,我们看如下代码:
1 public class ExtendTest { 2 public static void main(String[] args) { 3 AWM[] awms = new AWM[2]; 4 Gun[] guns = awms;// 将一个AWM数组赋值给Gun数组变量 5 guns[0] = new Gun("枪", "白色"); 6 awms[0].loadGunsight("4倍镜"); 7 } 8 }
我们把一个AWM数组向上转型赋值给一个Gun数组,然后把Gun数组的第一个元素引用一个Gun对象。我们通过内存分析,知道awms[0]和guns[0]都指向了同一个Gun对象实例,看起来好像我们通过一个合理的手段进行了一项不合理的操作,因为我们做到了“枪是狙击枪”的操作,结果运行到第6句的时候将会报错:
Exception in thread "main" java.lang.ArrayStoreException: com.javadss.javase.ch05.Gun
at com.javadss.javase.ch05.test.ExtendTest.main(ExtendTest.java:16)
因此我们在使用数组的时候,要谨慎的赋值,需要牢记数组元素的类型,尽量避免以上这种情况发生。
5.7.2向下转型
在学习基本数据类型的时候,我们学习过强制类型转换,例如可以把一个double变量强制转换为int型:
double d = 1.5d; int i = (int) d;
实际上,对象类型可以采用类似的方式进行强制类型转换,只不过如果我们胡乱进行强制类型转换没有意义,一般我们需要用到对象的强制类型转换的场景是:我们有时候为了方便或其他原因,暂时把一个子类对象赋值给超类变量(如上节中的例子),但是因为某些原因我们又想复原成子类,这个时候就需要用到强制类型转换了,我们把这种超类类型强制转换为子类类型的操作称为向下转型。例如:
Gun gun = new AWM("awm", "绿色", "4倍镜"); AWM awm = (AWM) gun;
这种向下转型是不安全的,因为编译器无法确定转型是否正确,只有在运行时才能真正判断是否能够向下转型,如果转型失败,虚拟机将会抛出java.lang.ClassCastException异常。为了避免出现这种异常,我们可以在转型之前预先判断是否能够转型,Java给我们提供了instanceof关键字。例如:
1 public static void main(String[] args) { 2 Gun gun = new Gun("awm", "绿色"); 3 if (gun instanceof AWM) { 4 AWM awm = (AWM) gun; 5 } 6 }
上面代码第4句将不会执行。对于语句:a Instanceof B,实际上判断的是a是否为B类型或B的子孙类类型,如果是则返回true,否则返回false。如果a为null,该语句会返回false而不是报错。
在实际工作运用中,笔者并不推荐大量使用向下转型操作,因为大部分的向下转型都是因为超类的设计问题而导致的,这个话题在这就不展开讨论了,等大家经验丰富后,自然会体会到。