网络知识 娱乐 C++ 多态(二) : 虚函数、静态绑定、动态绑定、单/多继承下的虚函数表

C++ 多态(二) : 虚函数、静态绑定、动态绑定、单/多继承下的虚函数表

文章目录

  • ⏰1.多态的原理——虚函数表
    • 🍁虚函数表指针
    • 🍁虚函数底层原理
    • 🍁校招虚函数常见题
    • 🍁虚函数表的存储位置
  • ⏰2.动态绑定和静态绑定
  • ⏰3.继承与虚函数表
    • 🌕单继承与虚函数表
    • 🌕多继承与虚函数表

⏰1.多态的原理——虚函数表

每个包含了虚函数的类都包含一个虚表。如果一个基类包含了虚函数,那么其继承类也可调用这些虚函数,换句话说,一个类继承了包含虚函数的基类,那么这个类也拥有自己的虚表。

📕虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。虚表内的条目,即虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。

🍁虚函数表指针

对于定义了虚函数的类来说,有一个隐藏的虚函数表指针,指向一个虚函数表,这个虚函数表中存放着虚函数的地址。

在X86下运行如下代码1:

class human
{
public:
	virtual void print()
	{
		cout << "i am a human" << endl;
	}
    virtual void liangzai()
	{
		cout << ".." << endl;
	}
	void test()
	{
		cout << "1test1" << endl;
	}
	int _age;
};

class student : public human
{
public:
	virtual void print()
	{
		cout << "i am a student" << endl;
	}
	int _stunum;
};

int main()
{
	human man;
	student st;
	cout << sizeof(man) << endl;

	cout << sizeof(st) << endl;
	return 0;
}

按理说,human当中只有一个int变量,student多了个int变量,大小应该分别为4、8,但是运行结果为:

image-20220531210714378

打开监视窗口观察:

image-20220531211649865

可以看到里面除了 _age以外,还有个指针 _vfptr,这个指针指向了一个函数指针数组,这个函数指针数组也就是虚函数表,其中的每一个成员指向的都是之前我们实现的虚函数,这个 _vfptr也被称为虚函数表指针

📕虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。

如下图:

image-20220601235931874

一个继承类的基类如果包含虚函数,那个这个继承类也有拥有自己的虚表,故这个继承类的对象也包含一个虚表指针,用来指向它的虚表。

突击小结:

1.一个类的所有对象,共享一张虚表
2.重写之后派生类会覆盖虚表当中父类的那一部分。但由于父类和子类用的不是一张虚表,所以说子类对表的更改不会影响父类。
3.只有虚函数会放入虚表中,普通函数并不会放入虚表中;虚表是一个函数指针数组,并且最后一般放着一个nullptr。

🍁虚函数底层原理

实际上,多态调用虚函数是通过虚表指针实现的。

问题1:为什么需要派生类函数为虚函数,并且必须要重写才能实现?

image-20220531212155451

观察这个虚函数表,我们可以看到,如果派生类实现了某个虚函数的重写,那么在派生类的虚函数表中,重写的虚函数就会覆盖掉原有的函数,如student::print。而没有完成重写的virtual void liangzai()则依旧保留着从基类继承下来的虚函数human::liangzai

结合上面的内容可以发现,派生类会继承基类的虚函数表,如果派生类完成了重写,则会将重写的虚函数覆盖掉原有的函数。所以指针或引用指向哪一个对象,就调用对象中虚函数表中对应位置的虚函数,来实现多态。

问题2:为什么必须要指针或者引用才能构成多态?

还是之前的主程序:

int main()
{
	student st;
	human man1 = st;
	human* man2 = &st;
	
	return 0;
}

image-20220531213014977

📕分析:

从监视窗口可以观察到,如果将派生类对象赋值给基类对象,会因为对象切割,导致他的内存布局整个被修改,完全转换为基类对象的类型,虚函数表也与基类相同,所以不能实现多态。

如果用基类指针或者引用指向派生类对象,虽然指向的是派生类对象,但是他们的内存布局是兼容的,他不会像赋值一样改变派生类对象的内存结构,所以派生类对象的虚函数表得到了保留,所以他可以通过访问派生类对象的虚函数表来实现多态。

虽然man2是基类的指针只能指向基类的部分,但是虚表指针亦属于基类部分,所以 man2 可以访问到对象st 的虚表指针。st的虚表指针指向类student的虚表,所以man2可以访问到student的虚表。

派生类虚函数表的生成规则:

1.首先派生类会将基类的虚函数表拷贝过来
2.如果派生类完成了对虚函数的重写,则用重写后的虚函数覆盖掉虚函数表中继承下来的基类虚函数
3.如果派生类自己又新增了虚函数,则添加在虚函数表的最后面

🍁校招虚函数常见题

有了对虚函数底层了解的基础,这些题也就easy了。

内联函数可以是虚函数吗?

不可以,内联函数没有地址,无法放进虚函数表中。

静态成员函数可以是虚函数吗?

不可以,静态成员函数没有this指针,无法访问虚函数表。

构造函数可以是虚函数吗?

不可以,虚函数表指针也是对象的成员之一,是在构造函数初始化列表初始化时才生成的,不可能是虚函数

析构函数可以是虚函数吗?

可以,并且最好把基类析构函数声明为虚函数,防止使用基类指针或者引用指向派生类对象时,派生类的析构函数没有调用,可能导致内存泄漏。

对象访问虚函数快还是普通函数快?

