C++中多态的原理

文章目录

  • 前言
  • 多态的原理
  • 多态的条件要求
  • 虚函数表
  • 用程序打印虚表
  • 多继承的虚函数表
  • 静态多态和动态多态
  • 菱形虚拟继承

前言

上篇讲解了多态的原理,这篇文章来详细讲解一下多态的原理。

这里有一道常考笔试题:sizeof(Base)是多少?
C++中多态的原理_第1张图片

C++中多态的原理_第2张图片
为什么不是8?
可以调试带大家看一下。
仔细看,对象的头部多了一个指针。
C++中多态的原理_第3张图片
这个指针叫做虚函数表指针。

上面不重要,重要的是下面的东西,多态的原理。
这个指针指向的表里到底有什么东西呢?

多态的原理

看下面,这里有两个对象,一个是mike,一个是johnson,这两个对象都有表指针。

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person mike;
	Func(mike);
	Student johnson;
	Func(johnson);
	return 0;
}

我们之前讲过,构成多态跟什么有关。
跟指针或者这个引用指向的对象有关。

为什么?怎么实现的?
C++中多态的原理_第4张图片
这个指针指向父类调用父类的虚函数,指向子类调用子类的虚函数。
怎么做到的呢?

大家看父类对象的虚表存的是父类的虚函数,子类对象的虚表存的是子类的虚函数。
编译器是怎么做的呢?
编译器也是判断构不构成多态,如果不构成多态,它就编译时确定调用的地址。

怎么确定呢?
看person是什么类型。那它去person里面找到这个函数,确定这个地方的地址。

如果是多态
它就会去指向的对象的虚表里去找。
编译器也很简单,就是严格的去卡这个多态的条件满不满足。

带大家来调试看一下
C++中多态的原理_第5张图片
C++中多态的原理_第6张图片

构成多态的情况:
p.BuyTicket();这个指令执行不知道调用的是谁。为甚么?
这个person对象有两种情况

上面这段汇编代码的本质就是,跟调用的指针对象或引用对象的类型已经无关,
看指向的对象,指向的父类调用父类的,指向子类调用子类的。

多态就是转换成汇编的问题
不构成多态直接确定地址,构成多态,转成对应的汇编指令。
这段指令干嘛?无法确定地址,不知道调用谁的,那引用指向的是父类,
它找到父类头四个字节,找到虚表的指针,找到虚表,找到虚函数,靠的就是这个虚函数。
C++中多态的原理_第7张图片

指向子类就会切割或者切片,
C++中多态的原理_第8张图片

单看p.BuyTicket();这个指令,它不知道指的是子类还是父类。
汇编指令一样,为什么调用结果不一样?
因为传不同的对象,不同的对象虚表是不一样的。

虚函数的另外一个名字为什么叫做覆盖?
如果在子类里面,重写虚函数以后,子类里面对应的虚表位置,会把它拷贝过来,
覆盖成我的虚函数一样。

你可以这样认为,重写是语法层的概念,覆盖是原理层的概念。

多态的条件要求

现在可以反过来思考多态的条件
1.多态的条件为什么是重写?
因为要覆盖虚表那个虚函数的位置。

2.为什么指针或者引用呢?
因为指针和引用既可以指向父类对象也可以指向子类对象。

为什么不把虚函数直接存到对象的头上呢?
因为他可能有多个虚函数,都存到对象里面不合适。
其次,同类型的虚表一样。

虚函数表:本质是一个虚函数指针数组

如果有多个虚函数
C++中多态的原理_第9张图片

再来感受什么叫覆盖。
C++中多态的原理_第10张图片
第一个虚函数完成了重写,可以这样认为,子类对象先把父类对象的表拷贝过来。
然后重写那个覆盖成我自己的。没有重写就不覆盖。

虚函数表其实是在编译的时候就确定好 ,没有重写是一个样子,
完成了重写是另外一个样子。

虚函数表里可能有多个地址,那具体调用哪一个呢?
看函数的声明顺序是第几个。

3.如果是父类的对象能不能实现多态?
父类的指针或者引用在这里可以切片。父类的对象也可以切片。
对象为什么不能实现多态,从原理上看?
它转换成指令就是编译的时候,如果是对象peron直接去调person的就可以了。

