C++中的虚表

虚函数表(VTBL)

引入:发现虚表的存在

class Base {
public:
	virtual void Func1() {
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};
 
int main(void)
{
    Base b;
 
    return 0;
}

答案:答案令人诧异,居然是 8。

通过监视窗口我们发现除了 _b 成员外还有了一个 _vfptr 在 b1 对象中:

对象中的这个 _vfptr 我们称之为虚表指针(virtual function pointer),我们简称其为 虚表

一个含有虚函数的类中都至少有一个像这样的虚函数表指针,虚函数地址都会放到这个表里。

为了方便演示,我们再多整点函数:

class Base {
public:
	void Func1() {
		cout << "Func1()" << endl;
	}
	virtual void Func2() {
		cout << "Func2()" << endl;
	}
	virtual void Func3() {
		cout << "Func3()" << endl;;
	}
private:
	int _b = 1;
};

通过监视窗口我们可以看到,虚函数 Func2 和 Func3 都被存进了 _vfptr 中。

虚表虚表,自然是存虚函数的表了,Func1 不是虚函数,自然也就不会存入表中。

 观察虚表指针 _vfpt

思考:多态是怎么做到指向哪就调用哪的?对于父类的虚表又是什么样的呢?

 代码:我们用的是 VS2013 + 64位 环境去观测:

class Base {
public:
	virtual void Func1() {
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2() {
		cout << "Base::Func2()" << endl;
	}
	void Func3() {
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};
 
int main(void)
{
	cout << sizeof(Base) << endl;
	Base b;
 
	return 0;
}

监视:我们还是先用监视窗口去做一个简单的观察:

监视窗口是为了方便我们观测优化过的,相当于是一种美化。

注意看,Func3 没有放在 _vfptr 中,又一次证明了这个表里只会存虚函数。

其实虚函数表也没搞什么特殊,也没什么了不起的,虚函数其实是和普通函数一样存在代码段的。

只是普通函数只会进符号表以方便链接,都是 "编译时决议",

而虚函数的地址会被放进虚表,是为了 "运行时决议" 做准备,这个我们后面会细说。
所以这里我们可以这么理解:

 

虚表的本质:虚表是一个 "存虚函数指针的指针数组" ,一般情况这个数组最后面会放一个空指针。 

虚函数的重写与覆盖

回忆一下,上一章我们介绍重写的时候还说过,"重写" 还可以称为 "覆盖",

这是为什么呢?叫重写似乎更好去理解,覆盖好像很难去理解啊。

 代码:现在我们增加一个子类 Derive 去继承 Base:

// 父类 Base
class Base {
public:
	virtual void Func1() {
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2() {
		cout << "Base::Func2()" << endl;
	}
	void Func3() {
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};
 
// 子类 Derive
class Derive : public Base {
public:
	virtual void Func1() {
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
 
int main(void)
{
	cout << sizeof(Derive) << endl;
	Derive d;
 
	return 0;
}

 运行结果:

监视:我们再通过监视窗口观察

 

父类 b 对象和子类 d 对象虚表是不一样的,这里看我们发现 Func1 完成了重写,

所以 d 的虚表中存的是重写的 Derive::Func1,所以虚函数的重写也叫做覆盖。

就可以理解为:子类的虚表拷贝了父类的虚表,子类的 Func1 覆盖掉了父类上的 Func1。

  • 虚函数重写:语法层的概念,子类对继承父类虚函数实现进行了重写。
  • 虚函数覆盖:原理层的概念,子类的虚表,拷贝父类虚表进行了修改,覆盖重写那个虚函数。

总结:虚函数的重写与覆盖,重写是语法层的叫法,覆盖是原理层的叫法。 

编译器的查表行为 

 思考: 是如何做到指针指向谁就调用谁的虚函数的?

class Base {
public:
	virtual void Func1() {
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2() {
		cout << "Base::Func2()" << endl;
	}
	void Func3() {
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};
 
class Derive : public Base {
public:
	virtual void Func1() {
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
 
int main(void)
{
	Base b;
	Derive d;
 
	Base* ptr = &b;  
	ptr->Func1();   // 调用的是父类的虚函数
 
	ptr = &d;
	ptr->Func1();   // 调用的是子类的虚函数
 
	return 0;
}

 运行结果:

 能不能猜到是跟虚表有关系?它到底要调用哪个函数不是按类型去定的,

如果是按类型去定的那这里调的应该都是父类,结果会都是 Base::Func1() ,所以显然不是。

这里会去 ptr 指针指向的对象里去查表,其实对它自己而言它自己都也不知道调用的是谁,

因为子类切个片,它自己也只能看到父类对象,它根本就没法知道,但是他会查表!
具体行为如下:

编译器会从指向的对象里去找,先在父类对象里找到了 Base::Func1, 

	Base* ptr = &b; // 指向是b,是父类Base的
	ptr->Func1();   // 调用的是父类的虚函数

 然后指向变为 &d,它就从子类对象里找,从而找到了 Derive::Func1。

ptr = &d;       // 指向变成d了,是子类Derive的
ptr->Func1();   // 这时调用的就是子类的虚函数了

 所以,多态调用实现是依靠运行时去指向对象的虚表中查,调用函数地址。

 探讨:对象也能切片,为什么不能实现多态?

Base* ptr = &d;    ✅
Base& ref = d;     ✅ 
 
Base b = d;    ❓ 为什么不行?都是支持切片的,为什么对象就不行?

 从编译器的角度,编译器实现时会判断构不构成多态,不满足规则不构成多态就找到地址,call。

至于为什么实现不了多态,因为实现出来会出现混乱状态。 

根本原因是:对象切片时,子类对象只会拷贝成员给父类对象,并不会拷贝虚表指针。 

因为拷贝了就混乱了,父类对象中到底是父类的虚表指针?还是子类的虚表指针?

那下面的调用是调用父类的虚函数还是子类的虚函数?就不确定了:

ptr = &b;
ptr->func1();    //  ?????????? 父类的func1,还是子类的func1?

对象实现多态又不得不去拷贝虚表,因为它肯定是需要去对象里的虚表里找,

问题是拷贝虚表后就乱了套了。最大问题是 —— 这时候到底调用的是谁的问题。

如果一个父类对象切片拷贝给子类后,切片前指向子类,没切片前指向父类。

所以对象不能实现多态,想实现也不行,实现了就乱了套了!

总结:

  • 一个类对象中的 __vptr 是恒定的,它永远都会指向其所属类的虚表。
  • 而当使用指针或引用时,__vptr 会指向继承类的虚表(从而达成多态的效果)

 透过内存窗口仔细观察 _vfptr

class Base {
public:
	virtual void Func1() {
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2() {
		cout << "Base::Func2()" << endl;
	}
	void Func3() {
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};
 
class Derive : public Base {
public:
	virtual void Func1() {
		cout << "Derive::Func1()" << endl;
	}
	void Func3() {
		cout << "Derive::Func3()" << endl;
	}
	virtual void Func4() {
		cout << "Derive::Func4()" << endl;
	}
private:
	int _d = 2;
};

 从监视窗口观察,有时候会发现,好像有些虚函数再监视窗口显示的虚表里不存在。

这时候千万不能动摇 "只要是虚函数都会存入虚表" 这个事实。 

这是监视窗口的锅,我们前面就说了 —— 监视窗口是美化过的!

想要看到真实的样子,我们可以打开内存去查看:

 

但是这内存看的很让人迷糊,这谁看得懂,知道谁是谁?有什么办法可以把虚表打印出来? 

只要取到虚表指针,想打印虚表就很简单了:

虚表是个函数指针数组,该数组里的每个元素存放的是一个函数指针。

typedef void(*V_FUNC)();
 
/* 打印虚表 */
void Print_VFTable(V_FUNC* arr) {
	printf("vfptr:%p\n", arr);
	for (size_t i = 0; arr[i] != nullptr; i++) {
		printf("[%d]: %p\n", i, arr[i]);
		V_FUNC foo = arr[i];
		foo();
	}
}
 
int main(void)
{
	Derive d;
	Print_VFTable(
		(V_FUNC*)(*((int*)&d))   // 指针之间是可以互相转换的
	);
 
    /* 
        语法有规定:完全没有关系的类型强转也不行。
        至少得有一点关系:比如指针和int
        因为指针虽然是地址但是也是表示地址的编号,第几个编号的地址
        指针之间可以随意转换,我想取4个字节,&d 是个 Derive*,
        接引用后就是 Derive,此时强转成 int* 解引用就是取4个字节了。
        由于是 int* 要调用 Print_VFTable 函数传不过去,所以我们最外层又
        强转回 V_FUNC* 了,这是一个函数指针数组的地址指针。
        
        
        “内线转外线再转内线”
    */
 
	return 0;
}

 运行结果:

 

结论:VS 监视窗口看到的虚函数表不一定是真实的,可能被处理过。 

多态的原理

  运行时决议与编译时决议

我们刚才知道了,多态调用实现是靠运行时查表做到的,我们再看一段代码。 

在刚才代码基础上,让父类子类分别多调用一个 Func3,注意 Func3 不是虚函数: 

class Base {
public:
	virtual void Func1() {
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2() {
		cout << "Base::Func2()" << endl;
	}
	void Func3() {
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};
 
class Derive : public Base {
public:
	virtual void Func1() {
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
 
int main(void)
{
	Base b;
	Derive d;
 
	Base* ptr = &b;  
	ptr->Func1();   // 调用的是父类的虚函数
	ptr->Func3();
	 
	ptr = &d;
	ptr->Func1();   // 调用的是子类的虚函数
	ptr->Func3();
 
	return 0;
}

 运行结果:

问题:这里 Func3 为什么不是 Derive 的?

解答:因为 Func3 不是虚函数,它没有进入虚表。

如果我们从更深的角度 —— 汇编层面去看,就可以牵扯出编译时决议和运行时决议。

 决议的意思就是如何去确定函数的地址,一个是在运行时确定,一个是在编译时确定。

多态调用:运行时决议,即运行时确定调用函数的地址。【通过查虚函数表】

普通调用:编译时决议,编译时确定调用函数的地址。【通过类型】

 

这正是多态底层实现的原理,编译器去检查,如果满足多态的条件了,它就按运行时决议的方式。 

 动态绑定与静态绑定

 静态库:指的是链接的那个阶段链接的库。

动态库:程序运行起来后才加载,去动态库里找。 

静态绑定:又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态。比如函数重载。 

 动态绑定:又称后期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也成为动态多态。

静态的多态和动态的多态 

静态的多态(编译时):指的是函数重载。 

int x = 0, y = 1;
double a = 0.0, b = 1.1;
 
swap(x, y);
swap(a, b);
 
这两个 swap 让人感觉是同一个函数,
但实际不是。实际编译链接根据函数名修饰规则找到不同的函数。

 动态的多态(运行时):指的是本节内容讲的这个。

void Func(Person& p) {
    p.BuyTicket();
}
 
Person Mike;
Func(Mike);
 
Student Jack;
Func(Jack);

 单继承与多继承关系的虚函数表

单继承中的虚函数表 

代码:单继承中的虚函数表: 

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; }
	void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
private:
	int b;
};

 我们还是用刚才介绍的方法打印虚表:

typedef void(*VFPTR) ();
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()
{
	Base b;
	Derive d;
}

 

代码:我们在把虚函数表打印出来看看(32位取头上4个字节,64位需要取头上8个字节): 

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

 运行结果:

 

多继承中的虚函数表 

刚才我们看的是单继承,我们现在再看复杂一点的多继承。 

代码:Base1 和 Base2 都进行了重写 

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 << 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;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

 这里 Derive 明显会有两张虚表,我们先透过监视简单看一下:

 

我们的 func3 是放哪一个虚表里?是两张都放一份,还是选择一份放呢? 

 

func1 的两个地址好像不一样,0X0911ae 和 0X901249,因为它们都不是真正的函数的地址。

我们来看看 Derive 中的 func1 真正的地址:

printf("%p\n", &Derive::func1);

 

 这里可能就是多套了一层,是一种保护机制。虽然不一样但是最后都跳到了函数上面去。

 结论:Derive 对象 Base2 虚表中 func1 时,是 Base2 指针 ptr2 取调用,但是这时 ptr2 发生切片指针偏移,需要修正。中途就需要修正存储 this 指针 ecx 的值。

 问题:这里还有一个指针偏移的问题,在多继承中这三个指针的值是一样的吗?

Base1* ptr1 = &d;
Base2* ptr2 = &d;
Derive* ptr3 = &d;
 
cout << ptr1 << endl;
cout << ptr2 << endl;
cout << ptr3 << endl;

 运行结果:0073FBA0   0073FBA8   0073FBA0

答案:不一样。给人第一感觉好像是一样的,因为赋过去的值都是 &d,但实际上并不一样。

因为这里要发生切片,切片后赋值兼容,所以它们的地址就不一样了。

 

 多态的一些题目

1. 什么是多态?

2. 什么是重载、重写(覆盖)、重定义(隐藏)?

3. 多态的实现原理?答:参考本节课件内容

4. inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为 虚函数要放到虚表中去。

5. 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式 无法访问虚函数表,所以静态成员函数无法放进虚函数表。

6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。

7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析构函数定义 成虚函数。参考本节课件内容

8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针对象或者是 引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。

9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表存在数据段。

10. C++菱形继承的问题?虚继承的原理?
 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值