C++ 多态实现之虚函数表

本文内容大部分来自:C++虚函数表深入探索(详细全面)

虚函数表简介

虚函数(Virtual Function)是通过一张虚函数表来实现的。简称为 V-Table

在这个表中,主要是一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其真实反应实际的函数。

在有虚函数的类的实例中分配了指向这个表的指针的内存,当用父类的指针来操作一个子类的时候,这张虚函数表就可以指明了实际所应该调用的函数。

虚函数表存在的位置

如果对象存在虚函数,那么编译器就会生成一个指向虚函数表的指针,所有的虚函数都存在于这个表中。

虚函数表就可以理解为一个数组,每个单元用来存放虚函数的地址。

由于虚函数表是由编译器给我们生成的,那么编译器会把虚函数表安插在哪个位置呢?

下面可以简单的写一个示例来证明一下虚函数表的存在,以及观察它所存在的位置,先来看一下代码:

#include 
using namespace std;

class A{
public:
    int x;
    virtual void b() {}
};

int main()
{
    A* p = new A;
    cout << "sizeof(A) = " << sizeof(A) << endl;

    cout << p << endl;
    cout << &p->x << endl;
    return 0;
}

定义了一个类 A,含有一个 x 和一个虚函数 b()

实例化一个对象,然后输出对象的地址和对象成员 x的地址,可以通过对象地址和对象成员 x 的地址判断虚函数表的位置:

  • 如果对象的地址和 x 的地址相同,那么就意味着编译器把虚函数表放在了末尾
  • 如果两个地址不同,那么就意味着虚函数表是放在最前面的。

控制台输出:

sizeof(A) = 16
0x55d10364fc20
0x55d10364fc28

可以观察到结果是不同的,而且正好相差了 8 bytes(64 位系统中一个指针类型的 sizeof())。

由此可见,编译器把生成的虚函数表放在了最前面。

获取虚函数表

既然虚函数表是真实存在的,那么我们能不能想办法获取到虚函数表呢?

其实我们可以通过指针的形式去获得,因为前面也提到了,我们可以把虚函数表看作是一个数组,每一个单元用来存放虚函数的地址,那么当调用的时候可以直接通过指针去调用所需要的函数就行了。

我们就类比这个思路,去获取一下虚函数表。

首先先定义两个类,一个是基类一个是派生类,代码如下:

#include 
using namespace std;

class Base {
public:
    virtual void a() { cout << "Base a()" << endl; }
    virtual void b() { cout << "Base b()" << endl; }
    virtual void c() { cout << "Base c()" << endl; }
};

class Derive : public Base {
public:
    virtual void b() { cout << "Derive b()" << endl; } // 覆盖Base::b()
    virtual void d() { cout << "Derive d()" << endl; }
};

// void a()
// void b()
// void c()
// 可以表示为如下的函数指针 
typedef void(*Fun)(void);

现在我们设想一下 Derive 类中的虚函数表是什么样的,它应该是含有三个指针,分别指向基类的虚函数 a()c() 以及自己的虚函数 b() (因为基类和派生类中含有同名函数,被覆盖)和 e()

那么我们就用下面的方式来验证一下:

int main()
{
    Derive* p = new Derive;
    cout << "Derive对象所占的内存大小为:" << sizeof(d) << endl;
    printf("sizeof(Fun) = %d\n", sizeof(Fun));

    // 把 p 指针转换为 (Fun**) 指针
    // 用来表示虚函数表
    // vt_ptr 表示虚函数表数组
    // *vt_ptr 表示虚函数表的函数指针
    // **vt_ptr 表示虚函数的实际地址
    Fun** vt_ptr = (Fun**)p;

    cout << "-----------打印虚函数地址------------" << endl;
    for (int i = 0; i < 5; i++) {
        printf("vptr[%d] : %p\n", i, *(*vt_ptr + i));
    }

    cout << "-----------调用虚函数------------" << endl;
    for (int i = 0; i < 4; i++) {
        // 调用函数指针指向的虚函数
        (*(*vt_ptr + i))();
    }

    return 0;
}

控制台输出:

Derive对象所占的内存大小为:8
sizeof(Fun) = 8
-----------打印虚函数地址------------
vptr[0] : 0x55f3b1dd5d3c
vptr[1] : 0x55f3b1dd5de4
vptr[2] : 0x55f3b1dd5dac
vptr[3] : 0x55f3b1dd5e1c
vptr[4] : (nil)
-----------调用虚函数------------
Base a()
Derive b()
Base c()
Derive d()

提示:vptr[4] : (nil) 表示这一个内存地址没有引用虚函数。

每个类都有自己的虚函数表指针,指向自己的虚函数表

如上图所示,Derive 的虚函数表就是继承了 Base 的虚函数表,然后自己的虚函数放在后面,因此这个虚函数表的顺序就是基类的虚函数表中的虚函数的顺序 + 自己的虚函数的顺序


【小知识】

