理解多态的语法使用以及多态的实现原理

Posted 有心栽花无心插柳

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了理解多态的语法使用以及多态的实现原理相关的知识,希望对你有一定的参考价值。

C++多态语法与原理

1 多态的概念

多态顾名思义就是多种状态,就是完成某个行为时,不同的对象去完成会有不同的状态。

举个生活中的例子,比如说动物都会“叫”,那我们可以定义动物这个抽象的类,里面的有一个函数就是动物在“叫”。但是对于具体的某个动物来说,“叫声”是独有的。比如“狗叫”的声音,“猫叫”的声音,“鸟叫”的声音。那么我们就可以把动物类中的这个“叫”函数多态化,当狗这个类使用这个函数,就是狗叫;猫这个类使用这个函数就是“猫叫”;鸟这个类使用这个函数就是“鸟叫”。

再比如,买票这个动作,普通人买票是全价,学生买票可以是半价,军人可以优先买票,这也是一种多态。

多态离不开“继承”的支持,有继承才能有多态。动物这个抽象类,被具体的动物所继承。人这个抽象类被具体的人的身份所继承。所以,多态的前提是有继承关系。

2 多态的定义以及实现

2.1 多态构成的条件

首先,在语法上,多态要满足这两个条件:

1.必须通过基类的指针或引用调用虚函数(虚函数就是virtual修饰的类成员函数,文章后面会讲)
2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写(注意区别于重定义和重载,重定义(也叫隐藏)派生类继承基类时,派生类的函数与基类名字相同即可,其基类的该函数被隐藏。重载必须在一个作用域内。)重写的要求比较严格,下面会讲。

2.2 虚函数

被virtual修饰的类成员函数,叫虚函数。

如:

class Person 
public:
 virtual void BuyTicket()  cout << "买票-全价" << endl;
;

2.3 虚函数的重写(也叫覆盖)

虚函数的重写:派生类有一个跟基类的接口完成相同的虚函数(即派生类虚函数与基类虚函数的函数返回值类型,函数名字,参数列表相同),称派生类的虚函数重写了基类的的虚函数。

2.4 多态的实现例子(语法层面的解释)

下面代码实现的是,普通人买票全价,学生买票半价的例子。

class Person

public:
	virtual void buy_ticket()
	
		cout << "买票-全价" << endl;
	
;

class Student : public Person

public:
	virtual void buy_ticket()
	
		cout << "买票-半价" << endl;
	
;
void fun(Person& person)//通过基类的引用去调用虚函数

	person.buy_ticket();

int main()

	Person p1;//一个Person对象
	Student s1;//一个Student对象
	fun(p1);//Person传递给Person的引用
	fun(s1);//Student传递给Person的引用
	return 0;

为什么我把s1传递给Person类型,就能实现Student类的 buy_ticket呢?

我们首先知道,派生类和基类的赋值兼容规则。

派生类对象可以赋值给基类的对象/基类的指针/基类的引用。这里有个形象的说法,叫切片,或者切割。寓意就是把派生类中的基类那部分内容切下来,然后赋值过去。

在完成切片之前,Student类完成了对buy_ticket()函数的重写(也称为覆盖)。那么我们可以理解成,之前从基类中继承过来的buy_ticket()函数被我们覆盖掉了。

这也就很好地解释了,为什么把s1传递给Person类的引用,就能实现多态了。

这是从语法层面的解释,后面会讲实现的原理。

2.5 虚函数重写的两个例外

2.5.1 协变(基类与派生类的虚函数返回值类型不同。)

而且这两个返回值类型必须是指针或者引用,而且必须是继承关系。

如以下例子:本来两个函数的返回值类型应该相同的,但是这里的协变是个例外,了解即可。

class A;
class B : public A;

class Person

public:
	virtual A* fun()
	
		return new A;
	
;
class Student : public Person

public:
	virtual B* fun()
	
		return new B;
	
;

2.5.2派生类的虚函数可以不加virtual

