前言
面向对象的三大特性:封装、
继承
、多态。在这三个特性中,如果没有封装和继承,也不会有多态。
那么多态实现的途径和必要条件是什么呢?以及多态中的重写和重载在JVM中的表现是怎么样?
(若文章有不正之处,或难以理解的地方,请多多谅解,欢迎指正)
多态
多态是同一个行为具有多个不同表现形式或形态的能力。
举个栗子,一只鸡可以做成白切鸡、豉油鸡、吊烧鸡、茶油鸡、盐焗鸡、葱油鸡、手撕鸡、清蒸鸡、叫花鸡、啤酒鸡、口水鸡、香菇滑鸡、盐水鸡、啫啫滑鸡、鸡公煲等等。
多态实现的必要条件
用上面的“鸡的十八种吃法“来举个栗子。
首先,我们先给出一只鸡:
class Chicken {
public void live ( ) {
System. out. println ( "这是一只鸡" ) ;
}
}
1. 子类必须继承父类
对于子类必须继承父类,小编个人认为,是因为按照
面向对象的五大基本原则
所说的中的依赖倒置原则:
抽象不依赖于具体,具体依赖于抽象
。既然要实现多态,那么必定有一个作为"抽象"类来定义“行为”,以及若干个作为"具体"类来呈现不同的行为形式或形态。
所以我们给出的一个具体类——白切鸡类:
class BaiqieChicken extends Chicken { }
但仅是定义一个白切鸡类是不够的,因为在此我们只能做到复用父类的属性和行为,而
没有呈现出行为上的不同的形式或形态
。
2. 必须有重写
重写,简单地理解就是
重新定义的父类方法,使得父类和子类对同一行为的表现形式各不相同
。我们用白切鸡类来举个栗子。
class BaiqieChicken extends Chicken {
public void live ( ) {
System. out. println ( "这是一只会被做成白切鸡的鸡" ) ;
}
}
这样就实现了重写,鸡类跟白切鸡类在live()方法中定义的行为不同,鸡类是一只命运有着无限可能的鸡,而白切鸡类的命运就是做成一只白切鸡。
但是为什么还要有“父类引用指向子类对象”这个条件呢?
3. 父类引用指向子类对象
其实这个条件是
面向对象的五大基本原则
里面的里氏替换原则,简单说就是父类可以引用子类,但不能反过来。
当一只鸡被选择做白切鸡的时候,它的命运就不是它能掌控的。
Chicken c = new BaiqieChicken ( ) ;
c. live ( ) ;
运行结果:
这是一只会被做成白切鸡的鸡
为什么要有这个原则?因为父类对于子类来说,是属于“抽象”的层面,子类是“具体”的层面。“抽象”可以提供接口给“具体”实现,但是“具体”凭什么来引用“抽象”呢?而且“子类引用指向父类对象”是不符合“依赖倒置原则”的。
当一只白切鸡想回头重新选择自己的命运,抱歉,它已经在锅里,逃不出去了。
BaiqieChicken bc = new Chicken ( ) ;
bc. live ( ) ;
多态的实现途径
多态的实现途径有三种:重写、重载、接口实现,虽然它们的实现方式不一样,但是核心都是:
同一行为的不同表现形式
。
1. 重写
重写,指的是子类对父类方法的重新定义,但是**子类方法的参数列表和返回值类型,必须与父类方法一致!**所以可以简单的理解,重写就是子类对父类方法的核心进行重新定义。
举个栗子:
class Chicken {
public void live ( String lastword) {
System. out. println ( lastword) ;
}
}
class BaiqieChicken extends Chicken {
public void live ( String lastword) {
System. out. println ( "这只白切鸡说:" ) ;
System. out. println ( lastword) ;
}
}
这里白切鸡类重写了鸡类的live()方法,为什么说是重写呢?因为白切鸡类中live()方法的参数列表和返回值与父类一样,但方法体不一样了。
2. 重载
重载,指的是在一个类中有若干个
方法名相同,但参数列表不同的情况,返回值可以相同也可以不同
的方法定义场景。也可以简单理解成,同一行为(方法)的不同表现形式。
举个栗子:
class BaiqieChicken extends Chicken {
public void live ( ) {
System. out. println ( "这是一只会被做成白切鸡的鸡" ) ;
}
public void live ( String lastword) {
System. out. println ( "这只白切鸡说:" ) ;
System. out. println ( lastword) ;
}
}
这里的白切鸡类中的两个live()方法,一个无参一个有参,它们对于白切鸡类的live()方法的描述各不相同,但它们的方法名都是live。通俗讲,它们对于白切鸡鸡生的表现形式不同。
3. 接口实现
接口,是一种无法被实例化,但可以被实现的抽象类型,是抽象方法的集合,多用作定义方法集合,而方法的具体实现则交给继承接口的具体类来定义。所以,
接口定义方法,方法的实现在继承接口的具体类中定义,也是对同一行为的不同表现形式
。
interface Chicken {
public void live ( ) ;
}
class BaiqieChicken implements Chicken {
public void live ( ) {
System. out. println ( "这是一只会被做成白切鸡的鸡" ) ;
}
}
class ShousiChicken implements Chicken {
public void live ( ) {
System. out. println ( "这是一只会被做成手撕鸡的鸡" ) ;
}
}
从上面我们可以看到,对于鸡接口中的live()方法,白切鸡类和手撕鸡类都有自己对这个方法的独特的定义。
在虚拟机中多态如何表现
前文
我们知道,java文件在经过javac编译后,生成class文件之后在JVM中再进行编译后生成对应平台的机器码。而JVM的编译过程中体现多态的过程,在于选择出正确的方法执行,这一过程称为
方法调用
。
方法调用的唯一任务是确定被调用方法的版本
,暂时还不涉及方法内部的具体运行过程。(注:方法调用不等于方法执行)
在介绍多态的重载和重写在JVM的实现之前,我们先简单了解JVM提供的5条方法调用字节码指令:
invokestatic:调用静态方法。
invokespecial:调用实例构造器方法、私有方法和父类方法。
invokevirtual:调用所有的虚方法(这里的虚方法泛指除了invokestatic、invokespecial指令调用的方法,以及final方法)。
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用点限定符所应用的方法(说人话就是用于动态指定运行的方法)。
而方法调用过程中,在编译期就能确定下来调用方法版本的
静态方法、实例构造器方法、私有方法、父类方法和final方法
(虽是由invokevirtual指令调用)在编译期就已经完成了运行方法版本的确定,这是一个静态的过程,也称为
解析调用
。
而
分派调用
则有可能是静态的也可能是动态的,可能会在编译期发生或者运行期才确定运行方法的版本。
而分派调用的过程与多态的实现有着紧密联系,所以我们先了解一下两个概念:
静态分派:所有依赖静态类型来定位方法执行版本的分派动作。
动态分派:根据运行期实际类型来定位方法执行版本的分派动作。
1. 重载
我们先看看这个例子:
public class StaticDispatch {
static abstract class Human { }
static class Man extends Human { }
static class Woman extends Human { }
public void sayHello ( Human guy) {
System. out. println ( "hello, guy!" ) ;
}
public void sayHello ( Man guy) {
System. out. println ( "hello, gentleman!" ) ;
}
public void sayHello ( Woman guy) {
System. out. println ( "hello, lady!" ) ;
}
public static void main ( String[ ] args) {
Human man = new Man ( ) ;
Human woman = new Woman ( ) ;
StaticDispatch sr = new StaticDispatch ( ) ;
sr. sayHello ( man) ;
sr. sayHello ( woman) ;
}
}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
想想以上代码的运行结果是什么?3,2,1,运行结果如下:
hello, guy!
hello, guy!
为什么会出现这样的结果?让我们来看这行代码:
Human man = new Man ( ) ;
根据里氏替换原则,子类必须能够替换其基类,也就是说子类相对于父类是“具体类”,而父类是处于“奠定”子类的基本功能的地位。
所以,我们把上面代码中的“Human”称为变量man的
静态类型
(Static Type),而后面的"Man"称为变量的
实际类型
(Actual Type),二者的区别在于,
静态类型是在编译期可知的;而实际类型的结果在运行期才能确定
,编译期在编译程序时并不知道一个对象的实际类型是什么。
在了解了这两个概念之后,我们来看看字节码文件是怎么说的:
javac -verbose StaticDispatch.class
我们看到,图中的黄色框的invokespecial指令以及标签,我们可以知道这三个是指令是在调用实例构造器方法。同理,下面两个红色框的invokevirtual指令告诉我们,这里是采用分派调用的调用虚方法,而且入参都是“Human”。
因为在分派调用的时候,使用哪个重载版本完全取决于传入参数的数量和数据类型。而且,
虚拟机(准确说是编译期)在重载时是通过参数的静态类型而不是实际类型作为判断依据
,并且静态类型是编译期可知的。
所以,
在编译阶段,Javac编译期就会根据参数的静态类型决定使用哪个重载版本
。重载是静态分派的经典应用。
2. 重写
我们还是用上面的例子:
public class StaticDispatch {
static abstract class Human {
protected abstract void sayHello ( ) ;
}
static class Man extends Human {
@Override
protected void sayHello ( ) {
System. out. println ( "man say hello" ) ;
}
}
static class Woman extends Human {
@Override
protected void sayHello ( ) {
System. out. println ( "woman say hello" ) ;
}
}
public static void main ( String[ ] args) {
Human man = new Man ( ) ;
Human woman = new Woman ( ) ;
man. sayHello ( ) ;
woman. sayHello ( ) ;
}
}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
其运行结果为:
man say hello
woman say hello
相信你看到这里也会会心一笑,这一看就很明显嘛,重写是按照实际类型来选择方法调用的版本嘛。先别急,我们来看看它的字节码:
嘶…这好像跟静态分派的字节码一样啊,但是从运行结果看,这两句指令最终执行的目方法并不相同啊,那原因就得从invokevirtual指令的多态查找过程开始找起。
我们来看看invokevirtual指令的运行时解析过程的步骤:
找到操作数栈顶的第一个元素所指向的对象的
实际类型
,记作C。
如果在在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的
直接引用
,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
否则,按照继承关系从下往上依次对
C的各个父类
进行第2步的搜索和验证过程。
如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
我们可以看到,由于invokevirtual指令在执行的第一步就是
在运行期确定接收者的实际类型
,所以字节码中会出现invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个就是Java重写的本质。
总结一下,
重载
的本质是
在编译期就会根据参数的静态类型来决定重载方法的版本
,而
重写
的本质
在运行期确定接收者的实际类型
。
结语
坚持写技术文章的确是一件不容易的事情。现在技术更新越来越快,但是依然想把基础再打牢一点。
如果本文对你理解多态有帮助,请给一个赞吧,这会是我最大的动力~
参考资料:
Java多态性理解
从虚拟机指令执行的角度分析JAVA中多态的实现原理
《深入理解Java虚拟机》