Bootstrap

C++:多态的底层实现原理 -- 虚函数表

目录

一. 多态的原理

1.1 虚函数表

1.2 多态的实现原理

1.3 动态绑定与静态绑定

二. 多继承中的虚函数表

2.1 虚函数表的打印

2.2 多继承中虚函数表中的内容存储情况


一. 多态的原理

1.1 虚函数表

对于一个含有虚函数的的类,在实例化出来对象以后,对象所存储的内容包含两部分:

  • 类的成员变量。
  • 一个指向虚函数表得虚函数表指针。

下段代码定义了一个Base类,其中包含虚函数func1以及一个int型数据,在main函数中,使用sizeof(Base)计算这个类实例化出来的对象大小为8bytes而不是4bytes,这正是因为虚函数表指针占了4bytes的存储空间(32位编译环境)。

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

	int _b = 1;
};

int main()
{
	Base b;
	std::cout << sizeof(b) << std::endl;  //8
	return 0;
}

如果要调用Base中定义的虚函数func,那么程序会在运行时根据虚函数指针找到虚函数表,虚函数表中存有函数指针(函数所在地址),程序会根据虚函数表中存储的虚函数所在地址,找到对应的函数进行调用。

图1.1 虚函数指针和虚函数表

1.2 多态的实现原理

多态的实现,是通过虚函数的重写来实现的。对于一个包含虚函数的基类Base,设有一派生类Derive继承了基类Base,那么Derive会将Base的虚函数表一并继承下来。

  • 如果Derive中没有对Base中的虚函数进行重写,那么Derive和Base各自拥有不同的虚函数表,两者虚函数表中存储的内容相同。
  • 如果Derive对Base的虚函数完成了重写,那么虚函数表中的被重写的虚函数的地址会被覆盖,更新为派生类中对应的虚函数地址。

演示代码1.2中定义了一个基类Base和一个派生类Derive,Base中定义了两个虚函数func1和func2,在派生类Derive中,func2被重写了,func1没有被重写。运行代码,打开内存监视窗口,可以看到,在Derive对象的虚函数表中,func2的地址和Base对象中的不一样,而func1的地址一样,这证明了func2被重写后,其记录在虚函数表中的地址被覆盖了。

演示代码1.2:

#include<iostream>

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

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

class Derive : public Base
{
public:
	virtual void func2()
	{
		std::cout << "Derive::func2()" << std::endl;
	}

	int _d = 1;
};

int main()
{
	Base b;
	Derive d;
	return 0;
}
图1.2 继承体系中的虚函数表及虚函数覆盖情况

我们知道,多态的条件之一,就是通过父类的指针或引用去调用,可以从这个调用条件为切入点,分析多态中函数调用的流程,来探索多态的底层原理,多态中函数调用流程为:

  1. 将父类或子类的对象(地址)赋值给父类的引用(指针)。
  2. 父类的对象或引用,根据其实际表示的对象或指向,拿到对应的虚表函数指针,在虚函数表中找到要调用的虚函数地址,来调用对应的函数。

正是由于子类对象中完成了对父类对象虚函数的重写,所以在子类对象完成虚函数操作时,会执行子类中定义的虚函数。多态中的虚函数调用,是通过获取虚函数表中的函数指针来确定具体调用哪个函数的,由于父类对象和子类对象的虚函数表中存储不同的虚函数指针,所以会调用不同的虚函数,从而实现了多态。

关于多态中虚函数表的生成和覆盖,总结出以下几点关键内容:

  • 虚函数表本质是一个存虚函数指针的指针数组,在VS编译环境下,这个数组最后面放了一个nullptr,但是在Linux gcc编译环境下,后面不会存有nullptr。
  • 派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中  b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数  c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
  • 虚函数表中存的是虚函数指针,而不是虚函数。虚函数和普通函数一样,存储在代码段,对象中存储的也不是虚函数表,而是虚函数表指针,其指向虚函数表所在的地址。

