C++ - 多态的实现原理

前言

本博客主要介绍C++ 当中 多态语法的实现原理,如果有对 多态语法 有疑问的,请看下面这篇博客:

C++ - 多态语法 - 虚函数使用介绍-CSDN博客

 探究,为什么多态的条件是那样的(虚函数表)

 首先,调用虚函数必须是 父类的 指针或 引用,不能是子类的。这是因为,子类当中有父类部分,而父类当中只有自己。我们使用父类指针,当指向对象是父类的时候,指针类型也是相匹配的,就会调用父类的函数;如果指向那个对象是子类的,但是子类当中构造了父类,就会发生切片,因为指针类型是父类的,指针只会访问父类的那一部分。

但是如果指针类型是子类的,就不行了,因为子类虚函数表当中只有自己虚函数的地址。父类指针可以指向子类和父类,但是子类指针只能指向子类。


为什么不能是父类的对象,而必须是指针或引用呢
因为 ,指针的切片和 对象的切片做造成的结果是不一样的

如果是指针切片,在创建派生类的时候,子类当中父类的虚函数表,是先从父类当中拷贝一份到在子类当中父类的虚函数表,然后,如果子类当中重写了虚函数,再把子类当中重写虚函数地址直接覆盖在虚函数表中之前父类中对应的虚函数地址位置

 拷贝之后如下所示:
C++ - 多态的实现原理_第1张图片

 所以,才会有指向父类调用父类的虚函数;指向子类调用子类的虚函数;只不过在指针看来,看到的都是对象对象。一个是之间看到父类对象,一个是切片之后看到了子类当中父类对象。所以说,指针的切片不考虑拷贝的问题,就可以理解为他只是把原本就有的部分切片出来给指针看到。

 而对象的切片,像上述的例子, ps = st,相当于是把子类对象拷贝到 ps 当中;因为ps 是父类的指针, 而 st 是子类的指针,这时候就会发生切片。把切片出来的子类当中的父类拷贝到 ps 当中。

在子类的父类当中有两个部分,首先 _a 肯定是会拷贝到 ps  当中,但是虚函数表会不会拷贝呢

我们先来看,拷贝之后会发生什么。如果我们把子类当中的虚函数表拷贝到父类当中的虚函数表当中,那么当指针指向父类的时候,此时应该调用父类的函数,但是此时父类当中的虚函数表存储的是子类的虚函数地址(因为刚刚假设是直接拷贝),那么此时就会去调用子类的虚函数,这部乱套了吗?这肯定不会是我们所期望的,我们肯定期望父类指针指向父类对象 ,就去调用父类的虚函数。

所以,此时肯定不能 把 子类当中的 虚函数表拷贝到 父类当中的虚函数表当中。在实际当中,子类拷贝给父类,编译器也没有拷贝虚函数表,和我们刚刚所想是一样的。

