C++中多态的原理

文章目录

前言

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

这里有一道常考笔试题:sizeof(Base)是多少?


为什么不是8?

可以调试带大家看一下。

仔细看,对象的头部多了一个指针。

这个指针叫做虚函数表指针。

上面不重要,重要的是下面的东西,多态的原理。

这个指针指向的表里到底有什么东西呢?

多态的原理

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

cpp 复制代码
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;
}

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

为什么?怎么实现的?

这个指针指向父类调用父类的虚函数,指向子类调用子类的虚函数。

怎么做到的呢?

大家看父类对象的虚表存的是父类的虚函数,子类对象的虚表存的是子类的虚函数。

编译器是怎么做的呢?

编译器也是判断构不构成多态,如果不构成多态,它就编译时确定调用的地址。

怎么确定呢?

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

如果是多态

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

带大家来调试看一下

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

这个person对象有两种情况

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

看指向的对象,指向的父类调用父类的,指向子类调用子类的。

多态就是转换成汇编的问题

不构成多态直接确定地址,构成多态,转成对应的汇编指令。

这段指令干嘛?无法确定地址,不知道调用谁的,那引用指向的是父类,

它找到父类头四个字节,找到虚表的指针,找到虚表,找到虚函数,靠的就是这个虚函数。

指向子类就会切割或者切片,

单看p.BuyTicket();这个指令,它不知道指的是子类还是父类。

汇编指令一样,为什么调用结果不一样?

因为传不同的对象,不同的对象虚表是不一样的。

虚函数的另外一个名字为什么叫做覆盖?

如果在子类里面,重写虚函数以后,子类里面对应的虚表位置,会把它拷贝过来,

覆盖成我的虚函数一样。

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

多态的条件要求

现在可以反过来思考多态的条件
1.多态的条件为什么是重写?

因为要覆盖虚表那个虚函数的位置。

2.为什么指针或者引用呢?

因为指针和引用既可以指向父类对象也可以指向子类对象。

为什么不把虚函数直接存到对象的头上呢?

因为他可能有多个虚函数,都存到对象里面不合适。

其次,同类型的虚表一样。

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

如果有多个虚函数

再来感受什么叫覆盖。

第一个虚函数完成了重写,可以这样认为,子类对象先把父类对象的表拷贝过来。

然后重写那个覆盖成我自己的。没有重写就不覆盖。

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

虚函数表里可能有多个地址,那具体调用哪一个呢?

看函数的声明顺序是第几个。

3.如果是父类的对象能不能实现多态?

父类的指针或者引用在这里可以切片。父类的对象也可以切片。

对象为什么不能实现多态,从原理上看?

它转换成指令就是编译的时候,如果是对象peron直接去调person的就可以了。

它也可以实现切片,为什么不往多态去实现?

如果是指针和引用与对象的区别是什么,它们的切片有点不一样。

如果是指针和引用的切片?

如果是指针是指向这个父类或者引用这个父类。

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

如果是对象呢?

如果是个父类没什么问题,如果是子类呢?

子类给父类的切片,成员会拷贝过去,它会调用拷贝构造。
这里涉及一个问题?虚表会不会拷过去?

如果不会拷过去,父类的对象的虚表里面永远是父类的虚函数。

它不敢拷贝虚表,因为拷贝有一个很大的问题。

因为拷贝了就乱了。假设对虚表进行深拷贝,父类对象的虚表到底是子类的虚函数,

还是父类的虚函数完全分不清楚。

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

感受一下,虚表没有变

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

再来一个问题,
虚函数存在虚表这句话对不对?

不对,虚函数跟普通函数一样,都是放在代码段的。

但是虚函数的地址会别放进虚函数表。

这里涉及到linux操作系统的知识。大家可以去了解一下。

大家可以看一下同类的对象是不是构成同一张虚表


父类和子类的虚表不一样,因为子类要重写要有独立的虚表。

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

cpp 复制代码
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;
}

虚函数表

记住不是虚函数进入虚表,而是虚函数的地址进入虚表。

虚表的全称是虚函数表。

虚表的本质就是一个指针数组。

基类的虚表

派生类的虚表

