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指令的运⾏时解析过程的步骤:

  1. 找到操作数栈顶的第⼀个元素所指向的对象的 实际类型,记作C。
  2. 如果在在类型C中找到与常量中的描述符和简单名称都相符的⽅法,则进⾏访问权限校验,如果通过则返回这个⽅法的 直
    接引⽤,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对 C的各个⽗类进⾏第2步的搜索和验证过程。
  4. 如果始终没有找到合适的⽅法,则抛出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虚拟机》)

类加载过程包括三个⼤步骤: 加载、连接、初始化

这三个步骤的开始时间仍然保持着固定的先后顺序,但是进⾏和完成的进度就不⼀定是这样的顺序了。
在这里插入图片描述

  1. 加载:虚拟机通过这个 类的全限定名来获取这个 类的⼆进制字节流,然后在字节流中提取出这个 类的结构数据,并转换成 个类在⽅法区(存储类结构)的运⾏时数据结构;
  2. 验证:先验证这 字节流是否符合Class⽂件格式的规范,然后检查 这个类的其⽗类中数据是否存在冲突(如这个类的⽗类是继承被final修饰的类),接着对 这个类内的⽅法体进⾏检查,如果都没问题了,那就把之前的 符号引⽤换成直接引⽤;
  3. 准备:为 类变量(static修饰的变量) 分配内存(⽅法区)并 设置类变量初始值,⽽这⾥的初始值是指这个数据类型的值,如int的初始值是0;
  4. 解析:在Class⽂件加载过程中,会将Class⽂件中的标识⽅法、接⼝的常量放进常量池中,⽽这些常量对于虚拟机来说,就是符号引⽤。此阶段就是针对类、接⼝、字段等7类符号引⽤, 转换成直接指向⽬标的句柄——直接引⽤。
  5. 初始化:这阶段是 执⾏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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值