C++里多态要满足的条件之一重写其实是一种接口继承

意思就是说,重写虚函数的时候,重写其实是函数的具体实现,接口是直接继承过来的,所以当基类有了虚函数,子类的对应的虚函数可以不用写 virtual

2.5.2.1 例题

接下来看一下这段代码输出什么?

首先分析一下要满足多态的条件:

1.虚函数的重写,三同(函数名相同,返回值类型相同,参数列表相同)

题中派生类中的func函数满足对基类的重写(注意,是参数相同,缺省值不算)

2.必须是父类的指针或引用调用虚函数

题中看起来只是派生类的指针p,其实不然。
首先B类继承了A类,那么在 p->test()的时候,test()里的隐含参数是A* this,所以本质上是基类的指针调用了函数体里的func()函数,而func()函数在派生类中又完成了重写,所以满足多态。

那么该程序输出的是 B->0吗?不对!
还记得上面说的C++里的虚函数重写是接口继承,重写的是函数体吗?
也就是说基类里的virtual void func(int val = 1);这个接口是被派生类继承的,所以派生类里也是这个接口,所以该程序输出的结果是:
B->1

2.5.3 析构函数的重写(基类与派生类析构函数的名字不同,编译后内部是相同的)

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,
都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,
看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处 理,编译后析构函数的名称统一处理成destructor。

这里主要想讲的时候,基类和派生类的析构函数虽然函数名字不同,但内部处理后本质上是相同的,被编译器统一处理成destructor。那么也就是说派生类可以完成析构函数重写,实现多态。

2.6 C++11 final 和 override

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数
名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有
得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮
助用户检测是否重写。

2.6.1 final

final:修饰虚函数,表示该虚函数不能再被重写

2.6.2 override

override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

2.7 重载 重写(覆盖) 重定义(隐藏) 对比

2.8 抽象类

2.8.1

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口
类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生
类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。


2.8.2 接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实
现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成
多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

3 多态的实现原理

多态的关键在于通过基类指针或引用调用一个虚函数时,编译时不确定到底调用的时基类还是派生类的函数,运行时才确定,这叫“动态联编”,也叫“动态绑定”。接下来我们研究这种“动态联编”是怎么实现的,也即研究多态的实现原理。

3.1 多态实现的关键——虚函数表(本质是函数指针数组)

我们先来看下面这段代码:

根据结构体内存规则,应该输出的是4,8。
而该程序输出的确实8,12
为什么都多了四个字节?

答案就是里面存放了一个4个字节的指针。

每一个有虚函数的类(或有虚函数的类的派生类)都有一个虚函数表,该类的任何对象中放着虚函数表的指针。虚函数表中列出了该类的虚函数的地址。多出来的4个字节就是用来存放虚函数表的地址的。

这时基类的内存模型,

这是派生类的内存模型

多态的函数调用语句被编译成一系列根据基类指针所指向的(或基类引用所引用的)对象中存放的虚函数表的地址,在虚函数表中查找虚函数的地址,并调用虚函数的指令。

3.2 反思派生类的虚函数为什么要重写以及为什么多态一定要用父类的指针或引用

上面我们提到过,重写也叫覆盖。
所谓的覆盖其实在原理层面的叫法,就是我们派生类的虚函数表中,如果派生类的虚函数重写了的话,就会覆盖掉本来基类的虚函数的地址,在结合多态一定要用父类的指针或引用,子类发生切片,就能达到多态的目的。
我们以下面这段代码做分析:


首先我们可以分析出满足多态的条件:
1.通过父类引用调用虚函数
2.派生类有虚函数的重写

我们先来看看第20行代码:

接下来是重点,我们分析第22行代码:

3.3 解释动态联编

由上面的分析,我们可以知道,程序在含有多态的时候,在编译时,是无法确定某个对象是要调用哪个虚函数的,因为都是通过父类的指针或者引用去调用的,只能在运行时确定某个对象要调用哪个虚函数。
这种程序只有在运行时才能确定具体的行为,调用具体的函数,就叫动态联编,也叫动态绑定。