因为 sizeof(long)sizeof(Fun) 的值都是 8 bytes

因此如果我们只需要获得函数的地址的情况下,可以用 (long **) 表示虚函数表的类型。

如下代码也是可行的:

int main()
{
    printf("sizeof(long) = %d\n", sizeof(long));

    Derive* p = new Derive;
    long** vt_ptr = (long**)p;
    for (int i = 0; i < 5; i++) {
        printf("vptr[%d] : %p\n", i, *(*vt_ptr + i));
    }
    return 0;
}

同理,我们把基类的虚函数表的内容也用这种方法获取出来,然后二者进行比较一下,看看是否是符合我们上面所说的那个情况。

先看一下测试代码:

int main()
{
    cout << "-----------Base------------" << endl;
    Base* q = new Base;
    long** vt_ptr = (long**)q;
    for (int i = 0; i < 3; i++) {
        printf("vptr[%d] : %p\n", i, *(*vt_ptr + i));
    }

    Derive* p = new Derive;
    vt_ptr = (long**)p;
    cout << "---------Derive------------" << endl;
    for (int i = 0; i < 4; i++) {
        printf("vptr[%d] : %p\n", i, *(*vt_ptr + i));
    }
    return 0;
}

控制台输出:

-----------Base------------
vptr[0] : 0x55d6eed5ad46
vptr[1] : 0x55d6eed5ad7e
vptr[2] : 0x55d6eed5adb6
---------Derive------------
vptr[0] : 0x55d6eed5ad46
vptr[1] : 0x55d6eed5adee
vptr[2] : 0x55d6eed5adb6
vptr[3] : 0x55d6eed5ae26

可见基类中的三个指针分别指向 a()b()c() 虚函数地址,而派生类中的三个指针中第一个和第三个和基类中的相同,那么这就印证了上述我们所假设的情况,那么这也就是虚函数表。

多重继承的虚函数表

我们看看多重继承,也就是 Derive 类继承两个基类,先看一下代码:

#include 
using namespace std;

class Base1 {
public:
    virtual void a() { cout << "Base1 a()" << endl; }
    virtual void b() { cout << "Base1 b()" << endl; }
};

class Base2 {
public:
    virtual void c() { cout << "Base2 c()" << endl; }
    virtual void d() { cout << "Base2 d()" << endl; }
};

class Derive : public Base1, public Base2 {
public:
    virtual void a() { cout << "Derive a()" << endl; } // 覆盖Base1::a()
    virtual void c() { cout << "Derive c()" << endl; } // 覆盖Base2::c()
    virtual void e() { cout << "Derive e()" << endl; }
}; 

首先我们明确一个概念,对于多重继承的派生类来说,它含有多个虚函数指针

对于上述代码而言,Derive 含有两个虚函数表指针。
首先我们先来看看这个多重继承的虚函数表示意图。

我们就用代码来实际的验证一下是否会存在两个虚函数指针,以及如果存在两个虚函数表,那么虚函数表是不是这个样子的。

来看下面的代码:

int main()
{
    typedef void (*Func)();
    Derive d;
    cout << "Derive对象所占的内存大小为:" << sizeof(d) << endl;

    cout << "\n---------第一个虚函数表-------------" << endl;
    // 获取第一个虚函数表的指针
    Func** vptr1 = (Func **)&d;
    for (int i = 0; i < 4; i++) {
        (*(*vptr1 + i))();
    }

    cout << "\n---------第二个虚函数表-------------" << endl;
    printf("vptr1 = %p\n", vptr1);
    printf("vptr1+1 = %p\n", vptr1+1);

    // 获取第二个虚函数表指针 相当于跳过 8 个字节
    Func** vptr2 = vptr1 + 1;
    for (int i = 0; i < 2; ++ i) {
        (*(*vptr2 + i))();
    }

    return 0;
}

控制台输出:

Derive对象所占的内存大小为:16

---------第一个虚函数表-------------
Derive a()
Base1 b()
Derive c()
Derive e()

---------第二个虚函数表-------------
vptr1 = 0x7ffd81ea7080
vptr1+1 = 0x7ffd81ea7088
Derive c()
Base2 d()

因为在包含一个虚函数表的时候,含有一个虚函数表指针,所占用的大小为 8 个字节,这里输出了 16 个字节,就说明 Derive 对象含有两个虚函数表指针。
然后我们通过获取到了这两个虚函数表,并调用其对应的虚函数,可以发现输出的结果和上面的示例图是相同的。
因此就证明了上述所说的结论是正确的。

虚函数表总结

简单的总结一下:

  • 1)每一个基类都会有自己的虚函数表,派生类的虚函数表的数量根据继承的基类的数量来定。
  • 2)派生类的虚函数表的顺序,和继承时的顺序相同。
  • 3)派生类自己的虚函数放在第一个虚函数表的后面,顺序也是和定义时顺序相同。
  • 4)对于派生类如果要覆盖父类中的虚函数,那么会在虚函数表中代替其位置。

