Java的多态(深入版)
Posted 是Cc哈
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java的多态(深入版)相关的知识,希望对你有一定的参考价值。
Java 中的继承和多态(深⼊版)
⾯向对象的三⼤特性:封装、继承、多态。在这三个特性中,如果没有封装和继承,也不会有多态。
那么多态实现的途径和必要条件是什么呢?以及 多态中的重写和重载在JVM中的表现是怎么样?在Java中是如何展现继承的特性呢?对于⼦类继承于⽗类时,⼜有什么限制呢?本⽂系基础,深⼊浅出过⼀遍 Java 中的多态和继承。
多态
多态是同⼀个⾏为具有多个不同表现形式或形态的能⼒。
举个栗⼦,⼀只鸡可以做成⽩切鸡、豉油鸡、吊烧鸡、茶油鸡、盐焗鸡、葱油鸡、⼿撕鸡、清蒸鸡、叫花鸡、啤酒鸡、⼝⽔鸡、⾹菇滑鸡、盐⽔鸡、啫啫滑鸡、鸡公煲等等。
多态实现的必要条件
⽤上⾯的“鸡的⼗⼋种吃法“来举个栗⼦。
⾸先,我们先给出⼀只鸡:
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);
想想以上代码的运⾏结果是什么?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();
其运⾏结果为:
man say hello
woman say hello
相信你看到这⾥也会会⼼⼀笑,这⼀看就很明显嘛,重写是按照实际类型来选择⽅法调⽤的版本嘛。先别急,我们来看看它的字节码:
嘶…这好像跟静态分派的字节码⼀样啊,但是从运⾏结果看,这两句指令最终执⾏的⽬⽅法并不相同啊,那原因就得从invokevirtual指令的多态查找过程开始找起。
我们来看看invokevirtual指令的运⾏时解析过程的步骤:
- 找到操作数栈顶的第⼀个元素所指向的对象的 实际类型,记作C。
- 如果在在类型C中找到与常量中的描述符和简单名称都相符的⽅法,则进⾏访问权限校验,如果通过则返回这个⽅法的 直
接引⽤,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。 - 否则,按照继承关系从下往上依次对 C的各个⽗类进⾏第2步的搜索和验证过程。
- 如果始终没有找到合适的⽅法,则抛出java.lang.AbstractMethodError异常。
我们可以看到,由于invokevirtual指令在执⾏的第⼀步就是 在运⾏期确定接收者的实际类型,所以字节码中会出现invokevirtual指令把常量池中的类⽅法符号引⽤解析到了不同的直接引⽤上,这个就是Java重写的本质。
总结⼀下, 重载的本质是 在编译期就会根据参数的静态类型来决定重载⽅法的版本,⽽ 重写的本质 在运⾏期确定接收者的实类型。
继承
假如我们有两个类:⽣物类、猫类。
⽣物类:
class Animal
private String name;
public void setName(String name)
this.name = name;
public String getName()
return this.name;
猫类:
class Cat
private String name;
private String sound;
public void setName(String name)
this.name = name;
public void setSound(String sound)
this.sound = sound;
public String getName()
return this.name;
public String getSound()
return this.sound;
我们知道,猫也是属于⽣物中的⼀种,⽣物有的属性和⾏为,猫按理来说也是有的。但此时 没有继承的概念,那么代码就得不到复⽤,⻓期发展,代码冗余、维护困难且开发者的⼯作量也⾮常⼤。
继承的概念
继承就是⼦类继承⽗类的特征和⾏为,使得⼦类对象(实例)具有⽗类的实例域和⽅法,或⼦类从⽗类继承⽅法,使得⼦类具有⽗类相同的⾏为。
简单来说,⼦类能吸收⽗类已有的属性和⾏为。除此之外,⼦类还可以扩展⾃⾝功能。⼦类⼜被称为派⽣类,⽗类被称为超类。在 Java中,如果要实现继承的关系,可以使⽤如下语法:
class ⼦类 extends ⽗类
继承的基本实现
继承的基本实现如下:
class Animal
private String name;
public void setName(String name)
this.name = name;
public String getName()
return this.name;
class Cat extends Animal
public class Test
public static void main(String[] args)
Cat cat = new Cat();
cat.setName("猫");
System.out.println(cat.getName());
运⾏结果为:
猫
我们可以看出, ⼦类可以在不扩展操作的情况下,使⽤⽗类的属性和功能。
⼦类扩充⽗类
继承的基本实现如下:
class Animal
private String name;
public void setName(String name)
this.name = name;
public String getName()
return this.name;
class Cat extends Animal
private String sound;
public void setSound(String sound)
this.sound = sound;
public String getSound()
return this.sound;
public class Test
public static void main(String[] args)
Cat cat = new Cat();
cat.setName("NYfor2020")
cat.setSound("我不是你最爱的⼩甜甜了吗?");
System.out.println(cat.getName()+":"+cat.getSound());
运⾏结果为:
NYfor2020:我不是你最爱的⼩甜甜了吗?
我们可以看出,⼦类在⽗类的基础上进⾏了扩展,⽽且对于⽗类来说,⼦类定义的范围更为具体。也就是说, ⼦类是将⽗类具体化的⼀种⼿段。
总结⼀下,Java中的继承利⽤⼦类和⽗类的关系, 可以实现代码复⽤,⼦类还可以根据需求扩展功能。
继承的限制
1. ⼦类只能继承⼀个⽗类
为什么⼦类不能多继承?举个栗⼦.
class ACat
public void mewo()...
class BCat
public void mewo()...
class CCat extends ACat, BCat
@Override
public void mewo()...? //提问:这⾥的mewo()是继承⾃哪个类?
虽说Java只⽀持单继承,但是 不反对多层继承呀!
class ACat
class BCat extends ACat
class CCat extends BCat
这样,BCat就继承了ACat所有的⽅法,⽽CCat继承了ACat、BCat所有的⽅法,实际上CCat是ACat的⼦(孙)类,是BCat的⼦类。
总结⼀下, ⼦类虽然不⽀持多重继承,只能单继承,但是可以多层继承。
2. private修饰不可直接访问,final修饰不可修改
private修饰
对于⼦类来说,⽗类中⽤ private修饰的属性对其隐藏的,但如果提供了这个变量的 setter/getter接⼝,还是能够访问和修改这
个变量的。
class ACat
private String sound = "meow";
public String getSound()
return sound;
public void setSound(String sound)
this.sound = sound;
class BCat extends ACat
public class Test
public static void main(String[] args)
BCat b = new BCat();
b.setSound("我不是你最爱的⼩甜甜了吗?");
System.out.println(b.getSound());
final修饰
⽗类已经定义好的 final修饰变量(⽅法也⼀样), ⼦类可以访问这个属性(或⽅法),但是不能对其进⾏更改。
class ACat
final String sound = "你是我最爱的⼩甜甜";
public String getSound()
return sound;
public void setSound(String sound)
this.sound = sound; //这句执⾏不了,会报错的
class BCat extends ACat
总结⼀下, ⽤private修饰的变量可以通过getter/setter接⼝来操作,final修饰的变量就只能访问,不能更改。
3. 实例化⼦类时默认先调⽤⽗类的构造⽅法
在实例化⼦类对象时,会调⽤⽗类的构造⽅法对属性进⾏初始化,之后再调⽤⼦类的构造⽅法。
class A A
public A A()
System. out.println("我不是你最爱的⼩甜甜了吗?");
public A A(String q)
System. out.println(q);
class B B extends A A
public B B()
System. out.println("你是个好姑娘");
public class Test
public static void main(String[] args)
B b = new B();
运⾏结果为:
我不是你最爱的⼩甜甜了吗?
你是个好姑娘
从结果我们可以知道,在实例化⼦类时,会 默认先调⽤⽗类中⽆参构造⽅法,然后再调动⼦类的构造⽅法。
那么怎么调⽤⽗类带参的构造⽅法呢?只要在⼦类构造⽅法的第⼀⾏调⽤super()⽅法就好。
class A A
public A A(String q)
System.out.println(q);
class B B extends A A
public B B()
super("我是你的⼩甜甜?");
System.out.println("你是个好姑娘");
public class Test
public static void main(String[] args)
B b = new B();
运⾏结果为:
我是你的⼩甜甜?
你是个好姑娘
在⼦类实例化时,默认调⽤的是⽗类的⽆参构造⽅法,⽽如果没有⽗类⽆参构造⽅法,则 ⼦类必须通过super()来调⽤⽗类的有参构造⽅法,且 super()⽅法必须在⼦类构造⽅法的⾸⾏。
总结⼀下,Java继承中有三种继承限制,分别是⼦类只能单继承、⽗类中private修饰的变量不能显式访问和final修饰的变量不能改变,以及实例化⼦类必定会先调⽤⽗类的构造⽅法,之后才调⽤⼦类的构造⽅法。
类是怎么加载的?
(此处只是粗略介绍类加载的过程,想了解更多可参考《深⼊理解Java虚拟机》)
类加载过程包括三个⼤步骤: 加载、连接、初始化。
这三个步骤的开始时间仍然保持着固定的先后顺序,但是进⾏和完成的进度就不⼀定是这样的顺序了。
- 加载:虚拟机通过这个 类的全限定名来获取这个 类的⼆进制字节流,然后在字节流中提取出这个 类的结构数据,并转换成 个类在⽅法区(存储类结构)的运⾏时数据结构;
- 验证:先验证这 字节流是否符合Class⽂件格式的规范,然后检查 这个类的其⽗类中数据是否存在冲突(如这个类的⽗类是继承被final修饰的类),接着对 这个类内的⽅法体进⾏检查,如果都没问题了,那就把之前的 符号引⽤换成直接引⽤;
- 准备:为 类变量(static修饰的变量) 分配内存(⽅法区)并 设置类变量初始值,⽽这⾥的初始值是指这个数据类型的值,如int的初始值是0;
- 解析:在Class⽂件加载过程中,会将Class⽂件中的标识⽅法、接⼝的常量放进常量池中,⽽这些常量对于虚拟机来说,就是符号引⽤。此阶段就是针对类、接⼝、字段等7类符号引⽤, 转换成直接指向⽬标的句柄——直接引⽤。
- 初始化:这阶段是 执⾏static代码块和类构造器的过程,有⼩伙伴可能会疑惑类构造器不是默认static的吗?详情请看这个博客:https://www.cnblogs.com/dolphin0520/p/10651845.html
总结⼀下,类加载的过程中,⾸先会对Class⽂件中的类提取并转换成运⾏时数据结构,然后对类的⽗类和这个类的数据信息进⾏检验之后,为类中的类变量分配内存并且设置初始值,接着将Class⽂件中与这个类有关的符号引⽤转换成直接引⽤,最后再执⾏
类构造器。⽽且我们可以从第⼆步看出,在加载类的时候,会先去检查这个类的⽗类的信息,然后再检查这个类的⽅法体,也就是说, 在加载类的时候,会先去加载它的⽗类。
参考资料:
加粗样式Java多态性理解
https://www.cnblogs.com/jack204/archive/2012/10/29/2745150.html
从虚拟机指令执⾏的⻆度分析JAVA中多态的实现原理
https://www.cnblogs.com/hapjin/p/9248525.html
《深⼊理解Java虚拟机》
https://blog.csdn.net/wei_zhi/article/details/52780026
本篇博客转载自:https://blog.csdn.net/NYfor2017/article/details/104704516
以上是关于Java的多态(深入版)的主要内容,如果未能解决你的问题,请参考以下文章