所以说,上述就是我们不能使用父类对象调用虚函数的原因(如果使用对象调用,就要进行赋值拷贝开空间,而新开出来的父类就需要重新构建虚函数表,而又不能直接拷贝原本的虚函数表,原本子类和父类构建的重写关系可能会乱套

 这里提一嘴,我们普通继承(比如 A类 继承 B类),这种称之为 实现继承。而 上述的多态继承称之为 接口继承

至于为什么需要虚函数的重写,上述给出的过程也可以证明了,因为只有是虚函数重写,在子类当中的虚函数表当中才会 有 子类新重写的虚函数地址。而调用虚函数的 父类指针 或 引用,只需要“无脑的” 从 父类当中的虚函数表 ,找到这个虚函数地址,调用这个虚函数就行了。 


 关于虚函数表的一些问题

  • 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
  •  基类b对象和派生类d对象虚表是不一样的,这里我们发现BuyTicket完成了重写,所以Student的虚表中存的是重写的Student::BuyTicket,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法.
  • 虚函数表当中只放虚函数的地址,如果不是虚函数,该函数的地址是不会放到虚函数表当中的;
  • 派生类对于父类当中的虚函数表是先拷贝,然后在把派生类重写的虚函数地址进行直接覆盖,如果在派生类当中自己有虚函数,就按照派生类的声明次序写在虚函数表当中(注:VS的调试窗口下不会显示派生类当中的虚函数(不是重写的),但是我们打开内存窗口可以看到派生类虚函数的地址)。也就是说,在派生类中父类的虚函数表当中有三个部分:从父类拷贝下来父类的虚函数;子类重写之后覆盖的虚函数;子类当中的虚函数;
  • 虚函数表的本质是一个 存放 函数指针的指针数组,一般情况下,这个数组在最后放了一个 nullptr(0),但是这个看不同的编译器,在VS下就给了,但是在 g++ 当中没给。
  • 虚函数表是存储在 代码段,也就是常量区当中的。

虚函数表存储在代码段的验证:

  • C++当中数据存储位置大概有以下几个地方:栈  堆   数据段(静态区)   代码段(常量区)。首先排除的是 堆 ,因为堆是拿给我们动态开辟空间的,而虚函数表是由编译器生成的,所以不可能是编译器 什么 malloc new出来的。
  • 栈也不可能,因为同类型的对象共用一个虚函数表(一表多用)(比如 Person ps1 和 Person ps2这两个对象是共用一个虚函数表的,不管这两个对象分别构造在任意位置),而栈上的空间一般是跟着栈帧走的,不能单独开空间。而且如果存储在栈上也有一个问题,就是函数执行结束,战争销毁,存储在这个栈帧当中的虚函数表也要进行销毁,那么当下一个同类型的对象构造的时候,虚函数表难道要重新进行构造吗?肯定是不行的。(除非是 main 函数栈帧)
  • 我们可以来验证一下,我们用 分别存储在上述四个存储位置的  四个数据,分别打印他们的地址,这样我们可以大概的看出这个四个存储位置的地址区间,在打印对象当中虚函数表的地址(用强转类型,int* ,这样解引用的话只会访问 4 个字节的内容),因为这个例子的数据量很小,地址最接近的我们可以认为虚函数表就存储在那个 存储位置:

C++ - 多态的实现原理_第2张图片

 我们发现,虚函数表 和 常量区 存储位置地址最接近。

VS当中 虚函数表最后的 nullptr(有时候编译器在你调试的时候修改一些代码,编译器可能不会给nullptr,但是清理一下,重新生成解决方案之后就会有了);

C++ - 多态的实现原理_第3张图片

 验证,派生类的虚函数表当中,VS调试窗口看不到的,派生类的虚函数地址:
 

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

};

class Student : public Person
{
public:
	virtual void Func3()
	{
		cout << "Student::Func3()" << endl;
	}
};

如上例子所示,在子类 Student 中的虚函数表,除了存储父类 Person 的两个虚函数之外,还要存储 子类 当中的 虚函数--func3()的地址,但是 这个 func3 ()函数的地址,在VS的调试窗口上是不会显示的,但是在内存当中,除了 有父类 当中两个虚函数的地址,还多出来一个地址,我们怀疑这个地址的空间存储的就是 func3()函数的地址:
 

调试窗口子类虚函数表没有func3()地址:

C++ - 多态的实现原理_第4张图片

 内存窗口当中多出一个地址:
C++ - 多态的实现原理_第5张图片

 上述说过,虚函数表其实就是函数指针数组,这个数组当中存储的是每一个虚函数的指针,所以我们可以利用C 当中的函数指针来帮助我们验证这个地址是不是 func3()函数的指针。

我们可以在虚函数表数组当中找到这个地址,然后用这个地址调用这个地址的函数看是不是func3()。

函数指针语法(转自博客:c++ 函数指针_c++指针函数_Alpha205的博客-CSDN博客):

double (*pf)(int);   // 指针pf指向的函数, 输入参数为int,返回值为double

这样不太好看,我们可以typedef一下:

