C++虚函数深入研究
- 从汇编指令角度来看,虚函数和普通的成员函数并没有什么区别,关键就在于调用方式不同
- 普通成员函数调用是直接找到函数地址并调用
- 虚函数是先通过虚函数表指针找到虚函数表,再在虚函数表中找到函数地址再调用,要比普通成员函数调用复杂一些
- 那么为什么虚函数要多此一举的方式来实现调用,当然是为了多态。
summary
- 带有虚函数的类对象的内存模型
- 虚函数的底层调用机制
- 虚函数指针存放的是虚函数表偏移量
内存模型
不带虚函数的内存模型
-
我们先来看一个
Student类的内存模型class Student: + int Id; + int Age; + double Score; + string Name;sizeof(Student)为48个字节(id(4) + age(4) + score(8) + name(32))
带虚函数的内存模型
-
如果我们给
Student类增加一个虚函数,那么sizeof(Student)又是多少?class Student: + int Id; + int Age; + double Score; + string Name; + virtual void greeting();sizeof(Student)是56,多了8个字节,正好是一个指针的大小,这个指针指向虚函数表,它的内存模型如下图所示:
虚函数调用底层原理
打印虚函数地址
-
先定义一个
Student类class Student + void func() + virtual void virtual_func1() + virtual void virtual_func2() -
接下来打印这三个函数的地址
void print_fun_address(void(Student::*funPtr)(), string funName) { unsigned char buffer[sizeof(funPtr)]; std::memcpy(buffer, &funPtr, sizeof(funPtr)); std::cout << "Member function " << funName << " pointer raw bytes: "; for (size_t i = 0; i < sizeof(funPtr); ++i) { printf("%02X ", buffer[i]); } cout << endl; } int main() { Student stu; print_fun_address(&Student::func, "func"); print_fun_address(&Student::virtual_func1, "virtual_func1"); print_fun_address(&Student::virtual_func2, "virtual_func2"); stu.func(); stu.virtual_func1(); stu.virtual_func2(); return 0; } -
执行结果如下所示:
Member function func pointer raw bytes: 6E 58 97 93 0F 56 00 00 00 00 00 00 00 00 00 00 Member function virtual_func1 pointer raw bytes: 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 Member function virtual_func2 pointer raw bytes: 09 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00- 普通函数的地址倒是很正常,但是两个虚函数的地址打印出来怎么是
1 and 9(cpu眼里的c++这本书讲的是0和8) - 其实虚函数这里的
1 and 9其实是vtable的偏移量(实际要减去1,这也是为什么书里讲的是0和8)
- 普通函数的地址倒是很正常,但是两个虚函数的地址打印出来怎么是
虚函数表
- 我们先来探究下虚函数表,然后再讨论偏移量的问题
- 先从asm窗口确定三个函数的地址
- 再查看虚函数表的内存结构
虚函数的调用细节
void test_call(Student* ptr)
{
ptr->virtual_func1();
ptr->virtual_func2();
}
-
先看这两行代码对应的汇编代码
-
再来看这么一段代码
auto fun = &Student::virtual_func2; (ptr->*fun)(); -
可以看到,如果用一个变量去接收虚函数的话,你会得到一个
offset,然后再调用的时候会从虚函数表中找到对应的函数地址并执行。 -
那么我有一个疑问?上述例子中为什么偏移量用9,等实际调用的时候需要-1得到实际的偏移量?为什么不直接用8偏移量呢?
- 原来是用于区分虚函数和普通函数的,如果是偶数,那么就认为是实际的函数地址,如果是奇数,就是虚函数表的偏移量(需要减去1)
- 这个设计不是c++的标准,是gcc自己的特性
Q & A
-
如果一个class没有任何字段,那么
sizeof(class)占几个字节?- 1个字节,为什么占一个字节?是为了实例对象具有唯一性(占据一个字节的内存),否则有可能多个实例对象共享一块内存地址。
-
如果一个class没有任何字段,但是有一个虚函数,那么
sizeof(class)占几个字节?- 8个字节,因为有
vptr的存在
- 8个字节,因为有
-
为什么普通函数占8个字节,类成员函数占16个字节?
-
子类对象会包含父类对象的虚函数表吗?
- 不会,调用完父类的构造函数后,前8个字节就会替换成子类的虚函数表指针。
-
可以在构造函数中调用虚函数吗?
- 可以,但是无法调用派生类的多态函数,因为在构造函数中,虚函数表指针还是当前类对象的