派生类的虚表也有两个虚函数的地址

不同的是,你可以这样认为子类的虚表是把父类的虚表给拷贝过来,

拷贝过来以后做什么是呢?

重写虚函数,重写的那个位置会完成覆盖,覆盖成我重写的虚函数。

多态的本质就是依靠虚表来实现的。

比如有个父类的指针或者引用,可以指向父类对象也可以指向

子类对象,指向子类对象是把子类对象父类那部分给切出来。

你可以这样认为,对于这个指针而言,看到的都是父类对象。

只是一个本身就是父类对象,一个是子类对象里切出来的父类对象。

ptr->Func1();底层的汇编都是一样的,代码的本质都是转换成汇编。

它不管你是什么,它都去虚表里面找那个虚函数的地址。

所以指向父类调用父类的,指向子类调用子类的。

假设在派生类里面加了一个Func4();

现在Func1();完成了重写,Func4();没有完成重写。

我们现在看一下Func4();在不在虚表里呢?

没有看到,Func4();哪去了呢?

Func4();是虚函数,怎么没在虚表呢?

我们去看一下内存窗口。

Func1()和Func2()都在,那这个是Func4();吗?

怎样验证一下呢?

可以打印Func4();的地址对比一下吗?可以是可以,但是后面还有其他更复杂的情况。

现在是一个单继承,那多继承呢,还有菱形继承。

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

用程序打印虚表

怎么打印?

假设我已经有虚表的地址了,这个函数指针的数组的地址,

现在怎么打印。

它是一个函数指针,处理起来比较麻烦。

这句话是什么意思。这里typedef一个函数指针。

函数指针本身很特殊,应该是这样的。

但是函数指针typedef不能前面是类型后面是重命名的名字。

函数指针定义变量或者typedef都应改放到中间。

打印数组很简单, 但是不确定这个数组有多大,因为不同

对象的虚表是不一样的,g++下就只能写死,比如知道有三个,

就只能打印三个。但是vs系列给了一个遍历。

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

如果自己vs的编译器没有看到nullptr,清理一下解决方案,然后再重新生成解决方案

就可以了。

再接着往下看,我现在要把虚表的地址给取出来。

怎么样把虚表的地址取出来呢?

这个指针在对象的头4个字节或者头8个字节。

如何去取对象的头4个字节?

可以回顾一下学大小端的时候,想取低位的值。

假设给你一个整型,我想取这个整型的第一个字节是怎么取的。

1.定义联合体(这里再定义一个联合体加不进来了)

2.将int的地址jint强转成char再解引用。

这里我们用第二种玩法。

但是这里是int,函数传参传不过去。int在强转为对应的类型。

传参的时候不会直接转吗?

不会,直接转是隐式类型转换,C++只有相近类型才支持隐式类型转换。

比如int, double, char.

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

注意,不能用sizeof()去算数组,只要传参都会出问题。

还有这个不是我们平时用的那种数组,只有我们定义的静态的数组才能算数组的大小0。

其他地方都不行。

还有一种更直接的方式

这还是简化过的,如果直接把函数指针套进来,就变成天书了。

带大家理解一下。

为甚么不直接这样呢?

先说结论,这样是不行的。

首先你要传过去的地址在哪?在对象的头4个字节或8个字节。

必须有解引用才能把对象的头4个字节或8个字节取出来。

&b是指向对象的指针,你要传1号位置的指针还是2号位置的指针。2号。

而你现在传的是1号,2号位置的指针在对象的头4个字节上,怎么取出来?

强转成VF_PTR**, 指针解引用在32位看4个字节,在64为看8个字节。

这两种写法的差异是什么?

第一种写法具有一定的局限性,局限性在于它只能在32位跑,

64位下就跑不通了。

第二种写法都适应,VF_PTR**解引用是看VF_PTR*,VF_PTR*在

32位4个字节,64位8个字节。

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


有个疑问,父类没有Func4();怎么能进入虚表呢?

这个虚表已经不仅仅属于父类了,它被继承了。只是生长点是子类对象父类的一部分。

Func4();是子类的,其次这个虚表严格来说是属于子类的。

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

虚表是在什么阶段生成的?

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

