C++多态的底层原理

文章目录

  • 零.前言
  • 1.虚函数表
    • (1)虚函数表指针
    • (2)虚函数表
  • 2.虚函数表的继承--重写(覆盖)的原理
  • 3.观察虚表的方法
    • (1)内存观察
    • (2)打印虚表
      • 虚表的地址
      • 函数
      • 传参
    • (3)虚表的位置
  • 4.多态的底层过程
  • 5.几个原理性问题
    • (1)虚表中函数是公用的吗?
    • (2)为什么必须传入指针或引用而不能使用对象?
    • (3)为什么私有虚函数也能实现多态?
    • (4)VS中的虚表中存的是指令地址?
  • 6.多继承中的虚表
  • 7.总结

零.前言

要了解C++多态的底层原理需要我们对C指针有着深入的了解,这个在打印虚表的时候就可以见功底,理解了多态的本质我们才能记忆的更牢,使用起来更加得心应手。

1.虚函数表

(1)虚函数表指针

首先我们在基类Base中定义一个虚函数,然后观察Base类型对象b的大小:

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2" << endl;
	}
	void f()
	{
		cout << "f()" << endl;
	}
protected:
	int b = 1;
	char ch = 1;
};
int main()
{
	Base b;
	cout << sizeof(b);
	return 0;
}

我们发现,如果按照对齐数原则来计算b的大小时,得到的结果是8,而我们打印的结果是:
C++多态的底层原理_第1张图片
这说明带有虚函数的类所定义的对象中,除了成员变量之外还有其他的东西被加入进去了(成员函数默认不在对象内,在代码段)。
我们可以通过调试来观察b中的内容:
在这里插入图片描述

我们发现对象中多了一个__vfptr,即为虚函数表指针。简称为虚表指针。

(2)虚函数表

仍然看上图,我们发现虚函数表指针下方有两个地址,这两个地址分别对应的就是Base中两个虚函数的地址,构成了一个虚函数表。所以虚函数表本质是一个指针数组,数组中每一个元素是一个虚函数的地址
VS2019封装更为严密,在底层的汇编代码中,虚函数表中的地址并不一定是虚函数的地址,可能存放的是跳转到虚函数的地址的指令的地址。这个在后面会加以演示。
因此当我们调用普通函数和虚函数时,它们的本质是不同的:

	Base* bb=nullptr;
	bb->f();
	bb->Func1();

其中bb调用f()的过程没有发生解引用操作,非虚函数在公共代码段中,直接对其进行调用即可。而bb调用Func1()的过程中,需要通过虚表指针来找到Func1(),而拿到虚表指针需要对bb进行解引用操作,而bb是空,因此程序会崩溃。
我们知道对象中只存储成员变量,成员函数存储在公共代码段中,其实虚函数也是一样存储在公共代码段,只不过寻找虚函数需要通过虚表来确定位置。普通函数直接就可以确定位置。

2.虚函数表的继承–重写(覆盖)的原理

还拿上一节中买票的例子举例,其中父类中有两个虚函数,子类重写了其中的一个,子类中还有自己的函数。

class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "全价" << endl;
	}
	virtual void Func1()
	{
		cout << "Func1" << endl;
	}
protected:
	int _a;
};
class Student :public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "半价" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2" << endl;
	}
protected:
	int _b;
};
int main()
{
	Person a;
	Student b;
	return 0;
}

我们可以通过调试来观察一下他们的虚表和虚表指针。C++多态的底层原理_第2张图片显然父类对象__vfptr[0]中存放的是BuyTicket的地址,__vfptr[1]中存放的是Func1()的地址。子类对象中__vfptr[0]中存放的是继承并重写的BuyTicket的地址,__vfptr[1]中存放的是继承下来但没有进行重写的Func1()的地址。通过对比我们发现:对于没有进行重写的Func1()来说,子类中虚表中的地址和父类中的是一样的,可以说是直接拷贝下来的。而对于进行了重写的BuyTicket来说,子类中虚表的地址与父类中明显不一样,其实是在拷贝了父类的地址后又进行了覆盖的。因此重写从底层的角度来说又叫做覆盖。
同时我们又发现了一个问题,那就是子类对象的虚表中为什么没有写它自己的虚函数地址Func2()呢?
其实是写了的,只不过通过VS的监视窗口并不能看到,我们可以通过内存来进行观察:

