10

我对 vptr 和内存中对象的表示有点困惑,希望你能帮助我更好地理解这件事。

  1. 考虑B继承自A并且都定义虚函数f()。从我了解到的 B 类对象在内存中的表示形式如下所示:并且[ vptr | A | B ] 指向contains 。我也明白,将对象从to投射到除了忽略对象末尾的部分之外,什么也不做。这是真的吗?这种行为是不对的吗?我们希望该类型的对象执行method 而不是.vtblvptrB::f()BABAA::f()B::f()

  2. 系统中是否有一些vtables作为类的数量?

  3. vtable从两个或多个类继承的类的外观如何?C 的对象将如何在内存中表示?

  4. 与问题 3 相同,但具有虚拟继承。

4

3 回答 3

16

以下对于 GCC 是正确的(对于 LLVM链接似乎也是如此),但对于您正在使用的编译器也可能是正确的。所有这些都是依赖于实现的,并且不受 C++ 标准的约束。但是,GCC 编写了自己的二进制标准文档Itanium ABI

作为我关于 C++ 中的虚函数性能的文章的一部分,我试图用更简单的语言解释虚拟表如何布局的基本概念,您可能会发现这篇文章很有用。以下是您的问题的答案:

  1. 描述对象内部表示的更正确方法是:

    | vptr | ======= | ======= |  <-- your object
           |----A----|         |
           |---------B---------|
    

    B 包含它的基类 A,它只是在它结束后添加了几个他自己的成员。

    B*to 转换A*确实没有任何作用,它返回相同的指针,并且vptr保持不变。但是,简而言之,虚函数并不总是通过 vtable 调用。有时它们就像其他函数一样被调用。

    这里有更详细的解释。您应该区分两种调用成员函数的方式:

    A a, *aptr;
    a.func();         // the call to A::func() is precompiled!
    aptr->A::func();  // ditto
    aptr->func();     // calls virtual function through vtable.
                      // It may be a call to A::func() or B::func().
    

    问题是它在编译时知道如何调用该函数:通过 vtable 或者只是通常的调用。问题是转换表达式的类型在编译时是已知的,因此编译器会在编译时选择正确的函数。

    B b, *bptr;          
    static_cast<A>(b)::func(); //calls A::func, because the type
       // of static_cast<A>(b) is A!
    

    在这种情况下,它甚至看不到 vtable 内部!

  2. 一般来说,没有。如果一个类从多个基类继承,则可以有多个 vtable,每个基类都有自己的 vtable。这样的一组虚拟表形成了一个“虚拟表组”(参见第 3 部分)。

    类还需要一组构造 vtable,以便在构造复杂对象的基础时正确分配虚函数。您可以在我链接的标准中进一步阅读。

  3. 这是一个例子。假设C继承自Aand B,每个类定义virtual void func(), 以及a,bc与其名称相关的虚函数。

    C有一个包含两个 vtable 的 vtable 组。它将共享一个vtable A(当前类的自己的函数所在的vtable称为“primary”),并且B将附加一个vtable:

    | C::func()   |   a()  |  c()  || C::func()  |   b()   |
    |---- vtable for A ----|        |---- vtable for B ----| 
    |--- "primary virtual table" --||- "secondary vtable" -|
    |-------------- virtual table group for C -------------|
    

    内存中对象的表示看起来几乎与其 vtable 的样子相同。只需vptr在组中的每个 vtable 之前添加一个,您就可以粗略估计数据在对象内的布局方式。您可以在 GCC 二进制标准的相关部分阅读它。

  4. 虚拟基础(其中一些)布置在 vtable 组的末尾。这样做是因为每个类应该只有一个虚拟基础,并且如果它们与“通常”的 vtables 混合在一起,那么编译器就不能重用构造的 vtables 的一部分来制作派生类的部分。这将导致计算不必要的偏移并降低性能。

    由于这样的放置,虚拟基础还在它们的 vtables 中引入了额外的元素:vcall偏移量(当从指针跳转到完整对象内的虚拟基础到覆盖虚拟函数的类的开头时,获取最终覆盖器的地址)对于那里定义的每个虚拟功能。此外,每个虚拟基都添加了vbase偏移量,这些偏移量被插入到派生类的 vtable 中;它们允许找到虚拟基础数据的开始位置(它不能被预编译,因为实际地址取决于层次结构:虚拟基础位于对象的末尾,并且从开始的移位取决于有多少非虚拟当前类继承的类。)。

哇,我希望我没有引入太多不必要的复杂性。在任何情况下,您都可以参考原始标准,或您自己的编译器的任何文档。

于 2010-07-24T11:42:49.340 回答
2
  1. 这对我来说似乎是正确的。好像您使用的是 A 指针并没有错,您只需要 A 提供的内容以及可能从 A vtable 可用的 B 函数实现(可以有多个 vtable,具体取决于编译器和层次结构的复杂性)。
  2. 我会说是的,但它依赖于编译器实现,因此您不必真正了解它。
  3. 4. 读得更远。

我建议阅读Multiple Inheritance Considered Useful,这是一篇很长的文章,但它使主题更加清晰,因为它非常详细地解释了继承在 C++ 中的工作原理(数字链接不起作用,但它们在页面底部可用)。

于 2010-07-24T10:39:45.010 回答
-1
  1. 如果对象 B 从 A 继承,则 B 的内存表示形式如下:

    • 指向 A 的虚拟表的指针
    • 特定的变量/函数
    • 指向 B 的虚拟表的指针
    • B 特定变量/函数/覆盖

    如果你有 B* b = new B(); (A)b->f() 然后:

    • 如果 f 被声明为虚函数,则调用 B 的实现,因为 b 是 B 类型
    • 如果 f 没有被声明为虚函数,那么在调用时将不会在他的 vtable 中查找正确的实现,并且将调用 A 的实现。
  2. 每个对象都有自己的 vtable(不要认为这是理所当然的,因为我必须研究它

  3. 在处理多重继承时看一下这个vtable 布局的例子

  4. 有关菱形继承和 vtable 表示的讨论,请参见this

于 2010-07-24T11:50:13.460 回答