有动态绑定,那么也有静态绑定。
静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态。
比如:函数重载

4 总结

多态的基本实现是依靠虚函数表实现的。
多态能完成不同对象完成某一动作后的状态,但是使用多态时,实际消耗的性能会比不用多态时消耗的性能大,因为每次使用多态都要有一个虚函数表,每次调用虚函数,都要访问虚函数表,这肯定会造成性能上的损耗。
所以我们平时要根据实际情况判断是否要使用多态。

从虚拟机指令执行的角度分析JAVA中多态的实现原理

从虚拟机指令执行的角度分析JAVA中多态的实现原理

前几天突然被一个“家伙”问了几个问题,其中一个是:JAVA中的多态的实现原理是什么?

我一想,这肯定不是从语法的角度来阐释多态吧,隐隐约约地记得是与Class文件格式中的方法表有关,但是不知道虚拟机在执行的时候,是如何选择正确的方法来执行的了。so,趁着周末,把压箱底的《深入理解Java虚拟机》拿出来,重新看了下第6、7、8章中的内容,梳理一下:从我们用开发工具(Intellij 或者Eclipse)写的 .java 源程序,到经过javac 编译成class字节码文件,再到class字节码文件被加载到虚拟机并最终根据虚拟机指令执行选择出正确的(多态)方法执行的整个过程。

在讨论的多态(一般叫运行时多态)的时候,不可避免地要和重载(Overload)进行对比,为什么呢?因为这涉及到一种方法调用方式----分派(分派这个名字来源于 深入理解Java虚拟机 第8章8.3.2节)

先从源代码(语法)的角度看看二者的区别:

  • 重载(Overload)

  • 重写(Override),或者叫运行时多态,这是本文主要要讨论的内容。

先来看看重载,(代码来源于书中)

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);//hello, guy
	sr.sayHello(woman);//hello, guy
    }
}

从语法的角度来聊一聊为什么上面的三个sayHello方法是重载的。方法之间是重载的要求这些方法具有:相同的简单名称 和 不同的特征签名。

  • 方法的简单名称是:没有类型和参数修饰的方法名称。比如上面的sayHello方法的简单名称就是 字符串"sayHello"
  • 方法的特征签名:可简单粗暴地理解成方法的 参数类型、参数顺序、参数个数。
    对于上面的三个sayHello方法而言,它们的简单名称是相同的,而参数类型不同(一个是Human 类型、一个是Woman类型、一个是Man类型),因此:它们是重载的。
    额外多补充一点:
    上面并没有提到方法的返回值,因为方法的返回值并不属于特征签名。
    当你在编辑器,比如IDEA或者Eclipse 中写了两个 简单名称相同、特征签名也相同、但是方法返回值不同的两个方法时,会报编译错误:“定义了两个同名的方法”。但是,这两个“同名的方法”是可以共存于同一个class文件中的。因为class文件格式规定了:描述符不完全一致的两个方法可以共存于同一个class文件中。
    那什么是方法的描述符呢?
    每个人编写代码的时候,给方法定义一个方法、给方法取个名字、带上参数……写出来的方法的无穷无尽的,如何用一套统一的规则来描述这些写出来的方法,就是方法描述符干的事情。

用描述符来描述方法时,先按照参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号内

从上面的描述符定义看,方法的描述符是包含了方法的返回值的。因此, 简单名称相同、特征签名也相同、但是方法返回值不同的两个方法可共存于同一个class文件中。
总结一下,讨论一个方法是否“相同”,这里涉及到了三个概念:

  • 简单名称
  • 方法特征签名
  • 方法描述符