它也可以实现切片,为什么不往多态去实现?
如果是指针和引用与对象的区别是什么,它们的切片有点不一样。

如果是指针和引用的切片?
如果是指针是指向这个父类或者引用这个父类。
子类呢?把子类对象父类那部分切出来。然后指向或引用切出来的那部分。
子类这部分的虚表还是子类的。

如果是对象呢?
如果是个父类没什么问题,如果是子类呢?
子类给父类的切片,成员会拷贝过去,它会调用拷贝构造。
这里涉及一个问题?虚表会不会拷过去?
如果不会拷过去,父类的对象的虚表里面永远是父类的虚函数。
它不敢拷贝虚表,因为拷贝有一个很大的问题。
因为拷贝了就乱了。假设对虚表进行深拷贝,父类对象的虚表到底是子类的虚函数,
还是父类的虚函数完全分不清楚。

所以对象的切片,只拷贝成员不拷贝虚表。

感受一下,虚表没有变
C++中多态的原理_第11张图片

只有虚函数的地址才会存进虚表。

再来一个问题,
虚函数存在虚表这句话对不对?
不对,虚函数跟普通函数一样,都是放在代码段的。
但是虚函数的地址会别放进虚函数表。

这里涉及到linux操作系统的知识。大家可以去了解一下。
C++中多态的原理_第12张图片

大家可以看一下同类的对象是不是构成同一张虚表
C++中多态的原理_第13张图片
C++中多态的原理_第14张图片
父类和子类的虚表不一样,因为子类要重写要有独立的虚表。

监视窗口看到的是被修饰过的,监视窗口看到的不一定是最真实的。

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}

	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}

private:
	int _b = 1;
};

class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};

int main()
{
	Base b1;
	Base b2;
	Base b3;

	Derive d;

	b1.Func1();
	b1.Func3();
	return 0;
}

虚函数表

记住不是虚函数进入虚表,而是虚函数的地址进入虚表。
虚表的全称是虚函数表。
虚表的本质就是一个指针数组。

C++中多态的原理_第15张图片

基类的虚表
C++中多态的原理_第16张图片

派生类的虚表
派生类的虚表也有两个虚函数的地址
不同的是,你可以这样认为子类的虚表是把父类的虚表给拷贝过来,
拷贝过来以后做什么是呢?
重写虚函数,重写的那个位置会完成覆盖,覆盖成我重写的虚函数。
C++中多态的原理_第17张图片

多态的本质就是依靠虚表来实现的。
比如有个父类的指针或者引用,可以指向父类对象也可以指向
子类对象,指向子类对象是把子类对象父类那部分给切出来。
你可以这样认为,对于这个指针而言,看到的都是父类对象。
只是一个本身就是父类对象,一个是子类对象里切出来的父类对象。

ptr->Func1();底层的汇编都是一样的,代码的本质都是转换成汇编。
它不管你是什么,它都去虚表里面找那个虚函数的地址。
所以指向父类调用父类的,指向子类调用子类的。

假设在派生类里面加了一个Func4();
C++中多态的原理_第18张图片

现在Func1();完成了重写,Func4();没有完成重写。
我们现在看一下Func4();在不在虚表里呢?
C++中多态的原理_第19张图片
没有看到,Func4();哪去了呢?
Func4();是虚函数,怎么没在虚表呢?

我们去看一下内存窗口。
C++中多态的原理_第20张图片
Func1()和Func2()都在,那这个是Func4();吗?
怎样验证一下呢?
可以打印Func4();的地址对比一下吗?可以是可以,但是后面还有其他更复杂的情况。
现在是一个单继承,那多继承呢,还有菱形继承。

接下来,我们就会讲到一个新玩法,用程序打印虚表。

用程序打印虚表

怎么打印?
假设我已经有虚表的地址了,这个函数指针的数组的地址,
现在怎么打印。

它是一个函数指针,处理起来比较麻烦。
在这里插入图片描述
这句话是什么意思。这里typedef一个函数指针。
函数指针本身很特殊,应该是这样的。
在这里插入图片描述
但是函数指针typedef不能前面是类型后面是重命名的名字。
函数指针定义变量或者typedef都应改放到中间。
C++中多态的原理_第21张图片