typedef void(*FUNC_PTR) ();

在数组当中找到 这个地址,然后调用这个地址上的 函数:

typedef void(*FUNC_PTR) ();

void PrintVFT(FUNC_PTR* table)
{
	for (size_t i = 0; table[i] != nullptr; i++)
	{
		printf("[%d]:%p\n", i, table[i]);
	}
	printf("\n");
}

int main()
{
	Person ps;
	Student st;

	int vft1 = *((int*)&ps);
	PrintVFT((FUNC_PTR*)vft1);

	int vft2 = *((int*)&st);
	PrintVFT((FUNC_PTR*)vft2);

	return 0;
}

我们拿输出结果看和内存对比,地址是否相同,来验证我们当前取出来的地址是否正确:
 

C++ - 多态的实现原理_第6张图片

 我们发现是完全吻合的。

然后我们在把 疑似 func3()函数的函数指针拿出来调用,看这个地址是不是 func3()函数的地址:
 

//打印虚函数表当中 虚函数地址的函数
void PrintVFT(FUNC_PTR* table)
{
	for (size_t i = 0; table[i] != nullptr; i++)
	{
		printf("[%d]:%p\n", i, table[i]);
		FUNC_PTR func = table[i];
		func();
	}
	printf("\n");
}

输出:

[0]:00FC1357
Person::Func1()
[1]:00FC12AD
Person::Func2()

[0]:00FC1357
Person::Func1()
[1]:00FC12AD
Person::Func2()
[2]:00FC12D0
Student::Func3()

此时我们就验证了,那个多出来的地址就是 Func3()函数的地址。

 以上验证方式需要注意的点:

  •  首先,这个程序是在 X86 也就是在 32位环境下执行的,也就是说,我们在寻找虚函数表的首地址的时候,是寻找对象前4字节的存储的数据,这个数据就是虚函数表的地址。如果是 64 位环境的话,应该是取 对象的前8个字节。
  • 上述写的PrintVFT()这个函数中的循环,是以 nullptr(0)作为循环的终止条件的,因为在VS当中的虚函数表,在最后会以 nullptr 来结尾。但是有时候我们不注意在调试时候修改代码,可能就不会以 nullptr 来结尾了,这时候我们需要重新生成解决方案。
  • 在Linux中,也就是在g++环境下,虚函数表不是以 nullptr 结尾的,这时候的循环只能写死了。

 看到上述的验证,我们应该注意了,VS当中的监视窗口有时候可能不靠谱,而内存当中是绝对靠谱的。

函数指针(函数地址)在使用的时候需要注意,如果你知道函数的地址,不管这个函数受哪一个权限修饰符修饰,就算是使用 private 修饰,照样可以访问。

因为,此时你都已经知道了这函数的地址,使用函数指针来调用函数,是直接在代码段当中找到这个函数,然后调用。

还有一个原因是,权限的限定只是在语法层次来限定,不是在运行层当中进行限制的。这里的函数指针直接跳过了语法层次,直接在语法层次来进行寻找函数调用。

 动态绑定和静态绑定(动态多态和静态多态)

 其实多态这一现象不止发生在对象当中,在函数的当中时常发生。如下例子:
 

int a = 1;
double b = 1;

cout << a << endl;
cout << b << endl;

 库函数当中的 cout 流插入之所以实现自动判别类型,其实底层实现就是使用 函数重载。当我们传入不同类型的参数的时候,编译器就会自动的去寻找参数列表对应的函数来调用。

上述这种用函数重载来实现的多态,就叫做静态多态

而我们上述的多态,也就是使用继承,虚函数来实现的多态,就是动态多态

 多继承当中的虚函数表

class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int b1;
};

class Base2 {
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int b2;
};

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

我们先来计算 Deribve d ,这个对象的大小是多少,sizeof(d)= 20;Base1的虚表指针 + b1 + Base2的虚表指针 + b2 + d1 = 20。

