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种修饰符的作用范围表列出来:
上面我们说过,继承的目的之一是把公共的属性和方法放到超类中,节省代码量。对于外部来说,虽然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的属性,子类无法访问。
- 超类的所有属性都会被子类继承,只不过针对不同的修饰符,对于访问的限制不同而已。
对于继承属性这一块,事实上官方的指南的原文如下:
笔者其实更喜欢从内存角度看待问题,前面的一些章节也多次从内存角度分析问题。前面我们看到,实例化一个子类的时候,必须要先实例化超类。当我们执行完下列语句:
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而不是报错。
在实际工作运用中,笔者并不推荐大量使用向下转型操作,因为大部分的向下转型都是因为超类的设计问题而导致的,这个话题在这就不展开讨论了,等大家经验丰富后,自然会体会到。