打印数组很简单, 但是不确定这个数组有多大,因为不同
对象的虚表是不一样的,g++下就只能写死,比如知道有三个,
就只能打印三个。但是vs系列给了一个遍历。

vs系列在存储虚表的时候,在数组的最后放了一个nullptt,
g++没有。

如果自己vs的编译器没有看到nullptr,清理一下解决方案,然后再重新生成解决方案
就可以了。
C++中多态的原理_第22张图片

C++中多态的原理_第23张图片

再接着往下看,我现在要把虚表的地址给取出来。
C++中多态的原理_第24张图片
怎么样把虚表的地址取出来呢?
这个指针在对象的头4个字节或者头8个字节。
如何去取对象的头4个字节?

可以回顾一下学大小端的时候,想取低位的值。
假设给你一个整型,我想取这个整型的第一个字节是怎么取的。
1.定义联合体(这里再定义一个联合体加不进来了)
2.将int的地址jint强转成char再解引用。

这里我们用第二种玩法。
C++中多态的原理_第25张图片
但是这里是int,函数传参传不过去。int在强转为对应的类型。
C++中多态的原理_第26张图片
C++中多态的原理_第27张图片

传参的时候不会直接转吗?
不会,直接转是隐式类型转换,C++只有相近类型才支持隐式类型转换。
比如int, double, char.

指针都是一个地址,但是指针的类型决定了指针接引用的时候看多大。

注意,不能用sizeof()去算数组,只要传参都会出问题。
还有这个不是我们平时用的那种数组,只有我们定义的静态的数组才能算数组的大小0。
其他地方都不行。

还有一种更直接的方式
在这里插入图片描述
这还是简化过的,如果直接把函数指针套进来,就变成天书了。

带大家理解一下。
C++中多态的原理_第28张图片

C++中多态的原理_第29张图片

为甚么不直接这样呢?
在这里插入图片描述
先说结论,这样是不行的。
首先你要传过去的地址在哪?在对象的头4个字节或8个字节。
必须有解引用才能把对象的头4个字节或8个字节取出来。
&b是指向对象的指针,你要传1号位置的指针还是2号位置的指针。2号。
而你现在传的是1号,2号位置的指针在对象的头4个字节上,怎么取出来?

强转成VF_PTR**, 指针解引用在32位看4个字节,在64为看8个字节。
C++中多态的原理_第30张图片

这两种写法的差异是什么?
第一种写法具有一定的局限性,局限性在于它只能在32位跑,
64位下就跑不通了。
第二种写法都适应,VF_PTR**解引用是看VF_PTR*,VF_PTR*在
32位4个字节,64位8个字节。

现在已经可以打印出来虚表里虚函数的地址了,但是怎么确认就是这个呢,
再教大家一招。

C++中多态的原理_第31张图片
C++中多态的原理_第32张图片

有个疑问,父类没有Func4();怎么能进入虚表呢?
这个虚表已经不仅仅属于父类了,它被继承了。只是生长点是子类对象父类的一部分。
Func4();是子类的,其次这个虚表严格来说是属于子类的。

父类的虚表和子类的虚表不是同一个,子类继承了以后,子类把虚表拷贝了一份,
然后子类对其重写,自己的虚函数也会进入这个虚表。

虚表是在什么阶段生成的?
编译的时候就生成了,因为编译的时候就有这些函数的地址,就可以组成父类的虚表和子类的虚表。

对象中虚表什么时候初始化?
它是在构造函数的初始化列表的时候初始化。自己可以单独通过调试看一下。

虚表存在哪里?
首先虚表不在对象里面,对象里面的是虚表指针。
它有没有可能在栈上?
绝对不可能,因为多个对象存指向同一张虚表。栈里面只有栈帧,函数调用结束了,然后销毁了,不可能。
C++中多态的原理_第33张图片
有没有可能在堆上呢?
有可能,但是不合理。堆一般是动态申请的。不可能。

C++中多态的原理_第34张图片
接下来我们可以验证一下是在静态区还是常量区?
打印几个地址来对比一下就可以了。
C++中多态的原理_第35张图片
对比地址的远近,虚表的地址跟常量区最接近。