派生类是没有自己的虚表的,因为派生类在构造之前需要先构造其父类,它的父类当中就有虚表,而且这个虚表还是子类修改过的(重写就覆盖地址)。

多继承当中,派生类当中创建的多个父类当中都有虚表,我们可以认为,这些虚表都是属于这个派生类的,因为这些虚表当中,如果派生类对其中的虚函数进行了重写,那么都是对这个虚表进行了修改的,就算这个表当中没有,也不影响子类调用函数。 

那么在子类当中func3()这个函数,没有重写,但是是虚函数,那么也要放进虚表当中,但是紫烈继承了两个父类,此时有两个虚表,究竟是放到哪一个虚表当中的呢?

要得到上述问题的答案,我们还是要进行虚表当中虚函数的打印,打印过程和上述一样,唯一不一样的是,base2父类不在 d 对象当中的第一位置,Base1当中的虚表好弄,因为是Base1是在第一位置。所以,此时我们要像去Base1一样先取出第一位置的地址,然后加上 sizeof(base2),因为 所用的指针是 d 类型指针,所以此时还需要把 d 指针强转为 char* ,使得我们加上 sizeof(base2)是一个字节一个字节加的。具体代码如下所示:
 

Derive d;
int vft2 = *((int*)( (char*)&d + sizeof(Base1)));

这样就可以取出d对象当中 Base2 父类当中的虚函数表的地址了。

还有一个更好的方法:使用 Base2 类型的指针,指向子类对象(d),这样就会发生切片,Base2类型的指针直接指向 d 对象当中的 Base2 首地址。虚函数就在首地址处,直接按照4个字节大小取出就好:

Derive d;
Base2* ptr = &d;
int vft2 = *((int*)ptr);
void PrintVTable(VFPTR vTable[])
{
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}

