(巨详细 + 图解) C++多态的机制原理

上一篇我们简单认识了C++的多态, 这次我们来看看多态的底层机制和原理. 不多哔哔, 直接开始.

文章目录



虚函数表(虚表)和虚表指针

1. 虚表和虚表指针的认识

首先我们来看一个常见的问题, 下面类的对象占几个字节

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

private:
	int _a = 0;
};

按原来的知识点来说, 成员函数是不体现在对象中的, 所以一个Size对象是4字节

下面进行测试

void Test() {
	cout << sizeof(Size) << endl;
}

(巨详细 + 图解) C++多态的机制原理
我们可以看到结果是8, 显然不是上述的情况, Size类的对象模型如下

(巨详细 + 图解) C++多态的机制原理

我们可以看到, 除了_a以外, 还有一个_vfptr

这个_vfptr我们叫做虚表指针 (v代表virtual,f代表function)
一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表

我们要明确一下概念

  • 虚函数本身不占用对象空间
  • 使用虚函数后, 对象中会有一个虚表指针
  • 虚表: 存放虚函数的地址 (是一个存放虚函数指针的数组)
  • 虚表指针: 指向虚表的首地址 (本身是一个二级指针)
  • 普通的成员函数地址不会放在虚表中

2. 其他值得注意的问题

下面我们先看一段代码

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

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

	//普通成员函数地址不会存放在虚表中
	void func3() {
		cout << "base::func2()" << endl;
	}

private:
	int _a = 1;
};

class Derive1 : public Base0 {
public:
	//重写func1
	virtual void func1() {
		cout << "derive::func1()" << endl;
	}
	
	//子类新增的虚函数
	virtual void func4() {
		cout << "derive::func4()" << endl;
	}

private:
	int _d = 0;
};

void Test2() {
	Base0 b;
	Derive1 d;
}

下面给出对象b和d的内存模型

(巨详细 + 图解) C++多态的机制原理

1. 我们可以看到, 父类有一张虚表, 子类也继承下来一张虚表, 但是两张虚表的地址是不同的(看vfptr的值即可)
2. 明显可以看到, 子类重写了父类的func1函数, 在虚表中存的是子类的func1函数, 我们把这这个叫做虚函数的重写, 也叫覆盖
3. 虚函数表本质是一个存虚函数指针的指针数组,这个数组以空指针nullptr结尾

总结一下派生类的虚表生成 :

  • a.先将基类中的虚表内容拷贝一份到派生类虚表中
  • b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
  • c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。(vs的调试器不可见, 后面我们会手动调用虚函数以验证)

3. 验证虚表存在哪里

下面我们通过一段代码验证在VS中虚表大致存在哪里, 只是大概的验证, 并不能精确的找到

void test3() {
	//数据段
	static int si = 3;
	//栈
	int stackval = 2;
	//堆
	int* heapval = new int;
	//代码段
	const char* p = "123";

	Base0 b;
	//虚表指针 : 对象头4/8字节
	int addr = *((int*) &b);  //获取对象头4个字节的内容
	cout << "虚表首地址: ";
	printf("%p\n", addr);

	printf("代码段: %p\n", p);	
	printf("数据段: %p\n", &si);	
	printf("堆: %p\n", heapval);
	printf("栈: %p\n", &stackval);
}

验证的思路也很简单, 分别定义堆, 栈, 数据段和代码段的变量, 然后拿到对象虚表的地址, 进行对比, 离得最近的就可以大致认为在什么地方存放

下面是对addr的详细解释
(巨详细 + 图解) C++多态的机制原理
运行结果如下:
(巨详细 + 图解) C++多态的机制原理
我们可以看到, 虚表地址距离代码段的变量最近, 故可以认为虚表存在代码段

虚表指针: 对象中 (存放虚表的首地址)
虚表: 代码段 (存放虚函数的首地址)
虚函数: 代码段

注意: 虚函数是存在代码段的, 很多同学误以为存在虚表中, 虚表中存的是虚函数的地址! ! !



多态的原理

下面给出一段代码

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
};

void Func(Person* p) {
	p->BuyTicket();
}

void test() {
	Person p;
	Func(&p);

	Student s;
	Func(&s);
}

我们拿到 Func(&s) 执行时的汇编代码来研究多态的原理
下面直接给出一图流

(巨详细 + 图解) C++多态的机制原理

多态 : 看对象
虚函数的行为 : 虚表中存放的实际地址决定