我的理解,也许不准确:简单名称和特征签名在语法层面 来判断 编写的两个方法是否 是相同的;方法描述符在字节码层面 来判断 两个方法是否是 相同的;方法描述符不仅包含了特征签名、还包含了方法返回值。搞明白这三者的区别及作用就好了。
这个时候,你可能就有疑问了:既然简单名称相同、特征签名也相同、但是方法返回值不同的两个方法可共存于同一个class文件中,在jvm在执行代码(这两个方法)的时候怎么办呢?其实不用担心,在类加载的时候,有一个验证阶段,验证阶段包含了一个叫元数据验证的过程,元数据验证过程会验证 加载到内存方法区里面的class字节码是否符合方法重载的规则。因此,虽然这两个方法共存于同一个class文件中,但这种不符合java语义(语言规范)的情形 最终在验证阶段会被检查出来的。

再来看看重写(Override)

public class DynamicDispatch {
    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();//man say hello
	woman.sayHello();//woman say hello
    }
}

在StaticDispatch.java 中,并不存在子类方法、父类方法。只有StaticDispatch.java的sayHello方法,即:sayHello 方法的接收者都是 StaticDispatch sr 对象,需要根据sayHello方法的参数类型来确定,具体执行下面这三个方法中的哪一个方法:

    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");
    }

而在DynamicDispatch.java中,首先有一个父类Human,它有一个sayHello方法,然后有两个子类:Woman、Man,它们分别@Override 了父类中的sayHello方法,也就是说:子类重写了父类中的方法。

上而就是从(源代码)语法的角度 描述了一下 重载(Overload) 和 重写(Override 或者叫运行时多态)的区别。程序要想执行,先要将源代码编译成字节码文件。

接下来看一下,二者在字节码文件上的不同

首先javac 命令将 StaticDispatch.java 和 DynamicDispatch.java编译成 class文件,然后使用分别使用下面命令输出这两个文件字节码的内容:

javap -verbose StaticDispatch


(图一)

上面截取的是 StaticDispatch.java main方法中的方法表中的内容。方法表的结构 可参考书中第6.3.6小节的描述。

main方法中的代码,经过编译器编译成字节码指令后,存放在方法属性表集合中的一个名为 "Code" 的属性里面。

StaticDispatch 的main方法 字节码的执行过程

上面的 序号26 和 序号31 红色方框标出来的内容叫做:方法的符号引用,从而可以判断:sr.sayHello(man);sr.sayHello(woman); 是由 invokevirtual指令执行的。

而且方法的符号引用都是:Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V

好,那咱就来看看,invokevirtual指令的具体执行过程,看它是如何将符号引用 解析到 具体的方法上的。

因为,覆盖(Override)或者说运行时多态也是通过invokevirtual指令来选择具体执行哪个方法的,因此:invokevirtual指令的解析过程 可以说是JAVA中实现多态的原理吧。

invokevirtual指令的解析过程大致分为以下几个步骤:

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

因此,第一步,找到操作数栈顶的第一个元素所指的对象的实际类型,这个对象其实就是方法接收者的实际类型,它是StaticDispatch对象sr StaticDispatch sr = new StaticDispatch()

为什么是sr对象呢?比如对于序号26的invokevirtual指令,序号24、25行的两条aload_3 和 aload_1字节码指令 分别是把第四个引用类型的变量推送到栈顶,把第二个引用类型的变量推送到栈顶。而第四个引用类型的变量是StaticDispatch sr 对象;第二个引用类型的变量则是Man类的对象Human man = new Man()

第二步,根据常量 Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V寻找 StaticDispatch类中哪个方法的简单名称和描述符都与该常量相同。

常量Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V的简单名称是 \'sayHello\',描述符信息是:返回的类型为空,参数类型为Human,只有一个参数。

而在StaticDispatch.java中一共有三个不同的sayHello方法,它们的简单名称都是\'sayHello\',而描述符中的参数类型为\'Human\'类型的方法是:

    public void sayHello(Human guy) {
	System.out.println("hello, guy");
    }

因此,sr.sayHello(man);实际调用的方法就是上面的public void sayHello(Human guy)方法。

同样地,sr.sayHello(woman);的方法接收者的实际类型是StaticDispatch对象sr,由序号31可知方法常量还是Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V ,因此,实际调用的方法还是public void sayHello(Human guy)

