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

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

虚函数表简介

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

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

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

虚函数表存在的位置

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

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

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

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

#include <iostream>
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 <iostream>
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 <iostream>
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 匹配。

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,588评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,456评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,146评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,387评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,481评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,510评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,522评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,296评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,745评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,039评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,202评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,901评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,538评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,165评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,415评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,081评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,085评论 2 352