3.观察虚表的方法

(1)内存观察

C++多态的底层原理_第3张图片
我们可以通过观察内存来观察虚函数表的情况,这里观察的是父类对象,会发现在虚函数指针的地址存放的是父类对象中两个虚函数的地址。
我们也可以观察一下子类对象:
C++多态的底层原理_第4张图片
与父类对象中存储的相同,唯一有区别的地方就是紫色的部分,存放的其实是子类虚函数Func2()的地址。这说明Func2()也在虚表中只不过在监视窗口没有看不到而已。

(2)打印虚表

虚表的地址

通过观察内存,对于单继承来说,我们只需要打印对象的首元素的地址即可找到虚表,并进行打印。
C++多态的底层原理_第5张图片
我们发现对象的前四个字节存储的就是虚表的地址。可以通过这一点来打印虚表。
我们关闭一下调试来重新写一下代码(关闭调试后再进行运行地址会发生变化,但是规律是不变的)

typedef void(*vfptr)();
void Printvfptr(vfptr* table)
{
	for (int i = 0; table[i] != nullptr; i++)
	{
		printf("%d:%p\n",i,table[i]);
	}
	cout << endl;
}
int main()
{
	Person a;
	Student b;
	Printvfptr((vfptr*)*(void**)&a);
	Printvfptr((vfptr*)*(void**)&b);
	return 0;
}

下面来解释一下如何打印的虚表,分为两部分,一部分是函数,一部分是传参:

函数

首先我们明确,虚函数指针是一个函数指针,因此为了简便我们可以将函数指针重命名为vfptr。
通过接收虚表指针,并依次打印指针数组中的内容(虚函数的地址)。

传参

拿父类对象a举例,我们要找到a的前四个字节的内容,即为虚表指针,然后再传入函数中。
首先使用(void**)对a的地址进行强制类型转换,这其中发生了切割。使用(void**)的原因在于,由于不知道是使用的32位还是64位系统,但我们可以通过指针的大小来判断。首先将&a转换成一个指针,再将其转换成一个指针类型,再进行解引用就得到了a的前4或者8个字节。但同时我们需要传递的是一个vfptr类型的函数指针,所以还需要进行(vfptr*)类型的强制转换。

有了前面的解释,我们就可以理解打印虚表的原理了,我们把这段代码运行一下:
C++多态的底层原理_第6张图片
发现分别打印出了a和b的虚函数表。
如果打印的虚函数数量不对,这是VS编译器的bug,我们可以重新生成解决方案,再重新运行代码。

(3)虚表的位置

我们还可以观察一下虚表的位置,在哪个区域:
使用其他区域的变量进行对比:

	Person per;
	Student std;
	int* p = (int*)malloc(4);
	printf("堆:%p\n", p);
	int a = 0;
	printf("栈:%p\n", &a);
	static int b = 1;
	printf("数据段:%p\n", &b);
	const char* c = "aaa";
	printf("常量区:%p\n", &c);
	printf("虚表:%p\n", *(void**)&std);

打印的结果是:
C++多态的底层原理_第7张图片
我们发现虚表的位置在数据段和常量区之间。大致属于数据段。

4.多态的底层过程

class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "全价" << endl;
	}
	virtual void Func1()
	{
		cout << "Func1" << endl;
	}
protected:
	int _a;
};
class Student :public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "半价" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2" << endl;
	}
protected:
	int _b;
};
void F(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person per;
	Student std;
	F(per);
	F(std);
	return 0;
}

