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

15

考虑以下层次结构:

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; }
};
  1. C 实例的精确的内存布局是什么?它包含多少个 vptrs,每个 vptrs 究竟放在哪里?哪些虚表与 C 的虚表共享?每个虚表具体包含什么?

    这里是我对布局的理解:

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

    AptrOfBx 是指向 A 实例的指针,该实例被包含在 Bx 中(由于继承是虚拟的)。
    这个说法正确吗?vptr1 指向哪些函数?vptr2 指向哪些函数?

  2. 给定以下代码:

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

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


这是作业还是好奇心? - templatetypedef
其实这是一场考试。但我相信,如果最终理解了这个例子中的东西,我就能够理解任何与多重继承和虚拟继承相关的内容。 - JeB
1
你可以通过以下方式轻松找到每个父子对象的起始位置:C foo; intptr_t offsetB1 = (intptr_t)(B1*)&foo - (intptr_t)&foo;,其他基类的起始位置也可以类似地推导出来。此外,计算所有类的 sizeof 应该能给你另一个很好的线索。 - cmaster - reinstate monica
2个回答

19
虚拟基类与普通基类非常不同。请记住,“虚拟”意味着“在运行时确定”,因此整个“基类子对象”必须在运行时确定。
想象一下,您正在获取一个“B&x”引用,并且您的任务是找到“A :: a”成员。如果继承是真实的,则“B”具有超类“A”,因此您通过“x”查看的“B”对象具有其中可以定位成员“A :: a”的“A”子对象。如果“x”的最派生对象具有多个类型为“A”的基础,则您只能看到该特定副本,该副本是“B”的子对象。
但如果继承是虚拟的,则所有这些都没有意义。我们不知道我们需要哪个“A”子对象-这些信息在编译时根本不存在。我们可能正在处理实际的“B”对象,例如“B y; B&x = y;”,或者正在处理类似于“C z; B&x = z;”的“C”对象,或者从“A”虚拟派生出更多次的其他内容。唯一的方法是在运行时找到实际的基础“A”。
这可以使用一个更高级别的运行时间接实现。(请注意,这完全类似于如何使用比非虚拟函数多一个运行时间接层来实现虚拟函数。)不是具有指向vtable或基类子对象的指针,而是存储指向实际基类子对象的指针的指针。这有时称为“thunk”或“trampoline”。
因此,实际对象“C z;”可能如下所示。内存中的实际排序由编译器决定并且不重要,我已经抑制了vtables。
+-+------++-+------++-----++-----+
|T|  B1  ||T|  B2  ||  C  ||  A  |
+-+------++-+------++-----++-----+
 |         |                 |
 V         V                 ^
 |         |       +-Thunk-+ |
 +--->>----+-->>---|     ->>-+
                   +-------+

因此,无论您拥有 B1& 还是 B2&,您首先要查找thunk,然后thunk会告诉您在哪里找到实际的基类子对象。这也解释了为什么您不能从 A& 执行静态转换到任何派生类型:在编译时根本不存在这些信息。
如需更详细的说明,请参阅这篇精彩文章。(在该描述中,thunk是 C 的vtable的一部分,并且虚继承始终需要维护vtable,即使没有任何虚函数。)

感谢你的精彩回答。据我所知,thunk是虚表的一部分。也就是说,如果您不需要偏移量来获取对象函数正在运行的内容,则不需要thunk。如果您需要偏移量,则在虚表的适当字段中有一个指向thunk的指针,其中包含偏移量和实际函数的指针。 因此,我对我的示例中的虚表的外观很感兴趣。也就是说,它们指向哪些函数,以及通过哪些thunk指向哪些函数。 - JeB
此外,我非常惊讶所有的转换(静态、动态、重新解释)都将我转到一个特定的函数C::f。这很奇怪。请问您能否解释一下,在这个例子中每个转换是如何工作的? 此外,我已经阅读了许多关于这个问题的文章,而您提供的链接文章是我读过的第一篇文章之一。但它仍然不能帮助我理解这里发生了什么。 - JeB
@user1544364 "所有的强制转换 () 都将我转到一个特定的函数"。不,这些强制转换返回一个对象指针,而不是一个函数。 - curiousguy
@user1544364 "_thunk,其中包含偏移量和指向实际函数的指针。" 不是的,thunk 不包含数据,它由可执行代码组成。Thunk 只是一个经过优化的函数。 - curiousguy
“the fine article”的链接已经失效,但我找到了备份:https://cs.nyu.edu/courses/fall16/CSCI-UA.0470-001/slides/MemoryLayoutMultipleInheritance.pdf - tuket

4
我稍微修改了你的代码,具体如下:

#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指针都不同,这使得程序能够推断出所有虚基类的正确位置。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接