多态过程 :
1. 首先通过指针/引用定位到实际的对象
2. 从对象中获取实际虚表的首地址 (虚表指针)
3. 从虚表中找到函数的实际地址
4. 最后执行虚函数


动态绑定与静态绑定

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


单继承和多继承关系的虚表

1. 单继承中的虚表

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a = 0;
};

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 = 0;
};

创建上述两个类的对象, 并查看其内存模型
(巨详细 + 图解) C++多态的机制原理
我们可以看到子类新增的虚函数在vs的调试器中是不可见的, 但他确实会加到虚表中, 下面我们进行验证

我们通过下面的代码手动调用虚函数, 通过结果验证

typedef void (*pfun)();

void test4() {
	Base b;
	pfun* vftable = (pfun*)*((int*)&b);
	(*vftable)();
	(*(vftable + 1))();

	Derive d;
	pfun* vftable2 = (pfun*)*((int*)&d);
	(*vftable2)();
	(*(vftable2 + 1))();
	(*(vftable2 + 2))();
	(*(vftable2 + 3))();
}

给出详细图解
(巨详细 + 图解) C++多态的机制原理
接下来看看运行结果
(巨详细 + 图解) C++多态的机制原理
可以看到, 通过子类的虚表指针, 我们手动调用了虚函数, 说明子类新增的虚函数确确实实是按顺序加在虚表中的


2. 验证虚表以nullptr结尾

//验证虚表是不是以空指针结束
void printVfptr(pfun* vftable) {
	cout << "虚表地址: " << vftable << endl;
	//虚表以空指针结束
	for (; *vftable != nullptr; ++vftable) {
		cout << "函数地址: " << *vftable << " ";
		(*vftable)();
		cout << endl;
	}
}

上述函数, 参数是虚表指针, 函数体就是循环调用虚表中的虚函数, 直到虚表最后一个元素 — 空指针为止

有了上面的函数, 就可以把上面的test4函数进行改造, 进行验证

void test4() {
	Base b;
	pfun* vftable = (pfun*)*((int*)&b);
	printVfptr(vftable);

	Derive d;
	pfun* vftable2 = (pfun*)*((int*)&d);
	printVfptr(vftable2);
}

我们预想的结果是按顺序输出虚函数的地址和内容

运行结果如下:
(巨详细 + 图解) C++多态的机制原理

可以看到, 结果和预想的一致, 说明虚表最后是以nullptr结尾的


3. 多继承中的虚函数表

先给出多继承的代码

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

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

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

三个类的对象内存模型如下 :

(巨详细 + 图解) C++多态的机制原理
(巨详细 + 图解) C++多态的机制原理

可以看到多继承后, 子类中每一个父类都会有一张虚表
子类中func1覆盖了两个父类的func1

而且子类的虚函数没有在虚表中出现, 这还是vs调试器看不到的问题, 我们再来手动找到他, 看他在第一个直接父类的虚表中, 还是在第二个父类的虚表中

void test5() {
	Base1 b1;
	pfun* vftable1 = (pfun*)*((int*)&b1);
	printVfptr(vftable1);

	Base2 b2;
	pfun* vftable2 = (pfun*)*((int*)&b2);
	printVfptr(vftable2);

	Derive2 d;
	pfun* vftable3 = (pfun*)*((int*)&d);
	printVfptr(vftable3);
	
	//访问第二个直接父类的虚表
	//跳过第一个直接父类的大小 
	//具体操作就是先强转成char*, 再跳过sizeof(Base1)即可
	//其他操作与之前一样
	pfun* vftable4 = (pfun*)*((int*)((char*)&d + sizeof(Base1)));
	printVfptr(vftable4);
}

运行结果如下:
(巨详细 + 图解) C++多态的机制原理
由此可证明, 子类中新增的虚函数加在第一个直接父类的虚表当中



关于虚函数的几个问题

1. inline函数可以是虚函数吗?

不能, 因为inline函数没有地址,无法把地址放到虚函数表中。

内联函数会被编译器展开成汇编代码, 无法将地址存在虚表中

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

不能, 因为静态成员函数没有this指针,直接使用类名访问成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

我们知道虚表指针是存在对象中的, 直接通过类名访问成员函数时, 对象不一定存在, 所以不可以

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

不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。

虚表指针是存在对象中的, 调用构造函数是为了构造对象, 此时对象还不存在, 所以不可以

上一篇:2021-09-04


下一篇:C++ 虚函数表解析