C++ 对象模型:虚函数表的底层结构与多态实现

0.简介

多态作为面向对象三大核心特性(封装、继承、多态)之一,其可以分为静态多态(模板,重载,重定义)和动态多态。理解动态多态的底层实现原理对于掌握C++对象模型有重要意义。本文将从虚函数表、内存布局、调用的汇编代码等底层结构出发,深入解析动态多态的实现机制。

1.C++对象内存结构

要理解C++的动态多态实现,需要从对象模型(Object Model)的内存结构入手。让我们先通过设计者的视角来分析这个机制:

1)核心问题:在复杂的继承体系中,对象如何动态确定要调用的函数地址?如果使用地址记录应该怎么记录?如果每个对象都存储所有虚函数的地址,会导致严重的空间浪费(每个对象都保存一份完整的函数指针表)。

2)解决方案设计:C++采用的是一种间接访问的方案,我们分别在类和对象角度来看:

  1. 类级别:每个包含虚函数的类会维护一个虚函数表(vtable),这是一个函数指针数组。
  2. 对象级别:每个对象只需存储一个指向对应vtable的指针(vptr)。
|----------------|          |------------------|
| 对象实例        |          | 类的虚函数表     |
|----------------|          |------------------|
| vptr ----------------> | 虚函数1的地址  |
| 成员变量       |          | 虚函数2的地址  |
|----------------|          | ...             |
                            |------------------|

3)关键优势:

空间效率:所有同类对象共享同一个vtable。

动态绑定:通过vptr在运行时确定实际调用的函数。

继承扩展:派生类可以创建新的vtable,只需修改差异部分

这种设计完美平衡了空间效率(每个对象只需一个指针开销)和时间效率(通过一次指针间接访问实现动态绑定),是C++多态机制的基石。

2.虚函数表基本结构

虚表是以指针数组存在的,即内部又指向不同的函数地址。我们通过一个例子来看。

#include <iostream>
using namespace std;
// 基类
class Base {
public:
    virtual void f() { cout << "Base::f()" << endl; }
    virtual void g() { cout << "Base::g()" << endl; }
    int a;
};
// 派生类
class Derived : public Base {
public:
    void f() override { cout << "Derived::f()" << endl; } // 重写f()
    virtual void h() { cout << "Derived::h()" << endl; }   // 新增虚函数h()
    int b;
};
// 定义函数指针类型(匹配虚函数签名)
typedef void (*VFuncPtr)();
int main() {
    Derived d;
    // 获取对象的虚表指针(vptr):对象首地址即为vptr
    void**vtable =*(void*** )&d;
    cout << "虚表地址: " << vtable << endl;
    // 打印虚表中的函数
    cout << "虚表内容(函数地址及调用结果):" << endl;
    for (int i = 0; i < 3; ++i) {  // Derived有3个虚函数:f、g、h
        VFuncPtr func = (VFuncPtr)vtable[i];  
        cout << "  索引" << i << ":地址=" << (void*)func << ",调用:";
        func();  // 调用虚函数
    }
    return 0;
}

运行结果如下:

所以我们可以得到其层级结构如下:

我们也可以使用如下命令去查看,可以看到其前两个是记录一些别的信息,所以虚表指针是从16偏移开始。

g++ -fdump-lang-class your_code.cpp

3.不同继承下的虚函数表

3.1 单继承下的虚函数表

单继承情况我们在第2节中已经描述了,其简单来说就是将自己的函数替换到虚表中,其余继续使用基类的,这种继承无论多少层都是先继承上一层的虚表,然后把自己实现的替换进去。

3.2 多继承下的虚函数表

多继承是通过多个虚表指针来实现的,其中如果函数同名在调用时会报错,比如基类Base1和基类Base2都定义了虚函数f(),如果调用是直接调用会报错,需要d.Base1::f(), 我们通过下面这个代码来看其结构:

#include<iostream>
using namespace std;
class Base1 { 
public:
    virtual void f(){cout<<"a"<<endl;}; 
};
class Base2 { 
public:
    virtual void g(){cout<<"a"<<endl;}; 
};
class Derived : public Base1, public Base2 {};
int main()
{
    Derived d;
    d.Base1::f();
    return 0;
}

其虚表结构如下:

依旧可以使用g++ -fdump-lang-class your_code.cpp命令验证:

3.3 菱形继承下的虚函数表

菱形继承即多继承有着共同的父类,我们以下面例子来看,如果不是虚继承且f()在Bottom中没有实现,其直接使用b.f()就会报错,因为其两个父类中都有(来源于Top类),需要指定调用那个的,加上作用域;当然如果Bottom实现了的话就可以直接调用:

#include<iostream>
using namespace std;
class Top {                  // 顶层基类
public:
    virtual void f() { cout << "Top::f()" << endl; }
    int x;
};
class Middle1 : public Top {  // 中间基类1
public:
    virtual void g1() { cout << "Middle1::g1()" << endl; }
};
class Middle2 : public Top {  // 中间基类2
public:
    virtual void g2() { cout << "Middle2::g2()" << endl; }
};
class Bottom : public Middle1, public Middle2 {  // 菱形顶点
public:
    //void f() override { cout << "Bottom::f()" << endl; }  // 重写Top::f
};
int main()
{
    Bottom b;
    b.Middle1::f();
    return 0;
}

这个的结构如下,其同样是两个虚函数指针:

我们依旧使用g++ -fdump-lang-class your_code.cpp命令验证。

可以看到其中Top的存储存在重复且调用需要指定类名,这个可以通过虚继承解决,代码如下:

#include<iostream>
using namespace std;
class Top {                  // 顶层基类
public:
    virtual void f() { cout << "Top::f()" << endl; }
    int x;
};
class Middle1 : virtual public Top {  // 中间基类1
public:
    virtual void g1() { cout << "Middle1::g1()" << endl; }
};
class Middle2 : virtual public Top {  // 中间基类2
public:
    virtual void g2() { cout << "Middle2::g2()" << endl; }
};
class Bottom : public Middle1, public Middle2 {  // 菱形顶点
public:
    //void f() override { cout << "Bottom::f()" << endl; }  // 重写Top::f
};
int main()
{
    Bottom b;
    b.f();
    return 0;
}

这个我们直接通过g++ -fdump-lang-class your_code.cpp来看其效果。

4.虚函数调用流程

虚函数调用流程我们从编译期和运行期分别来看,使用简单代码来做分析:

#include<iostream>
using namespace std;
class Base {
public:
    virtual void f() { cout << "Base::f()" << endl; } // 虚函数
    int a;
};
class Derived : public Base {
public:
    void f() override { cout << "Derived::f()" << endl; } // 重写虚函数
    int b;
};
int main()
{
    Base* b = new Derived();
    b->f();
    return 0;
}

1)编译期:编译期在处理Base类时,会给虚函数f()分配一个在虚表中的固定索引,派生类重写f()时会在自己的虚表中同一索引位置替换成自己的函数地址,也就是说,虚表在编译期就生成完成。

2)运行期:在创建对象时,其头部存放虚表指针,指向类对应的虚表,调用时通过指针找到虚表,根据索引找到函数地址,然后调用,我们来看一下其汇编代码:

g++ -S -fverbose-asm -g hhh.cpp -o test.s
# hhh.cpp:18:     b->f();
    .loc 1 18 10                  # 源代码位置标记(第18行)


    # 第一步:获取对象指针
    movq    -24(%rbp), %rax       # 从栈帧偏移-24处加载对象指针b到rax寄存器
                                  # 这里-24(%rbp)是局部变量b在栈上的存储位置
                                  # rax现在持有Base*类型的对象地址


    # 第二步:获取虚函数表指针(vptr)
    movq    (%rax), %rax          # 解引用对象指针,获取虚表指针(vptr)
                                  # 在C++对象内存布局中,vptr通常位于对象起始位置
                                  # 现在rax保存的是该对象对应的虚函数表地址


    # 第三步:获取虚函数地址
    movq    (%rax), %rdx          # 解引用虚表指针,获取第一个虚函数地址
                                  # 虚函数表是函数指针数组,首元素对应第一个虚函数f()
                                  # rdx现在保存的是Base::f()的实际函数地址


# hhh.cpp:18:     b->f();
    .loc 1 18 9                   # 源代码位置标记(同一条语句的另一种位置表示)


    # 第四步:准备this指针参数
    movq    -24(%rbp), %rax       # 再次加载对象指针到rax(可能因优化被重用)
    movq    %rax, %rdi            # 将对象指针移动到rdi寄存器(x64调用约定的第一个参数)
                                  # 在成员函数调用时,this指针作为隐式参数传递


    # 第五步:执行虚函数调用
    call    *%rdx                 # 通过函数指针间接调用
                                  # 实际会跳转到rdx存储的地址执行Base::f()
                                  # 这是动态多态的核心机制

5.总结

本文对于动态多态机制进行了详细的分析,从对象模型到虚函数布局再到其调用流程,可以看到,其既通过中间层提供了良好的扩展机制,但也带来了部分的性能开销,这也是一些对性能要求极高的应用选择静态多态(比如模板)的原因。

原文链接:,转发请注明来源!