C++虚函数深入研究

34 阅读4分钟

C++虚函数深入研究

  • 从汇编指令角度来看,虚函数和普通的成员函数并没有什么区别,关键就在于调用方式不同
    • 普通成员函数调用是直接找到函数地址并调用
    • 虚函数是先通过虚函数表指针找到虚函数表,再在虚函数表中找到函数地址再调用,要比普通成员函数调用复杂一些
  • 那么为什么虚函数要多此一举的方式来实现调用,当然是为了多态。

summary

  • 带有虚函数的类对象的内存模型
  • 虚函数的底层调用机制
    • 虚函数指针存放的是虚函数表偏移量

内存模型

不带虚函数的内存模型

  • 我们先来看一个 Student 类的内存模型

    class Student:
    + int Id;
    + int Age;
    + double Score;
    + string Name;
    
    • image.png
    • 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个字节,正好是一个指针的大小,这个指针指向虚函数表,它的内存模型如下图所示:
      • image.png

虚函数调用底层原理

打印虚函数地址

  • 先定义一个 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)
    • image.png

虚函数表

  • 我们先来探究下虚函数表,然后再讨论偏移量的问题
  • 先从asm窗口确定三个函数的地址
    • image.png
  • 再查看虚函数表的内存结构
    • image.png

虚函数的调用细节

void test_call(Student* ptr)
{
	ptr->virtual_func1();
	ptr->virtual_func2();
}
  • 先看这两行代码对应的汇编代码

    • image.png
  • 再来看这么一段代码

    auto fun = &Student::virtual_func2;
    (ptr->*fun)();
    
    • image.png
  • 可以看到,如果用一个变量去接收虚函数的话,你会得到一个 offset,然后再调用的时候会从虚函数表中找到对应的函数地址并执行。

  • 那么我有一个疑问?上述例子中为什么偏移量用9,等实际调用的时候需要-1得到实际的偏移量?为什么不直接用8偏移量呢?

    • 原来是用于区分虚函数和普通函数的,如果是偶数,那么就认为是实际的函数地址,如果是奇数,就是虚函数表的偏移量(需要减去1)
    • 这个设计不是c++的标准,是gcc自己的特性

Q & A

  • 如果一个class没有任何字段,那么 sizeof(class) 占几个字节?

    • 1个字节,为什么占一个字节?是为了实例对象具有唯一性(占据一个字节的内存),否则有可能多个实例对象共享一块内存地址。
  • 如果一个class没有任何字段,但是有一个虚函数,那么 sizeof(class) 占几个字节?

    • 8个字节,因为有 vptr 的存在
  • 为什么普通函数占8个字节,类成员函数占16个字节?

  • 子类对象会包含父类对象的虚函数表吗?

    • 不会,调用完父类的构造函数后,前8个字节就会替换成子类的虚函数表指针。
  • 可以在构造函数中调用虚函数吗?

    • 可以,但是无法调用派生类的多态函数,因为在构造函数中,虚函数表指针还是当前类对象的