1.3 动态绑定与静态绑定

  • 静态绑定:静态绑定又称为前期绑定,表示在程序编译期间就可以确定程序完整的行为,函数的普通调用就是静态绑定,在编译时,就能明确哪个函数会被调用,动态绑定也可称为编译时决议。
  • 动态绑定:在多态调用的场景下,需要在程序运行时,根据虚函数表中的函数地址,来确定调用哪个函数,即:程序运行起来之后才能明确程序的具体行为,动态绑定也可称为运行时决议。
  • 函数普通调用为编译时决议,函数的多态调用为运行时决议。

二. 多继承中的虚函数表

2.1 虚函数表的打印

为了探索多继承中虚函数的行为,我们需要定义一个PrintVFTalbe函数,来打印虚函数所存储的地址,并通过虚函数表中存储的函数指针调用对应的函数,来观察虚函数表中的函数指针与子类和父类虚函数的指向关系。

因为VS编译器会在虚函数表末尾位置存储nullptr,所以使用Table[i] != nullptr作为循环结束的判断条件(Linux gcc编译器不会将在虚函数表最后放nullptr,必须显示地给定虚函数表中存储的函数指针的个数)。在每层循环内部,先打印函数指针(函数首条指令地址),然后将函数指针变量赋值给ptr,通过函数指针调用函数。

演示代码2.1:(虚函数表打印函数)

typedef void (*VFPTR)();  //将指向无参数、返回void的函数的函数指针类型重定义为VFPTR

void PrintfVFTable(VFPTR* table)
{
	for (size_t i = 0; table[i] != nullptr; ++i)
	{
		printf("第%d个虚函数的地址:%p -> ", i, table[i]);
		VFPTR ptr = table[i];   //获取函数指针
		ptr();   //通过函数指针调用函数
	}
	std::cout << std::endl;
}

2.2 多继承中虚函数表中的内容存储情况

编写演示代码2.2,其中定义了两个父类Base1和Base2,两个父类中都定义了func1和func2虚函数,并且,在子类Derive中,重写func1函数,并且定义了一个新的虚函数func3。

调试代码,打开监视窗口,我们可以发现,子类对象中包含的两个父类对象各有一张虚函数表,但是,VS的监视窗口并没有显示出虚函数func3的地址,这并不是说func3的地址没有进虚函数表,而是VS编译器没有将其显示出来,可以认为这是编译器的一个小BUG。

演示代码2.2:

class Base1 
{
public:
	virtual void func1() { std::cout << "Base1::func1" << std::endl; }
	virtual void func2() { std::cout << "Base1::func2" << std::endl; }
private:
	int _b1 = 1;
};

class Base2 
{
public:
	virtual void func1() { std::cout << "Base2::func1" << std::endl; }
	virtual void func2() { std::cout << "Base2::func2" << std::endl; }
private:
	int _b2 = 2;
};

class Derive : public Base1, public Base2 
{
public:
	virtual void func1() { std::cout << "Derive::func1" << std::endl; }
	virtual void func3() { std::cout << "Derive::func3" << std::endl; }
private:
	int _d1 = 3;
};

int main()
{
	Derive d;
	Base1* ptr1 = &d;
	Base2* ptr2 = &d;

	PrintfVFTable((VFPTR*)*(int*)ptr1);  //打印Base1的虚函数表
	PrintfVFTable((VFPTR*)*(int*)ptr2);  //打印Base2的虚函数表

	return 0;
}
图2.1 VS2019调试演示代码2.2的监视窗口

由于VS编译器的这个小“bug”,就要求我们显示的打印虚函数表,将虚函数指针作为参数,传给虚函数表打印函数。可见。Base1的虚函数表中存储了三个函数指针,从前到后依次为:子类定义的func1、Base1中定义的func2、func3,Base2的虚表中存储了3个函数指针,从前到后依次为:子类中定义的func1、Base2的func2。

图2.2  多继承体系中的虚表及虚表中存储的内容

根据图2.2所示的虚表打印情况,总结出多继承体系中如下的规律:

  1. 在多继承体系中,每一个基类都有一张虚表。
  2. 如果两个基类之中存在同名的虚函数,同时在派生类中对同名的虚函数重写,那么这两个派生类中的虚函数都会被覆盖。
  3. 派生类中未被重写的虚函数,会被存入第一个基类的虚表之中。
图2.3 多继承体系中的内存模型
;