如果不构成多态的话,虚函数和普通函数的访问是一样快的,但是如果构成多态,调用虚函数就得到虚函数表中查找,就会导致速度变慢,所以普通函数更快一些。

🍁虚函数表的存储位置

实在不知道那就测试验证喽!

int main()
{
	student s1;

	int a = 0;
	int* p1 = &a;
	int* p3 = new int;

	printf("栈变量:%pn", p1);
	printf("代码段常量:%pn", "hello");
	printf("堆变量:%pn", p3);
	printf("普通函数地址:%pn", fun);
	printf("虚函数地址:%pn", &student::print);
	printf("虚函数表地址:%pn", *(int*)&s1);
}

image-20220531214328927

通过对比可以看到,虚函数表与常量,函数一样存储于代码段(常量区)中(在编译阶段就已经生成)。

这里注意区分下虚函数表(虚表)和 虚基表:

1.虚函数存放在代码段当中,只是它的地址放到虚表当中,我们对象存放的是虚表指针,虚表里面放在虚函数的地址,虚表也是存放在代码段当中的,虚函数表里面只放虚函数,普通函数不会放入。
2.虚基表当中放着的就是8个字节,一个是多态的偏移量,一个是虚继承的基类成员在子类当中的偏移量。

⏰2.动态绑定和静态绑定

其实虚表的构建是在对象实例化调用构造函数的初始化列表时实现的,这就是一种动态绑定,又称后期绑定(晚绑定)。

1.静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
2.动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

接着我们通过汇编代码,来观察多态是在哪个阶段实现的, 就可以知道它是静态还是动态:

int main()
{
	student s1;

	human& h1 = s1;
	human h2 = s1;

	h1.print();
	h2.print();

	return 0;
}

image-20220531221636254

可以看到h1的print是满足多态的,这里调用的函数是在image-20220531221901490

这一阶段中找到eax中存储的虚函数指针,所以可以发现,满足多态的调用是在运行的时候,到对象中的找到虚函数指针来完成的调用

而下面h2的print则不满足多态,它是直接在编译时从符号表中找到函数的地址后调用。

所以可以得出的结论是,满足多态的函数调用时在运行的时候调用的,也就是动态多态。而之前重载那一章节也曾经说过重载也是一种多态的表现,只不过重载是在编译的时候完成的调用,所以也被静态多态。

⏰3.继承与虚函数表

📕首先记住覆盖的原则:

“对象的虚表指针用来指向自己所属类的虚表,虚表中的指针会指向其继承的最近的一个类的虚函数”

所以,如果出现连续的单继承,比如B继承了A,C又继承了B,同时B重写了A中的某个虚函数,那么此时C里面继承到的是B重写的那个虚函数。

🌕单继承与虚函数表

对于单继承的虚函数表,他会直接继承基类的虚函数表,如果完成了重写,则会覆盖掉原来的虚函数,如果有新的虚函数,则会加在基类虚函数表的尾部。

示例代码:

class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a;
};
class Derive :public Base {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
private:
	int b;
};

int main()
{
	Base b;
	Derive d;
	return 0;
}

通过监视窗口可以发现监视器中d对象的虚表少了两个虚函数的地址

image-20220531230658729

监视窗口不够用了,我们可以用代码从内存中读取。

typedef void(*VFPTR)();

void PrintVirtualTable(VFPTR vTable[])
{
	cout << "虚表地址:" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; i++)
	{
		printf("第%d个虚函数地址:%pn", i, vTable[i]);
		VFPTR pf = vTable[i];
		pf();
	}
	cout << endl;
}

int main()
{
	Base b;
	Derive d;
	PrintVirtualTable((VFPTR*)*((int*)(&b)));
	PrintVirtualTable((VFPTR*)*((int*)(&d)));
	return 0;
}

image-20220531231214203

从内存中取地址思路:

1.取出d地址,强转成int类型
2.解引用,这样就是d中头4个字节,即虚表指针的内容
3.将这个int指针强转为VFPTR*类型,因为虚表就是一个存放VFPTR类型指针的数组。
4.虚表指针传递给PrintVTable进行打印虚表。
5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有
放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。

🌕多继承与虚函数表

对于多继承来说,派生类会拷贝两个基类的虚函数表。同样的,重写的虚函数会覆盖原有虚函数,而派生类未重写的虚函数则会放到第一个继承基类部分的虚函数表中.

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;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	cout <" << vTable <", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}
int main()
{
	Derive d;
	//Base1中的虚函数
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	//Base2中的虚函数
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

可以看到:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中

image-20220531231556687

对于派生类而言,当不考虑虚继承时,虚函数表指针在头上4字节,对象紧跟虚函数表指针其后。

这里抛出一个问题:请问一个类最多有几个虚表?

还记得第一次学到这块的时候,我百度了下,出来这个答案!!!当时也就直接接受了,毕竟也解释得通…直到后来某次面试被怼了,再次提醒大家尽量stackoverflow

image-20220603102324721

经过上面多继承的代码验证,一个类继承几个有虚函数的父类就有几个虚表。关于这个问题还懵逼的话可以参考这篇博客:C++多继承下,派生类对象有几张虚函数表?

虚函数表在编译的时候就确定了,而类对象的虚函数指针vptr是在运行阶段确定的,这是实现多态的关键!

这一块再推荐一篇文章:

陈皓大佬的blog

📕小结:

对于单继承:覆盖的函数被放到了虚表中原来父类虚函数的位置,没有被覆盖的函数依旧。

对于多继承:子类有多个虚表(即每个父类都有自己的虚表,看你继承几个父类 就有几张虚表),子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)