我们还使用这一段代码来举例,首先复习一下多态:使用父类的指针或者引用去接收子类或者父类的对象,使用该指针或者引用调用虚函数,调用的是父类或子类中不同的虚函数。
下面来分析原理:
父类对象原理:
首先用父类引用p来接收父类对象per,此时p中的虚表和per中的虚表一模一样,只需要访问__vfptr中的BuyTicket()的地址即可调用该函数。
子类对象的原理:
用p来接收子类对象std,发生切片处理,会将子类中的虚表内容拷贝到父类引用p中,然后再调用其中的__vfptr中的BuyTicket地址。此时的p不是新创建了一个父类对象,而是子类对象std切片后构成的,其中就将重写之后的BuyTicket()的地址也随之切入了p。可以把p看成原std的包含__vfptr的一部分。
总结:基类的指针或者引用,指向谁就去谁的虚函数表中找到对应位置的虚函数进行调用。

5.几个原理性问题

了解了多态原理之后,就可以分析出在上一节中出现的一些现象规律。

(1)虚表中函数是公用的吗?

虚表中的函数和类中的普通函数一样是放在代码段的,只是虚函数还需要将地址存一份到虚表,方便实现多态。这也就说明同一类型的不同对象的虚表指针是相同的,我们还可以通过调试观察:

	Person per;
	Person pper;

C++多态的底层原理_第8张图片

(2)为什么必须传入指针或引用而不能使用对象?

当我们使用父类对象去接收时,父类对象本身就具有一个虚表了,当子类对象传给父类对象的时候,其他内容会发生拷贝,但是虚表不会,C++这样处理的原因在于,如果虚表也会发生拷贝的话,那么该父类对象的虚表就存了子类对象的虚表,这是不合理的。
我们同样可以通过调试来进行观察:

void F(Person p)
{
	p.BuyTicket();
}
int main()
{
	Person per;
	Student std;
	F(std);
}

C++多态的底层原理_第9张图片
这是std中的虚表内容。
C++多态的底层原理_第10张图片
这是p中的虚表内容,而且在调试过程中,程序是进入父类中进行调用函数的。

(3)为什么私有虚函数也能实现多态?

这是因为编译器调用了父类的public接口,由于是父类的引用或者指针,因此编译器发现是public之后就不再进行检查了,只要在虚表中可以找到就能调用函数。

(4)VS中的虚表中存的是指令地址?

在VS2019中,为了封装严密,其实虚表中存入的是跳转指令,我们可以通过反汇编进行观察:
我们将虚表中的地址输入反汇编,看到的是这样的一条语句:
在这里插入图片描述
这是一条跳转指令,会跳转到BuyTicket()的实际地址处。

6.多继承中的虚表

谈到多继承就要谈到菱形虚拟继承,这是一个庞大而复杂的问题,需要更大的大佬来解释。
这里只介绍多继承中虚表的内容:

class Base1
{
public:
	virtual void Func1()
	{
		cout << "Func1" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2" << endl;
	}
protected:
	int _a;
};
class Base2
{
public:
	virtual void Func3()
	{
		cout << "Func3" << endl;
	}
	virtual void Func4()
	{
		cout << "Func4" << endl;
	}
};
class Derive :public Base1, Base2
{
public:
	virtual void Func5()
	{
		cout << "Func5" << endl;
	}
};
int main()
{
	Derive a;
}

我们可以使用调试来观察a中的虚表内容:
C++多态的底层原理_第11张图片
通过调试我们可以看到a中有两个虚表指针分别存放的是Base1中虚函数的地址和Base2中虚函数的地址,那么a中特有的类Func5()存在哪个虚表呢?这需要通过内存进行观察:
C++多态的底层原理_第12张图片
我们发现它被存放在了第一个虚表指针指向的虚表中。
我们知道打印第一个虚表指针指向虚表的方法,那么第二个虚表指针的该怎样进行处理呢:

Printvfptr((vfptr*)*(void**)((char*)&a+sizeof(Base1));

注意需要先将&a转换成char*类型,这样对其加一,才代表加一个字节。

7.总结

实际中我们不建议设定出菱形继承或者菱形虚拟继承,在实际中很少用,这里推荐大佬的两篇文章C++虚函数表解析,C++对象的内存布局,对我们的提升有很大的帮助,看一些原理书似乎用时长成效难以体现,但是真正静下心来深入理解的人最后都会有所成就,当你经过七重的孤独,才能成为真正的强者!

你可能感兴趣的:(C++编程,c++)