虚函数表指针与虚函数表布局

考虑如下的 class:

class A {
 public:
  int a;
  virtual void f1() {}
  virtual void f2() {}
};

int main() {
    A *a1 = new A();
  return 0;
}

首先明确,sizeof(A)的输出是 16,因为:class A 中含有一个 int 是 4 字节,然后含有虚函数,所以必须含有一个指向 vtable 的 vptr,而 vptr 是 8 字节,8 + 4 = 12,对齐到 8 的边界,也就是 16

虚函数表指针 vptr

为了完成多态的功能,现代的 C++编译器都采用了表格驱动的对象模型,具体来说,所有虚函数的地址都存放在一个表格之中,而这个表格就被称为虚函数表vtable,这个虚函数表的地址被存在放类中,称为虚函数表指针vptr

使用 clang 导出上述 class A 的对象布局,有如下输出:

*** Dumping AST Record Layout
         0 | class A
         0 |   (A vtable pointer)
         8 |   int a
           | [sizeof=16, dsize=12, align=8,
           |  nvsize=12, nvalign=8]

可以看到,在 class A 的对象布局中,第一个就是 vptr(8 字节)

虚函数表 vtable

利用 clang 的导出虚函数表的功能,可以看到上述 class A 的虚函数表具体内容如下:

Vtable for 'A' (4 entries).
   0 | offset_to_top (0)
   1 | A RTTI
       -- (A, 0) vtable address --
   2 | void A::f1()
   3 | void A::f2()

VTable indices for 'A' (2 entries).
   0 | void A::f1()
   1 | void A::f2()

需要注意的是:-- (A, 0) vtable address -- 的意思是,class A 所产生的对象的 vptr 指向的就是这个地址,而这个地址以后的内容全都是虚函数的地址,也就是说 vptr 不是指向整个虚函数表的开头的(即 offset_to_top(0)处)

虚函数表中只有虚函数的地址吗?从上述输出来看,显然并不是

一个完整的 vtable,有以下内容(虚函数表中的内容被称为条目或者实体,另外并不是所有的条目都会出现,但是如果出现,一定是按照下面的顺序出现):

  1. virtual call (vcall) offsets:用来修正虚函数指针
  2. virtual base (vbase) offsets:用来访问某个对象的虚基,
  3. offset to top:记录了对象的这个虚函数表地址偏移到该对象顶部地址的偏移量
  4. typeinfo pointer:用于 RTTI
  5. vitual function pointers:一系列虚函数指针

单一继承下的虚函数表布局

下面讨论,单一继承情况下,虚函数表里面各种条目的具体情况,考虑如下代码:

class A {
 public:
  int a;
  virtual void f1() {}
  virtual void f2() {}
};

class B : public A {
 public:
  int b;
  void f1() override {}
};

int main() {
  A *a1 = new A();
  B *b1 = new B();
  return 0;
}

首先需要明确的是:sizeof(A)与 sizeof(B)的大小:

  1. sizeof(A):4 + 8 = 12,调整到 8 的边界,所以是 16
  2. sizeof(B):4 + 4 + 8 = 16,不需要进行边界对齐,所以也是 16

利用 clang 查看 class A 与 class B 的所产生的对象 a1 与 b1 的布局,有如下输出:

*** Dumping AST Record Layout
         0 | class A
         0 |   (A vtable pointer)
         8 |   int a
           | [sizeof=16, dsize=12, align=8,
           |  nvsize=12, nvalign=8]

*** Dumping AST Record Layout
         0 | class B
         0 |   class A (primary base)
         0 |     (A vtable pointer)
         8 |     int a
        12 |   int b
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

// 对于b1来说:在构造b1时,首先需要构造一个A父类对象,所以b1的布局最开始上半部分是一个A父类对象

利用 clang 查看 class A 与 class B 的虚函数表内容,有如下输出:

Vtable for 'A' (4 entries).
   0 | offset_to_top (0)
   1 | A RTTI
       -- (A, 0) vtable address --
   2 | void A::f1()
   3 | void A::f2()

VTable indices for 'A' (2 entries).
   0 | void A::f1()
   1 | void A::f2()

Vtable for 'B' (4 entries).
   0 | offset_to_top (0)
   1 | B RTTI
       -- (A, 0) vtable address --
       -- (B, 0) vtable address --
   2 | void B::f1()
   3 | void A::f2()

VTable indices for 'B' (1 entries).
   0 | void B::f1()

在 class B 的虚函数表内容中,有如下两条:

-- (A, 0) vtable address --
-- (B, 0) vtable address --

意思是:

  1. 如果以 A 类型的引用或者指针来看待 class B 的对象,那么此时的 vptr 指向的就是-- (A, 0) vtable address --
  2. 如果以 B 类型的引用或者指针来看待 class B 的对象,那么此时的 vptr 指向的就是-- (B, 0) vtable address --

虽然在上述里例子中,这两个地址是相同的,这也意味着单链继承的情况下,动态向下转换和向上转换时,不需要对 this 指针的地址做出任何修改,只需要对其重新“解释”

这里需要说明一下:指针或者引用的类型,真正的意义是影响编译器如何解释或者说编译器如何看待该指针或者引用指向的内存中的数据)

单一继承情况下的虚函数表所含条目也比较少,理解起来也很容易

多重继承情况下的虚函数表布局

l 考虑如下代码:

class A {
 public:
  int a;
  virtual void f1() {}
};

class B {
 public:
  int b;
  virtual void f2() {}
};

class C : public A, public B {
 public:
  int c;
  void f1() override {}
  void f2() override {}
};

int main() {
  A *a1 = new A();
  B *b1 = new B();
  C *c1 = new C();
    return 0;
}

首先,依然讨论一下 A,B,C 三个 class 的大小:

  1. sizeof(A):4 + 8 = 12,调整到 8 的边界,即 16
  2. sizeof(B):4 + 8 = 12,调整到 8 的边界,即 16
  3. sizeof(C):4 + 4 + 4 +8 + 8 = 28,调整到 8 的边界,即 32

这里有一个问题,为什么计算 C 的大小时,加了两次 8?因为这两个 8 是两个 vptr,那怎么 C 会有两根 vptr 呢,后面会进行解释,此处先不讨论

查看 class A、B、C 三个对象的布局,如下:

*** Dumping AST Record Layout
         0 | class A
         0 |   (A vtable pointer)
         8 |   int a
           | [sizeof=16, dsize=12, align=8,
           |  nvsize=12, nvalign=8]

*** Dumping AST Record Layout
         0 | class B
         0 |   (B vtable pointer)
         8 |   int b
           | [sizeof=16, dsize=12, align=8,
           |  nvsize=12, nvalign=8]

*** Dumping AST Record Layout
         0 | class C
         0 |   class A (primary base)
         0 |     (A vtable pointer)
         8 |     int a
        16 |   class B (base)
        16 |     (B vtable pointer)
        24 |     int b
        28 |   int c
           | [sizeof=32, dsize=32, align=8,
           |  nvsize=32, nvalign=8]

查看 class A、B、C 的虚函数表的所有条目:

Vtable for 'A' (3 entries).
   0 | offset_to_top (0)
   1 | A RTTI
       -- (A, 0) vtable address --
   2 | void A::f1()

VTable indices for 'A' (1 entries).
   0 | void A::f1()

Vtable for 'B' (3 entries).
   0 | offset_to_top (0)
   1 | B RTTI
       -- (B, 0) vtable address --
   2 | void B::f2()

VTable indices for 'B' (1 entries).
   0 | void B::f2()

Vtable for 'C' (7 entries).
   0 | offset_to_top (0)
   1 | C RTTI
       -- (A, 0) vtable address --
       -- (C, 0) vtable address --
   2 | void C::f1()
   3 | void C::f2()
   4 | offset_to_top (-16)
   5 | C RTTI
       -- (B, 16) vtable address --
   6 | void C::f2()
       [this adjustment: -16 non-virtual]

Thunks for 'void C::f2()' (1 entry).
   0 | this adjustment: -16 non-virtual

VTable indices for 'C' (2 entries).
   0 | void C::f1()
   1 | void C::f2()

此时可以看到,在多重继承下,虚函数表多出了许多单一继承没有的条目,接下来进行仔细讨论

1 为什么 C 的布局中有两个 vptr?

与单链继承不同,由于 A 和 B 完全独立,它们的虚函数没有顺序关系,即 f1 和 f2 有着相同对虚表起始位置的偏移量,所以不可以按照偏移量的顺序排布;并且 A 和 B 中的成员变量也是无关的,因此基类间也不具有包含关系;这使得 A 和 B 在 C 中必须要处于两个不相交的区域中,同时需要有两个虚指针分别对它们虚函数表索引

2 class C 对象的内存布局中 primary base 是何意义?

再次关注一下 class C 的对象的内存布局:

*** Dumping AST Record Layout
         0 | class C
         0 |   class A (primary base)
         0 |     (A vtable pointer)
         8 |     int a
        16 |   class B (base)
        16 |     (B vtable pointer)
        24 |     int b
        28 |   int c
           | [sizeof=32, dsize=32, align=8,
           |  nvsize=32, nvalign=8]

已经知道 class C 是 public 方式继承了 class A 与 class B,而 class A 被标记为primary base,其意义是:class C 将 class A 作为主基类,也就是将 class C 的虚函数并入class A 的虚函数表之中

3 多重继承情况下,class C 的虚函数表 vtable 的特点?

多重继承情况下,class C 的虚函数表内容如下:

Vtable for 'C' (7 entries).
   0 | offset_to_top (0)
   1 | C RTTI
       -- (A, 0) vtable address --
       -- (C, 0) vtable address --
   2 | void C::f1()
   3 | void C::f2()
   4 | offset_to_top (-16)
   5 | C RTTI
       -- (B, 16) vtable address --
   6 | void C::f2()
       [this adjustment: -16 non-virtual]

Thunks for 'void C::f2()' (1 entry).
   0 | this adjustment: -16 non-virtual

VTable indices for 'C' (2 entries).
   0 | void C::f1()
   1 | void C::f2()

可以看到,class C 的整个虚函数表其实是两个虚函数表拼接而成(这也就对应了 class C 为什么由两个 vptr)

一步步分析,先看上半部分的虚函数表:

   0 | offset_to_top (0)
   1 | C RTTI
       -- (A, 0) vtable address --
       -- (C, 0) vtable address --
   2 | void C::f1()
   3 | void C::f2()

前面已经提到过,class C 会把 class A 当作主基类,并把自己的虚函数并入到 class A 的虚函数表之中,所以,可以才会看到如上的内容

所以,class C 中的一根 vptr 会指向这个虚函数表

再看下半部分的虚函数表:

   4 | offset_to_top (-16)
   5 | C RTTI
       -- (B, 16) vtable address --
   6 | void C::f2()
       [this adjustment: -16 non-virtual]

Thunks for 'void C::f2()' (1 entry).
   0 | this adjustment: -16 non-virtual

注意,此时的 offset_to_top 中的偏移量已经是 16 了

之前说过,offset_to_top 的意义是:将对象从当前这个类型转换为该对象的实际类型的地址偏移量

在多继承中,以 class A、B、C 为例,class A 和 class B 以及 class C 类型的指针或者引用都可以指向 class C 类型的实例,比如:

C cc = new C();
B &bb = cc;

bb.f1(); // 我们知道,由于多态,此时实际调用的class C中的虚函数f1(),即相当于cc.f1()
// 回顾class C的对象的内存布局
// 当我们用 B类型的引用接收cc对象时,this指针相当于指在了`16 |   class B (base)`这个地方,要想实现多态,需要将this指针向上偏移16个字节,这样this指针才能指向cc对象的起始地址,编译器才能以C类型来解释cc这个对象而不会出错
*** Dumping AST Record Layout
         0 | class C
         0 |   class A (primary base)
         0 |     (A vtable pointer)
         8 |     int a
        16 |   class B (base)
        16 |     (B vtable pointer)
        24 |     int b
        28 |   int c
           | [sizeof=32, dsize=32, align=8,
           |  nvsize=32, nvalign=8]

在多继承中,由于不同的基类起点可能处于不同的位置,因此当需要将它们转化为实际类型时,this 指针的偏移量也不相同,且由于多态的特性,cc 的实际类型在编译时期是无法确定的;那必然需要一个东西帮助我们在运行时期确定 cc 的实际类型,这个东西就是offset_to_top。通过让this指针加上offset_to_top的偏移量,就可以让 this 指针指向实际类型的起始地址

class C 下半部分的虚函数表还有一个值得注意的地方:

6 | void C::f2()
       [this adjustment: -16 non-virtual]

Thunks for 'void C::f2()' (1 entry).
   0 | this adjustment: -16 non-virtual

意思是,当以 B 类型的指针或者引用接受了 class C 的对象并调用 f2 时:需要将 this 指针调整-16 个字节,然后再进行调用(这跟上面所说的一样,将 this 向上调整 16 个字节就是让 this 指向 class C 对象的起始地址,从而编译器会以 class C 这个类型来看待 this 指针),然后再调用 f2,也就确保了调用的是 class C 的虚函数表中自己的 f2

02-13 12:49