对象中虚表什么时候初始化?

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

虚表存在哪里?

首先虚表不在对象里面,对象里面的是虚表指针。

它有没有可能在栈上?

绝对不可能,因为多个对象存指向同一张虚表。栈里面只有栈帧,函数调用结束了,然后销毁了,不可能。

有没有可能在堆上呢?

有可能,但是不合理。堆一般是动态申请的。不可能。

接下来我们可以验证一下是在静态区还是常量区?

打印几个地址来对比一下就可以了。

对比地址的远近,虚表的地址跟常量区最接近。

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

其实看下面这个也能看出来

编译好的函数是一串指令,这串指令的地址就是函数的地址,函数的地址是放到代码段

常量区的。

多继承的虚函数表

对于Base1和Base2没什么,关键就是看多继承的Derive;

先看监视窗口。

Derive应该有两张虚表,因为它同时继承了Base1和Base2

两张虚表里重写了func1();func2();没动。

现在有一个问题,子类的func3();放在哪里呢?

我们这里借助虚表打印看一下。

现在有一个问题,第一张虚表在第一个位置,打印第二张虚表怎么大?

两张虚表是放在两个对象里的,无法确定它是不是连续。

因为Base1除了这张虚表还有其他成员变量。

1.跳过Base1,加上sizeof(Base1);

2.用切片,借助指针的偏移。(Base2的指针会自动偏移)

但是这样不对,&d是Derive*, Derive*+1跳过Derive,强转成char*,char*+1跳过一个字节。

它放到第一张表去了

要理解指针的偏移。

我们先看一下下面这道题。

这道题理解了切片就能做。

p1虽然跟p3的地址一样,但是意义不一样。

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

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

大家有没有发现,重写的func1的地址不一样?

首先问大家一个问题,这个函数是不是func1?是不是重写的fuc1的地址?

是,因为我们后面的字符串是去调用这个函数打印的。

但是这个地址为什么不一样呢?

这个问题很深很不容易理解,我们只有看汇编才能看懂。

这两个都会转成汇编代码,这两段汇编代码一不一样。这两个地方调用的是不是同一个函数?

这里是不是call的同一个地址?

很多人都认为是一样的,因为这里符合多态的条件。

**你可以认为,第二个地址被封装过。**因为不封装完成不了调用,因为有些条件我们理解有一些偏差。

这里有深层次的原因。

重新运行地址不变,不利于比较,因为这涉及进程加载的一些原因。它要进行重定位。

ptr1是正常调用。

大家看ptr2连续jmp了好几次,为甚么?

jmp就相当于封装。

右边有一段指令非常特殊

ecx存的是this指针,然后配着这个图大家就能看懂

去调用子类的这个函数的时候。

ptr1没有处理的原因是因为它恰好指向子类对想的开始。

调用子类的函数,this指针应该指向子类对象。

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

这个指令的作用就是修正this指针的位置。

这里不一定减8,它减的是一个Base1的大小。

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

静态多态和动态多态

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

那什么是静态多态呢?

函数重载。

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

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

什么是动态多态呢?

对应运行时。

这两个本质都是写死了。

菱形虚拟继承

A有一个虚函数func,B有一个虚函数func,C有一个虚函数func,

D没有虚函数,这是不行的。

D如果不重写,会报错。它说不明确,为什么?

现在B重写了,C重写了,现在有一个问题,A的虚表里放谁的虚函数?

这个问题感兴趣的可以自己去了解一下,我这里就先不回答了。

相关推荐
阿伟*rui2 小时前
配置管理,雪崩问题分析,sentinel的使用
java·spring boot·sentinel
XiaoLeisj4 小时前
【JavaEE初阶 — 多线程】单例模式 & 指令重排序问题
java·开发语言·java-ee
paopaokaka_luck4 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
dayouziei4 小时前
java的类加载机制的学习
java·学习
捕鲸叉5 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer5 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
Peter_chq5 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
Yaml45 小时前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍
小小小妮子~6 小时前
Spring Boot详解:从入门到精通
java·spring boot·后端
hong1616886 小时前
Spring Boot中实现多数据源连接和切换的方案
java·spring boot·后端