通过 CLion 查看虚函数相关信息

1)在 CMakeLists.txt 中新增如下配置项。

# 指定 c++ 编译器为 g++
set (CMAKE_CXX_COMPILER /usr/bin/g++)
# -fdump-class-hierarchy 选项,可以用于输出C++程序的虚表结构(在当前目录下生成一个.class文件)
set(CMAKE_CXX_FLAGS -fdump-class-hierarchy)

2)编译上面的多继承代码。


点击【Reload CMake Project】

3)在构建目录可以找到 -fdump-class-hierarchy 选项输出的 .class 文件。

4)查看 main.cpp.002t.class 文件,虚函数表内容如下所示:

Vtable for Derive
Derive::_ZTV6Derive: 10u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI6Derive)
16    (int (*)(...))Derive::a
24    (int (*)(...))Base1::b
32    (int (*)(...))Derive::c
40    (int (*)(...))Derive::e
48    (int (*)(...))-8
56    (int (*)(...))(& _ZTI6Derive)
64    (int (*)(...))Derive::_ZThn8_N6Derive1cEv
72    (int (*)(...))Base2::d

Class Derive
   size=16 align=8
   base size=16 base align=8
Derive (0x0x7f050f6d64d0) 0
    vptr=((& Derive::_ZTV6Derive) + 16u)
  Base1 (0x0x7f050f693600) 0 nearly-empty
      primary-for Derive (0x0x7f050f6d64d0)
  Base2 (0x0x7f050f693660) 8 nearly-empty
      vptr=((& Derive::_ZTV6Derive) + 64u)

这样我们能够大概的看到虚函数表在内存中的布局信息, 美中不足的是这个文件中显示的名字已经是被编译器 mangle 过的, 我们需要用 c++filt 这个工具 demangle 之后显示的信息才会更清晰。

我们可以在命令行键入 cat mem_model.cc.002t.class | c++filt, 现在显示的就是一些更加清晰的信息:

Vtable for Derive
Derive::vtable for Derive: 10u entries
0     (int (*)(...))0
8     (int (*)(...))(& typeinfo for Derive)
16    (int (*)(...))Derive::a
24    (int (*)(...))Base1::b
32    (int (*)(...))Derive::c
40    (int (*)(...))Derive::e
48    (int (*)(...))-8
56    (int (*)(...))(& typeinfo for Derive)
64    (int (*)(...))Derive::non-virtual thunk to Derive::c()
72    (int (*)(...))Base2::d

Class Derive
   size=16 align=8
   base size=16 base align=8
Derive (0x0x7f050f6d64d0) 0
    vptr=((& Derive::vtable for Derive) + 16u)
  Base1 (0x0x7f050f693600) 0 nearly-empty
      primary-for Derive (0x0x7f050f6d64d0)
  Base2 (0x0x7f050f693660) 8 nearly-empty
      vptr=((& Derive::vtable for Derive) + 64u)

Class Derive 有两个虚函数指针。

  • 第一个虚函数指针是 vptr=((& Derive::vtable for Derive) + 16u),其指向 16 (int (*)(...))Derive::a 位置。
  • 第二个虚函数指针是 vptr=((& Derive::vtable for Derive) + 64u),其指向 64 (int (*)(...))Derive::non-virtual thunk to Derive::c() 位置。

【Thunk 解释】

所谓 thunk 是一小段 assembly代码,用来:

  • (1)以适当的 offset 值调整 this 指针。
  • (2)跳到 virtual function 去。

例如,经由一个 Base2 指针调用 Derived::c(),其相关的 thunk 可能看起来是下面这个样子:

this += sizeof( base1 ); // sizeof(base1) = 8
Derived::c( this );

测试不同类型的首地址:

int main()
{
    Derive d;
    printf("&d = %p\n", &d);
    printf("(Base1*) &d = %p\n", (Base1*) &d);
    printf("(Base2*) &d = %p\n", (Base2*) &d);

    return 0;
}

控制台输出:

&d = 0x7ffe34e2eb90
(Base1*) &d = 0x7ffe34e2eb90
(Base2*) &d = 0x7ffe34e2eb98

(Base2*) &d 的首地址和 Derive 的首地址的 offset = 8,和上文中的 Base2 (0x0x7f050f693660) 8 nearly-empty 中的 8 匹配。

参考

  • C++ 继承 - 菜鸟教程
  • C++ 多态
  • C++多态到底是什么(通俗易懂)
  • cmake之指定clang(++)编译器为默认编译器
  • C/C++杂记:虚函数的实现的基本原理
  • C/C++杂记:深入虚表结构
  • C++虚函数表深入探索(详细全面)
  • 虚函数 - 百度百科
  • C++ 之 多态(非常非常重要,重点在后面)
  • C++对象在64位机器上的内存布局
  • 浅析C++类的内存布局

你可能感兴趣的:(C++ 多态实现之虚函数表)