多重虚拟继承中的虚拟表和内存布局

Posted

技术标签:

【中文标题】多重虚拟继承中的虚拟表和内存布局【英文标题】:Virtual tables and memory layout in multiple virtual inheritance 【发布时间】:2012-07-22 19:07:48 【问题描述】:

考虑以下层次结构:

struct A 
   int a; 
   A()  f(0); 
   A(int i)  f(i); 
   virtual void f(int i)  cout << i; 
;
struct B1 : virtual A 
   int b1;
   B1(int i) : A(i)  f(i); 
   virtual void f(int i)  cout << i+10; 
;
struct B2 : virtual A 
   int b2;
   B2(int i) : A(i)  f(i); 
   virtual void f(int i)  cout << i+20; 
;
struct C : B1, virtual B2 
   int c;
   C() : B1(6),B2(3),A(1)
   virtual void f(int i)  cout << i+30; 
;

    C 实例的确切内存布局是什么?它包含多少个 vptr,每个 vptr 的确切放置位置?哪些虚拟表与 C 的虚拟表共享?每个虚拟表究竟包含什么?

    我是如何理解布局的:

    ----------------------------------------------------------------
    |vptr1 | AptrOfB1 | b1 | B2ptr | c | vptr2 | AptrOfB2 | b2 | a |
    ----------------------------------------------------------------
    

    其中AptrOfBx 是指向Bx 包含的A 实例的指针(因为继承是虚拟的)。 那是对的吗? vptr1 指向哪些函数? vptr2 指向哪些函数?

    给定以下代码

    C* c = new C();
    dynamic_cast<B1*>(c)->f(3);
    static_cast<B2*>(c)->f(3);
    reinterpret_cast<B2*>(c)->f(3);
    

    为什么所有对f 的调用都打印33

【问题讨论】:

这是作业,还是好奇? 其实这是考试。但我敢肯定,如果我最终理解了这个例子中的事情是如何工作的,我就能理解与多重继承和虚拟继承相关的任何事情。 您可以像这样轻松找出每个父子对象的起点:C foo; intptr_t offsetB1 = (intptr_t)(B1*)&amp;foo - (intptr_t)&amp;foo;,其他基础的起点可以类似推导出。此外,计算所有类的sizeof 应该会给你另一个很好的线索。 【参考方案1】:

虚拟基地与普通基地有很大不同。请记住,“虚拟”意味着“在运行时确定”——因此整个基础子对象必须在运行时确定。

假设您正在获得B &amp; x 引用,并且您的任务是查找A::a 成员。如果继承是真实的,那么B 有一个超类A,因此您通过x 查看的B-object 有一个A-subobject,您可以在其中找到您的成员A::a .如果x 的最派生对象有多个A 类型的基,那么您只能看到作为B 子对象的特定副本。

但是如果继承是虚拟的,那么这一切都没有意义。我们不知道我们需要哪个A-subobject——这些信息在编译时根本不存在。我们可以处理像B y; B &amp; x = y; 中的实际B-object,或者像C z; B &amp; x = z; 这样的C-object,或者完全不同的东西,实际上是从A 派生的更多次。唯一知道的方法是在运行时找到实际的基础A

这可以通过多一层运行时间接来实现。 (请注意,与非虚拟函数相比,这与虚拟 函数 是如何通过额外的运行时间接实现完全平行的。)一种解决方案不是使用指向 vtable 或基本子对象的指针,而是将指针 存储到指向实际基础子对象的指针。这有时被称为“thunk”或“trampoline”。

所以实际对象C z; 可能如下所示。内存中的实际排序取决于编译器并且不重要,并且我已经抑制了 vtables。

+-+------++-+------++-----++-----+
|T|  B1  ||T|  B2  ||  C  ||  A  |
+-+------++-+------++-----++-----+
 |         |                 |
 V         V                 ^
 |         |       +-Thunk-+ |
 +--->>----+-->>---|     ->>-+
                   +-------+

因此,无论您有B1&amp; 还是B2&amp;,您首先查找thunk,然后它会告诉您在哪里可以找到实际的基础子对象。这也解释了为什么不能从 A&amp; 执行静态转换到任何派生类型:此信息在编译时根本不存在。

如需更深入的解释,请查看this fine article。 (在那个描述中,thunk 是 C 的 vtable 的一部分,并且虚拟继承总是需要维护 vtable,即使任何地方都没有虚拟 函数。)