其实大家可以仔细想想虚表被编译好了会不会改?
虚表在编译的过程中可能被改,尤其是子类的虚表。
运行的时候不会被改,所以放在常量区更合适。

其实看下面这个也能看出来
C++中多态的原理_第36张图片
编译好的函数是一串指令,这串指令的地址就是函数的地址,函数的地址是放到代码段
常量区的。

多继承的虚函数表

C++中多态的原理_第37张图片

对于Base1和Base2没什么,关键就是看多继承的Derive;
先看监视窗口。

Derive应该有两张虚表,因为它同时继承了Base1和Base2
C++中多态的原理_第38张图片
两张虚表里重写了func1();func2();没动。

现在有一个问题,子类的func3();放在哪里呢?
我们这里借助虚表打印看一下。
C++中多态的原理_第39张图片
现在有一个问题,第一张虚表在第一个位置,打印第二张虚表怎么大?
C++中多态的原理_第40张图片

两张虚表是放在两个对象里的,无法确定它是不是连续。
因为Base1除了这张虚表还有其他成员变量。

1.跳过Base1,加上sizeof(Base1);
2.用切片,借助指针的偏移。(Base2的指针会自动偏移)
在这里插入图片描述
但是这样不对,&d是Derive*, Derive*+1跳过Derive,强转成char*,char*+1跳过一个字节。

它放到第一张表去了
C++中多态的原理_第41张图片

要理解指针的偏移。
我们先看一下下面这道题。
C++中多态的原理_第42张图片
这道题理解了切片就能做。
p1虽然跟p3的地址一样,但是意义不一样。
C++中多态的原理_第43张图片

谁先继承谁就先声明,谁先声明谁就在前面。

C++中多态的原理_第44张图片
func1会完成重写。它会重写两份,覆盖两个位置,覆盖Base1的虚表也会覆盖Base2的虚表。
但是这里面有一个非常奇怪的现象。这才是真正的大难题,十个学C++的9个都会翻车。

大家有没有发现,重写的func1的地址不一样?
首先问大家一个问题,这个函数是不是func1?是不是重写的fuc1的地址?
是,因为我们后面的字符串是去调用这个函数打印的。
C++中多态的原理_第45张图片

但是这个地址为什么不一样呢?
这个问题很深很不容易理解,我们只有看汇编才能看懂。

C++中多态的原理_第46张图片

这两个都会转成汇编代码,这两段汇编代码一不一样。这两个地方调用的是不是同一个函数?
这里是不是call的同一个地址?
很多人都认为是一样的,因为这里符合多态的条件。
**你可以认为,第二个地址被封装过。**因为不封装完成不了调用,因为有些条件我们理解有一些偏差。
这里有深层次的原因。

C++中多态的原理_第47张图片
重新运行地址不变,不利于比较,因为这涉及进程加载的一些原因。它要进行重定位。

ptr1是正常调用。
大家看ptr2连续jmp了好几次,为甚么?
jmp就相当于封装。
右边有一段指令非常特殊
在这里插入图片描述
ecx存的是this指针,然后配着这个图大家就能看懂
C++中多态的原理_第48张图片

去调用子类的这个函数的时候。
ptr1没有处理的原因是因为它恰好指向子类对想的开始。
调用子类的函数,this指针应该指向子类对象。

ptr2去调用子类的这个函数的时候,this指针不对。

这个指令的作用就是修正this指针的位置。
这里不一定减8,它减的是一个Base1的大小。

这里还涉及一个问题,如果先继承Base2,base1就要修正。

静态多态和动态多态

有些地方会分静态多态和动态多态。

那什么是静态多态呢?
函数重载。

一般语言层面,说静态都是指的编译时。

函数重载就是通过编译时实现的。

什么是动态多态呢?
对应运行时。

这两个本质都是写死了。

菱形虚拟继承

C++中多态的原理_第49张图片
A有一个虚函数func,B有一个虚函数func,C有一个虚函数func,
D没有虚函数,这是不行的。
D如果不重写,会报错。它说不明确,为什么?
C++中多态的原理_第50张图片

现在B重写了,C重写了,现在有一个问题,A的虚表里放谁的虚函数?
这个问题感兴趣的可以自己去了解一下,我这里就先不回答了。

你可能感兴趣的:(c++,c++,java,jvm)