从这里可看出:对于重载(Overload)而言,它的方法接收者的类型是相同的,那调用哪个重载方法就取决于:传入的参数类型、参数的数量等。而参数类型在编译器生成字节码的时候就已经确定了,比如上面的sayHello方法的参数类型都是Human(sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V

因此,sr.sayHello(man);sr.sayHello(woman);执行的是相同的方法public void sayHello(Human guy){}

接下来看看:覆盖(Override),也即运行时多态的执行情况:

javap -verbose DynamicDispatch


(图二)

上面截取的是DynamicDispatch.java的main方法的执行过程。从序号17和21 可知:man.sayHello();woman.sayHello();也都是由虚拟机指令invokevirtual指令执行的,并且调用的sayHello方法的符号引用都是Method org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V

那为什么最终执行的结果却是:man.sayHello()输出 \'man say hello\',而woman.sayHello()输出\'woman say hello\'呢?

	man.sayHello();//man say hello
	woman.sayHello();//woman say hello

下面再来过一遍invokevirtual指令的执行过程。当虚拟机执行到man.sayHello()这条语句时,invokevirtual指令第一步:找到操作数栈顶的第一个元素,这个元素就是序号7 astore_1存进去的,它是一个Man类型的对象

接下来,第二步,在 Man 类中寻找与常量中描述符和简单名称都相符的方法,在这里常量是Method org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V,而Man 类中与该常量的描述符和简单名称都相符的方法,显然就是 Man 类中的sayHello方法了。

于是invokevirtual指令就把 常量池中的类方法符号引用 解析 到了 具体的Man类的sayHello方法的直接引用上。

同理,类似地,在执行woman.sayHello()这条语句时,invokevirtual指令找到的操作数栈顶的第一个元素是由 指令15astore_2存储进去的Woman类型的对象。于是,在Woman类中 寻找与常量池类方法的符号引用Method org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V都相符的方法,这个方法就是Woman类中的sayHello方法。

从上面的invokevirtual指令的执行过程看,语句man.sayHello();woman.sayHello(); 对应的类方法的符号引用是一样的,都是org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V,但由于方法接受者的实际类型不同,一个是Man类型、另一个是Woman类型,因为最终执行的方法也就不一样了。

文中涉及到的一些额外的概念:

  • 方法接收者:sr.sayHello(new Man()), sr 对象就是 sayHello方法的接收者
  • 常量:常量池中的常量,可参考常量池中的项目类型
  • 描述符:用来描述字段的数据类型,方法的参数列表和返回值,方法的参数列表指的是:方法有多少个参数、方法的参数是什么类型、参数的顺序
  • 简单名称:没有类型和参数修饰的方法或者字段名称。比如说方法:public void m(String a){},那简单名称就是 m

总之,jvm在判断具体执行哪个方法时,不仅要看方法的描述符(特征签名),而且要看方法的接收者的实际类型。而在多态中,从上面的示例中可以看出:方法的接收者的类型是不同的
以上纯个人理解,有些概念可能表述地不太严谨,若有错误,望指正,感激不尽。

写完这篇文章,我抬头望向窗外,天又黑了。目光缓缓移回到电脑屏幕上,一个技术人的追求到底是什么?我应该往哪个方向深入下去呢?后台、算法、ML、或者高大上的DL?

于是又想起了上一次的对话中那个人说的:关键是看你能不能持续地花时间把背后的原理搞清楚。

参考书籍:《深入理解JVM虚拟机》

原文:https://www.cnblogs.com/hapjin/p/9248525.html

以上是关于理解多态的语法使用以及多态的实现原理的主要内容,如果未能解决你的问题,请参考以下文章

C++基础语法多态

C 语言实现多态的原理:函数指针

9-1:C++多态之对多态的理解和多态的实现条件以及虚函数还有重载重写冲定义的区别

java多态理解和底层实现原理剖析

C++ 继承和多态

C++ 继承和多态