【讨论】:

感谢您的出色回答。据我所知,thunk 是虚拟表的一部分。 IE。如果您不需要偏移量来获取对象功能正在运行,则不需要 thunk。如果您需要偏移量,那么在 vtable 的相应字段中有一个指向 thunk 的指针,其中包含偏移量和指向实际函数的指针。所以我很想知道,在我的示例中,vtables 的外观如何。 IE。它们指向哪些函数,哪些函数通过 thunk 指向。 同样,我很惊讶所有转换(静态、动态、重新解释)都将我转向了一个特定的函数 C::f。这很奇怪。你能解释一下(在这个例子中)他们每个人是如何工作的吗?另外,我读过很多关于这个的文章,你链接的文章是我读过的第一篇文章。它仍然无法帮助我理解这里发生了什么。 @user1544364 "所有类型转换 () 将我转到一个特定的函数" 不。这些类型转换返回一个对象指针,而不是一个函数。 @user1544364 "thunk,包含偏移量和指向实际函数的指针。" 不。thunk 不包含数据,thunk 由可执行代码组成。 thunk 只是一个优化的函数。 “精品文章”的链接坏了,但我找到了备份:cs.nyu.edu/courses/fall16/CSCI-UA.0470-001/slides/…【参考方案2】:

我已经把你的代码拉了一点如下:

#include <stdio.h>
#include <stdint.h>

struct A 
   int a; 
   A() : a(32)  f(0); 
   A(int i) : a(32)  f(i); 
   virtual void f(int i)  printf("%d\n", i); 
;

struct B1 : virtual A 
   int b1;
   B1(int i) : A(i), b1(33)  f(i); 
   virtual void f(int i)  printf("%d\n", i+10); 
;

struct B2 : virtual A 
   int b2;
   B2(int i) : A(i), b2(34)  f(i); 
   virtual void f(int i)  printf("%d\n", i+20); 
;

struct C : B1, virtual B2 
   int c;
   C() : B1(6),B2(3),A(1), c(35) 
   virtual void f(int i)  printf("%d\n", i+30); 
;

int main() 
    C foo;
    intptr_t address = (intptr_t)&foo;
    printf("offset A = %ld, sizeof A = %ld\n", (intptr_t)(A*)&foo - address, sizeof(A));
    printf("offset B1 = %ld, sizeof B1 = %ld\n", (intptr_t)(B1*)&foo - address, sizeof(B1));
    printf("offset B2 = %ld, sizeof B2 = %ld\n", (intptr_t)(B2*)&foo - address, sizeof(B2));
    printf("offset C = %ld, sizeof C = %ld\n", (intptr_t)(C*)&foo - address, sizeof(C));
    unsigned char* data = (unsigned char*)address;
    for(int offset = 0; offset < sizeof(C); offset++) 
        if(!(offset & 7)) printf("| ");
        printf("%02x ", (int)data[offset]);
    
    printf("\n");

如您所见,这会打印出相当多的附加信息,使我们能够推断出内存布局。我机器上的输出(64 位 linux,小端字节序)是这样的:

1
23
16
offset A = 16, sizeof A = 16
offset B1 = 0, sizeof B1 = 32
offset B2 = 32, sizeof B2 = 32
offset C = 0, sizeof C = 48
| 00 0d 40 00 00 00 00 00 | 21 00 00 00 23 00 00 00 | 20 0d 40 00 00 00 00 00 | 20 00 00 00 00 00 00 00 | 48 0d 40 00 00 00 00 00 | 22 00 00 00 00 00 00 00 

所以,我们可以这样描述布局:

+--------+----+----+--------+----+----+--------+----+----+
|  vptr  | b1 | c  |  vptr  | a  | xx |  vptr  | b2 | xx |
+--------+----+----+--------+----+----+--------+----+----+

这里,xx 表示填充。请注意编译器如何将变量 c 放入其非虚拟基的填充中。另请注意,所有三个 v 指针都是不同的,这允许程序推断出所有虚拟碱基的正确位置。

【讨论】:

以上是关于多重虚拟继承中的虚拟表和内存布局的主要内容,如果未能解决你的问题,请参考以下文章

关于C++中的虚拟继承的一些总结

C++ 虚拟继承内存布局

C++ 对象的内存布局

C++ 对象的内存布局

C++ 对象的内存布局

C++ 对象的内存布局