关于虚函数和虚函数表的深度理解(c++代码到汇编)
一 背景
今天晚上偶然间看到了一篇关于虚函数的博客,引起了我极大的兴趣,于是我打算从代码入手,把虚函数指针和虚函数表的实质弄清楚,后面带着疑惑越走越远,走到了汇编,终于是解开了虚函数的真相。虚函数终于破云开雾!
二 实践过程
1.类内声明或定义虚函数后,会有什么样的变化?
首先由我了解的虚函数,每当类中加上virtual关键字的时候,类中就会多出一个虚函数表指针(指向虚函数表的指针)。为了证明我做了以下的实验:
//1个字节
class Base {
public:
void test() {
std::cout << "base test" << std::endl;
}
};
很明显我定义的上述类Base是一个普通的类,大小就是一个字节。然后我给每个方法加上virtual关键字,将普通方法变成虚函数。如下:
//8个字节
class Base {
public:
virtual void test() {
std::cout << "base test" << std::endl;
}
};
你会发现这个类的大小为8个字节,很明显这是一个指针的大小,它与关键字virtual关键字的个数无关,所以这个指针就是我们所说的虚函数表指针,也就证明了上述结论,类中存在virtual关键字修饰的虚函数的时候,它会有一个虚函数表指针。
证明这个结论后,我又提出疑问,子类继承这个类后,是不是同一个虚函数指针呢?换言之,它们指向的是同一张表吗?(为什么会有这个疑问呢?看下面)
2.子类会继承父类中的虚函数表指针吗或者说子类中的虚函数表指针跟父类会是一样吗?
提出这个问题的时候,(肯定有人觉得我c++语法基础不够牢固)肯定有人会说这是无容置疑的,因为从c++代码的角度出发,继承一个类,会继承且拥有它的所有属性。但是如果我们假设这对于虚函数表指针也成立,也就说假设父类和子类所拥有的虚函数表指针相同即指向同一个虚函数表,即每次匹配函数的时候,都是在同一张表中进行所谓的的查找,那么按照我们原先对虚函数的理解(我原先对虚函数的理解),当一个子类重写了父类中的虚函数的时候,也就覆盖了虚函数表中原先父类所定义的虚函数。但是事实真的是这样吗?结合我们实际来看并不是这样,我们可以通过找到父类和子类所对应的虚函数表指针是否相等来证明:
class Base {
public:
virtual void test() {
std::cout << "base test" << std::endl;
}
virtual void test1() {
std::cout << "base test1" << std::endl;
}
virtual void test2() {
std::cout << "base test2" << std::endl;
}
};
class Son : public Base {
};
int main() {
Base base;
Son son;
using u64 = int64_t;
//指向基类和父类中的虚函数表指针的指针
u64 *p = (u64 *) (&base);
u64 *p1 = (u64 *) (&son);
//基类和父类中的虚函数表指针
u64 *arr1 = (u64 *) (*p);
u64 *arr2 = (u64 *) (*p1);
std::cout << arr1 << " " << arr2 << std::endl;
}
输出结果:
00007FF65288ADB8 00007FF65288ABB8
这就可以说明,父类和子类中对应的虚函数表指针并不相同,通俗点就是说,两个类对应两张虚函数表。如下图:
经过上述实验我们就能确定,子类和父类对应的虚函数表是不同的。
3.函数重写的时候发生了什么?
对于这个问题我觉得首先我们先来探究一下函数没有重写的时候,两个类中虚函数表中对应的函数指针是不是一样。如下代码:
class Base {
public:
int a;
virtual void test() {
std::cout << "base test" << std::endl;
}
virtual void test1() {
std::cout << "base test1" << std::endl;
}
virtual void test2() {
std::cout << "base test2" << std::endl;
}
};
class Son : public Base {
//没有重写父类中的虚函数
// void test() override {
// std::cout << "son test" << std::endl;
// }
//
// void test1() override {
// std::cout << "son test1" << std::endl;
// }
//
// void test2() override{
// std::cout << "son test2" << std::endl;
// }
};
int main() {
Base base;
Son son;
using u64 = int64_t;
//函数指针
using func = void(*)();
//指向基类和父类中的虚函数指针
u64 *p = (u64 *) (&base);
u64 *p1 = (u64 *) (&son);
//虚函数表指针
u64 *arr1= (u64 *) (*p);
u64 *arr2 = (u64 *) (*p1);
for (int i = 0; i < 3; ++i) {
//遍历虚函数表中的函数指针地址
std::cout << arr1[i] << " " << arr2[i] << std::endl;
}
func fa = (func) arr2[0];
func fb = (func) arr2[1];
func fc = (func) arr2[2];
fa();
fb();
fc();
}
输出结果:
140696546644903 140696546644903 //函数指针相同
140696546644908 140696546644908 //函数指针相同
140696546644913 140696546644913 //函数指针相同
base test
base test1
base test2
如下图:
上面我们发现,若子类没有重写父类的虚函数,两张表中(其实虚函数表就是一个数组)存放的函数指针是一样的。这时候我们尝试着重写一个虚函数:
class Son : public Base {
void test() override {
std::cout << "son test" << std::endl;
}
};
输出结果:
140700380369831 140700380369881 //函数指针不同
140700380369836 140700380369836 //函数指针相同
140700380369841 140700380369841 //函数指针相同
son test
base test1
base test2
这里我们在son类中重写了test()方法,虚函数表中存储的第一个函数指针就发生了改变。
如下图:
从上述实验中我们就能直到下面两件事情:
1.子类若没有重写父类中的虚函数那么它自己的虚函数表中的所存储的函数指针是跟父类中一样或者说函数指针指向的是父类中的函数。
2.子类重写虚函数后,它只是修改了它自己虚函数表中函数指针,指向了重写后的函数。
4.虚函数表在什么时候创建的?
虚函数表在什么时候创建的,这时候我们将走进汇编世界,查看汇编。我们查看上述代码的汇编,发现虚函数表的部分
如下图:
上面的汇编代码包含了含有虚函数的类的属性,以及其各自虚函数表中的信息。我们发现了Son中的虚函数表中包含了三个函数,其中一个是Son::test()原因是因为我们上面重写了父类中的test()方法。从这里我们就能知道,在c++代码静态编译的过程中,一旦出现了virtual关键字,也就是说出现了虚函数,编译器就会在此期间帮你建立相应的虚函数表,并且根据子类是否重载了父类方法,确定虚函数表中的内容。
5.查找虚函数的开销主要在哪?
这个方面我们觉得我们要从两方面探讨:
第一:正常调用使用子类调用子类中的方法
Son s;
s.test();
s.test1();
s.test2();
对应的汇编:
第二:利用多态的方式调用:
Base * b = new Son;
b->test();
b->test1();
b->test2();
对应的汇编:
从上述两种情况我们就能知道,如果是正常使用子类调用自己的方法,那么是不会任何关于多态的开销,所有的开销跟普通的类调用自己方法的开销是一样的,都是直接call…它不会有虚函数定位的一个过程。
但是如果我们使用多态的方式来调用方法的话,我们在调用方法的过程中就明显复杂了许多,这就是一个查找和定位的过程,确定类型,定位虚函数表,查找对应的虚函数,这就是我们平时所说在使用多态时候,在虚函数表中查找虚函数的过程,这也占所谓的动态绑定开销的一大部分。还有可以注意的点是:因为我这是64位的编译器和主机,所以指针的大小都是8个字节,分别调用test(),test1(),test2(),的时候,其在虚函数表中所占的大小就是8个字节,这里在汇编的过程中也体现出来了。(看不懂的建议搜一下,上述汇编不是很难)
三 总结
终于到了总结的部分,我觉得如果根据上面的思路把整个实验流程走了一遍,那么你在理解再一下我下面给出的官方点的结论,你应该会茅塞顿开。
动态绑定的开销主要体现在这个几个方面:
- 虚函数表(vtable):编译器会为每个含有虚函数的类生成一个虚函数表,其中记录了虚函数的地址。每个对象都会包含一个指向其对应虚函数表的指针,这样在运行期可以根据对象的实际类型来查找和调用正确的虚函数。
- 虚函数调用:当使用基类指针或引用调用虚函数时,需要经过一层间接的函数调用,即先通过对象的虚函数表找到正确的虚函数地址,然后再进行函数调用。这个额外的间接过程会引入一定的开销。
- 运行时类型识别(RTTI):在使用虚函数时,编译器需要进行运行时类型的识别,以确定具体调用的是哪个对象的虚函数。这通常通过运行时类型信息(RTTI)来实现,可能需要额外的开销和内存占用。
参考链接:
启蒙