int main()
{
	Derive d;
	int vTableb1 = (*(int*)&d);
	PrintVTable((VFPTR*)vTableb1);
	int vTableb2 = (*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable((VFPTR*)vTableb2);
	return 0;
}

输出:

 虚表地址>00A69B94
 第0个虚函数地址 :0Xa61244,->Derive::func1
 第1个虚函数地址 :0Xa612e9,->Base1::func2
 第2个虚函数地址 :0Xa61230,->Derive::func3

 虚表地址>00A69BA8
 第0个虚函数地址 :0Xa61357,->Derive::func1
 第1个虚函数地址 :0Xa610b9,->Base2::func2

通过上述输出结果发现,func3()函数的地址是存在 第一个 父类对象的虚函数表当中的。


我们还看到,上述在 Base1 当中的 func1()函数的地址 和 在 Base1 当中的 func1()函数的地址是不一样的

C++ - 多态的实现原理_第7张图片

我们先来模拟,用两个分别指向 Base1 和 Base2 的指针来调用func1()函数,来看看:
 

int main()
{
	Derive d;
	Base1* pb1 = &d;
	pb1->func1();

	Base2* pb2 = &d;
	pb2->func1();

	return 0;
}

输出结果是一样的:

Derive::func1
Derive::func1

虽然两次函数调用的结果是一样的,但是两次调用函数的地址是不一样的。

我们来查看反汇编,来看看编译器在这里究竟干了些什么,为什么要这样做?

C++ - 多态的实现原理_第8张图片

 上述就是我们在main函数的当中写的代码所转化的反汇编截图。

过程描述:

首先是 Base1 指针调用 func1(),call指令调用函数,call指令当中 eax寄存器 存储的地址是 jmp的地址,因为在VS当中,调用函数之前要先走一趟jmp,而jmp跳跃到的地址才是调用函数真真的地址,我们发现,Base1 指针调用 func1()函数就是直接跳到子类重写的 func1()函数地址来进行调用的

C++ - 多态的实现原理_第9张图片

 然后我们来看 Base2 指针调用func1()的过程,同样在call指令开始查看,发现此时 eax寄存器当中存储的地址和 Base1当中 eax存储的地址不一样了

 也就是说,此时call之后执行的 jmp 指令也不会是之前的那个指令了:

C++ - 多态的实现原理_第10张图片

 此时的jmp 跳到了另一个指令位置当中,此时就只有 sub 和 jmp这两个指令,首先执行 sub 这个指令,这个指令是 减 的意思,意思是 寄存器 ecx 当中的值 减 8。而 ecx 当中存储的值是 this 指针的值,也就是说,sub 指令是让 this 指针 减8。

然后 接下来执行的 jmp指令 就和 Base1 当中的 jmp 指令地址一样的了,也就是执行的是一个指令,此时就跳到了 func1()子类重写的地址处进行执行。我们发现,Base2也是跳到 子类重写的函数当中进行调用 func1()函数的。

 既然,两处最后都是跳到 子类当中对 func1()函数重写位置进行 调用的,那么 Base2 指针调用的 func1()函数为什么要多执行这几步绕一圈在执行呢?

其实不难发现,Base2 指针调用的 func1()函数过程,多执行的几步当中,jmp指令都不重要,重要的是执行的 那个 sub 指令。这个指令对当时的 this 指针进行了修改,那么为什么要对当中的 this 指针进行修改呢?

 首先我们要知道,此时的this指针指向的是什么。此时的this指针,谁调用的,谁就是 this 指针,很明显,此时的this指针是 pb2。而此时的pb2 指向的是 d 子类对象当中的 Base2 这个父类对象,也就是说,此时的this 指针指向的是 Base2 这个对象。

但是,我们此时调用的 func1()函数进行了重写,所以 func1 ()函数的实现是在 子类当中,而不在Base2 当中,当 func1()当中调用了 子类当中的成员函数或成员变量,我们知道,调用成员是需要 this 指针来调用的,如果此时this 指针还是指向 Base2,就出大问题了!! 

 所以,此时编译器就对this指针进行了修改:

C++ - 多态的实现原理_第11张图片

 ecx 存储的是 this指针,在最开始是从 ptr2 拷贝过来的this指针的值,ptr2 存储的是 Base2 对象的指针,这肯定是不对的,所以此时编译器才饶了一圈来修改this指针指向的位置。

 那为什么 Base1 类型的指针(ptr1)调用 func1()函数就没有这样饶圈,而是直接跳到 func1()函数实现位置调用呢?

其实是也 ptr1 指针指向位置很特殊,他就是 子类对象 d 的首地址,就是 d 对象的 this指针应该指向的地方,所以此时编译器不需要对this 指针进行修改。

 当然,上述是基于 VS 当中的编译器做到事情,其他编译器不好说。

除了上述方法,我们还可以让两个虚表当中的 func1()函数的地址是一样的,而且同样的可以修改this 指针。

就是不要 那 ptr2 来赋值 ecx ,就算要拿 ptr2 来赋值给 ecx ,在下一行指令就把 ecx 当中的值向上述一样 减8 就行了。

 菱形继承、菱形虚拟继承 当中的虚函数表

 实际中我们不建议设计出菱形继承及菱形虚拟继承,因为菱形继承很复杂容易出现问题,而且菱形继承当中进行了一些 bug 的修复,有一定的性能损耗。如果想了解的可以看下面这两篇文章:
C++ 虚函数表解析 | 酷 壳 - CoolShell

C++ 对象的内存布局 | 酷 壳 - CoolShell

 其实菱形继承和 上述多继承基本没有区别。

但是在 基类 有虚函数,而 腰部位置的两个子类在重写基类的 虚函数的时候需要注意,如果不是菱形虚拟继承就可以,但是如果是菱形虚拟继承就会报错:

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

public:
	int _a = 1;
};

class B : virtual public A
{
public:
	virtual void func1()
	{
		cout << "B::func1()" << endl;
	}

public:
	int _b = 2;
};

class C : virtual public A
{
public:
	virtual void func1()
	{
		cout << "C::func1()" << endl;
	}

public:
	int _c = 3;
};

class D : public B, public C
{
public:
	int _d = 4;
};


报错:

 不是菱形虚拟继承的话,在B 和 C 当中又会有 从 A 类拷贝下来的虚表(其实是在B 和 C 当中都有一个 A 对象),所以如果 B 和 C 同时对A 当中的 func1()重写的话,两个表就可以分开记录 B 和 C 当中对 A 的func()函数重写的地址。

但是如果是菱形虚拟继承,A 的 整个对象都是 共用的,不再存储在 B 和 C 当中,而是存储在 D 的对象的末尾处。此时就只有一份 关于 A 的虚表,那么其中存储 func()函数地址的位置,应该存储 B 和 C 当中的哪一个重写函数的地址,就不知道了。

 这个时候的解决方式:让 D (菱形继承最底层的派生类)再去重写 func()函数就可以了:

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

public:
	int _a = 1;
};

class B : virtual public A
{
public:
	virtual void func1()
	{
		cout << "B::func1()" << endl;
	}

public:
	int _b = 2;
};

class C : virtual public A
{
public:
	virtual void func1()
	{
		cout << "C::func1()" << endl;
	}

public:
	int _c = 3;
};

class D : public B, public C
{
public:
	virtual void func1()
	{
		cout << "D:func1()" << endl;
	}
public:
	int _d = 4;
};

int main()
{
	D d;
	d.B::_a = 1;
	d.B::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;

	return 0;
}

此时 A 当中虚表放的是 D 当中重写的 func()函数。

 那么 B 和 C 当中重写就没用了吗

其实不完全是,因为只是在创建的 D 的对象当中 B 和 C 的重写没有意义了,但是如果是单独的 B 和 C 的对象还有意义的。

更复杂的情况: 

如果此时,在上述的情况之下(菱形虚拟继承),除了有D对A重写的虚函数,在 B 和 C 当中还有自己的虚函数,但是没有被重写,那么此时,B 和 C 当中自己的虚函数该存储在哪里呢

 首先我们应该清楚,B 和 C 当中自己的虚函数 不可能放到 A 的虚表当中,刚刚重写都不能放到 A 的虚表当中,现在单独的再放进去更加不合适了。

如果是 B 和 C 当中自己的虚函数 的话,B 和 C 会建立自己的虚表来存储自己的虚函数地址:

C++ - 多态的实现原理_第12张图片

 如上图所示,主函数代码没变,当我们在 B 和 C 当中添加他们各自自己的虚函数之后,d 对象当中存储  B 和 C 父类的空间当中多出了 B 和 C 各自的虚表地址。

 在上图当中我们还看到,似乎不止3张虚表,在 B 和 C 的存储空间当中,各自还有一个地址存储了类似 地址的数据,所以我们看上去,d 对象当中好像有 5 个 虚函数表一样,其实不是的。

 我们之前说过,B 和 C 对象想要找到 A 对象的位置,需要一个偏移量,而 B 和 C 有各自不同的偏移量,这些偏移量存储在 另一个表当中,这个表叫做虚基表。所以 在 d 对象当中 是有 3个虚表,2个虚基表。(注意:虚表和虚基表是两个表,别混淆

 在 VS2019 的编译器下,我们查看内存,发现,B 和 C 对象当中是虚表在前,虚基表在后

C++ - 多态的实现原理_第13张图片

 而且我们发现,在 B 对象的虚基表当中 ,第一的位置 存储的 值是 fffffffc,这个的值是 -4。但是学过虚基表的应该知道,我们之前在演示虚基表的时候,第一个位置通常存储都是0,此处为什么是 - 4 呢?

其实,虚基表存储的是两个偏移量,在第二个位置存储 本对象(B对象) 到 父类对象(A对象) 的相对距离(偏移量);除此之外,当本对象当中有虚表的时候,还需要存储存储虚表地址空间到存储 虚基表地址的空间 的 偏移量;

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