【问题标题】:understanding vptr in multiple inheritance?了解多重继承中的vptr?
【发布时间】:2011-08-06 18:35:35
【问题描述】:

我试图理解书中有效 c++ 中的陈述。下面是多重继承的继承图。

现在书上说 vptr 需要每个类中的单独内存。它还提出以下声明

上图中的一个奇怪之处在于,即使涉及四个类,也只有三个 vptr。 实现可以随意生成四个 vptr,但三个就足够了(反过来B 和 D 可以共享一个 vptr),并且大多数实现都利用这个机会来减少编译器生成的开销。

我看不出有什么理由在每个类中为 vptr 需要单独的内存。我知道 vptr 是从基类继承的,无论继承类型是什么。如果我们假设它显示了继承的 vptr 的结果内存结构,他们怎么能做出这样的声明

B和D可以共享一个vptr

有人可以澄清一下多重继承中的 vptr 吗?

  • 我们需要在每个类中单独使用 vptr 吗?
  • 如果上述情况属实,为什么 B 和 D 可以共享 vptr?

【问题讨论】:

  • 除非您是编译器编写者,否则这对于使用该语言并不重要或相关。事实上,标准中没有关于 vptr 或如何使用它们(或如何使用它们)的任何内容。这是因为这是一个实现细节(编译器甚至可能不使用 vptr)。所以如何做到这一点对于每个编译器来说都是非常具体的,这里所说的一切都只是猜测(除非答案来自编译器作者),即使这样,答案也将特定于特定编译器的特定版本。
  • @Eelke:不,因为虚拟继承A在这种情况下处理得非常特别。
  • @curiousguy:我没有研究过 clang 或 VS,自从我研究 gcc 以来已经 10 年了。但即使在那时 gcc 使用的技术也超出了您所描述的 CS101 技术。但我确信他们现在都已经超越了这一点。我花了一段时间才找到,但您要查找的代码是:来自 gcc 4.7.1 的源代码:gcc/cp/class.c 函数 build_primary_vtable()build_secondary_vtable() 所有优化都从那里悬垂。
  • 附言。我很高兴你最终回溯并回到我最初提出的观点(在第一篇文章中)。现在是时候阅读有关该主题的书了,这样您就可以真正了解下一次要做什么了。
  • @curiousguy:很抱歉您没有阅读这本书,因此不了解可能的优化。我提供了一本书的链接,以便您可以实际学习以及 gcc 中进行优化的“确切代码”;我不确定我还能做什么。如果您不理解代码,我无能为力。

标签: c++ multiple-inheritance vtable virtual-inheritance vptr


【解决方案1】:

您的问题很有趣,但是我担心您作为第一个问题的目标太大,所以如果您不介意,我将分几个步骤回答:)

免责声明:我不是编译器作者,虽然我确实研究过这个主题,但我的话应该谨慎行事。我会有不准确的地方。而且我对 RTTI 不是很精通。另外,由于这不是标准的,所以我描述的是可能性。

1.如何实现继承?

注意:我将忽略对齐问题,它们只是意味着块之间可以包含一些填充

现在让我们先把它放在虚方法之外,然后集中讨论继承是如何实现的,如下所示。

事实上,继承和组合有很多共同点:

struct B { int t; int u; };
struct C { B b; int v; int w; };
struct D: B { int v; int w; };

看起来像:

B:
+-----+-----+
|  t  |  u  |
+-----+-----+

C:
+-----+-----+-----+-----+
|     B     |  v  |  w  |
+-----+-----+-----+-----+

D:
+-----+-----+-----+-----+
|     B     |  v  |  w  |
+-----+-----+-----+-----+

是不是很震惊:)?

然而,这意味着多重继承很容易弄清楚:

struct A { int r; int s; };
struct M: A, B { int v; int w; };

M:
+-----+-----+-----+-----+-----+-----+
|     A     |     B     |  v  |  w  |
+-----+-----+-----+-----+-----+-----+

使用这些图,让我们看看将派生指针转换为基指针时会发生什么:

M* pm = new M();
A* pa = pm; // points to the A subpart of M
B* pb = pm; // points to the B subpart of M

使用我们之前的图表:

M:
+-----+-----+-----+-----+-----+-----+
|     A     |     B     |  v  |  w  |
+-----+-----+-----+-----+-----+-----+
^           ^
pm          pb
pa

pb 的地址与pm 的地址略有不同这一事实由编译器通过指针算法自动为您处理。

2。如何实现虚拟继承?

虚拟继承很棘手:您需要确保单个V(用于虚拟)对象将被所有其他子对象共享。让我们定义一个简单的菱形继承。

struct V { int t; };
struct B: virtual V { int u; };
struct C: virtual V { int v; };
struct D: B, C { int w; };

我将省略表示,并专注于确保在 D 对象中,BC 子部分共享相同的子对象。怎么办?

  1. 请记住,类大小应该是恒定的
  2. 请记住,在设计时,B 和 C 都无法预见它们是否会一起使用

因此找到的解决方案很简单:BC 只为指向V 的指针保留空间,并且:

  • 如果你构建一个独立的B,构造函数会在堆上分配一个V,会自动处理
  • 如果您将B 构建为D 的一部分,B 子部分将期望D 构造函数将指针传递给V 的位置

显然,C 同上。

D 中,优化允许构造函数在对象中为V 保留空间,因为D 不会从BC 虚拟继承,如图所示(虽然我们还没有虚拟方法)。

B:  (and C is similar)
+-----+-----+
|  V* |  u  |
+-----+-----+

D:
+-----+-----+-----+-----+-----+-----+
|     B     |     C     |  w  |  A  |
+-----+-----+-----+-----+-----+-----+

现在请注意,从 B 转换为 A 比简单的指针运算稍微复杂一些:您需要跟随 B 中的指针而不是简单的指针运算。

还有一个更糟糕的情况,向上转换。如果我给你一个指向A 的指针,你怎么知道如何回到B

在这种情况下,魔术是由dynamic_cast 执行的,但这需要在某处存储一些支持(即信息)。这就是所谓的RTTI(运行时类型信息)。 dynamic_cast 将首先通过某种魔法确定AD 的一部分,然后查询D 的运行时信息以了解DB 子对象的存储位置。

如果我们在没有B 子对象的情况下,它将返回 0(指针形式)或抛出 bad_cast 异常(参考形式)。

3.如何实现虚方法?

一般来说,虚拟方法是通过每个类的 v-table(即,指向函数的指针的表)和每个对象的 v-ptr 来实现的。这不是唯一可能的实现,并且已经证明其他实现可能更快,但是它既简单又具有可预测的开销(在内存和调度速度方面)。

如果我们采用一个简单的基类对象,带有一个虚方法:

struct B { virtual foo(); };

对于计算机来说,没有成员方法之类的东西,所以实际上你有:

struct B { VTable* vptr; };

void Bfoo(B* b);

struct BVTable { RTTI* rtti; void (*foo)(B*); };

当您从B 派生时:

struct D: B { virtual foo(); virtual bar(); };

你现在有两个虚拟方法,一个覆盖B::foo,另一个是全新的。计算机表示类似于:

struct D { VTable* vptr; }; // single table, even for two methods

void Dfoo(D* d); void Dbar(D* d);

struct DVTable { RTTI* rtti; void (*foo)(D*); void (*foo)(B*); };

注意BVTableDVTable 为何如此相似(因为我们将foo 放在bar 之前)?这很重要!

D* d = /**/;
B* b = d; // noop, no needfor arithmetic

b->foo();

让我们把调用翻译成机器语言foo(有点):

// 1. get the vptr
void* vptr = b; // noop, it's stored at the first byte of B

// 2. get the pointer to foo function
void (*foo)(B*) = vptr[1]; // 0 is for RTTI

// 3. apply foo
(*foo)(b);

那些vptr是由对象的构造函数初始化的,在执行D的构造函数时,发生了这样的事情:

  • D::D() 首先调用 B::B() 来初始化其子部分
  • B::B() 初始化vptr 指向它的vtable,然后返回
  • D::D() 初始化 vptr 指向它的 vtable,覆盖 B 的

因此,这里的vptr 指向D 的vtable,因此应用的foo 是D 的。对于B,它是完全透明的。

这里 B 和 D 共享同一个 vptr!

4.多继承中的虚拟表

不幸的是,这种共享并非总是可行的。

首先,正如我们所见,在虚拟继承的情况下,“共享”项在最终完整对象中的位置很奇怪。因此它有自己的 vptr。那是 1

其次,在多继承的情况下,第一个碱基与完整对象对齐,但第二个碱基不能对齐(它们都需要空间来存储数据),因此它不能共享它的 vptr。那是 2

第三,第一个基础与整个对象对齐,从而为我们提供与简单继承情况下相同的布局(相同的优化机会)。那是 3

很简单,不是吗?

【讨论】:

  • 他们的数据都需要空间”你忽略了他们没有数据(几乎是空的)的情况!
  • @curiousguy:确实,通过空基优化,第一个基可以被优化(在布局中),从而允许第二个基与派生对象共享其 v 指针。虽然这是一个极端情况,所以我会保持原样:)
  • @MatthieuM。还有一个更特殊的情况:几乎为空的基类优化(= 该类只有一个 vptr)。如果两个类具有完全相同的 vtable 布局,即它们具有完全相同的虚函数以相同的顺序(因此它们的 vtable,写为struct,将是布局兼容的),它们可以称为“虚拟布局兼容”。如果 1) 两个类 B1B2 几乎是空的(= 没有成员子对象),2) 是“虚拟布局兼容”
  • (...) 和 3) 如果 D 派生自 B1B2 对于每个 (B1, B2) 虚函数 (virtual函数没有标识(与普通函数不同,您无法获取虚拟调用函数的地址),因此它们是相等的定义),那么B1B2 是“实际上等价的”;两个“实际上等价的”子对象是不可区分的,一个可以“省略”(或者两者都可以混合,或“混淆”)。所以他们可以使用相同的vptr。 ;)
  • @curiousguy:实际上,我在想B1B2abstract 类(因此只指定了一个接口,没有实现), 和 有一个是另一个的未声明子集(就接口而言)。我确实认为这是一个极端情况,优化它是不值得的。
【解决方案2】:

如果一个类有虚拟成员,则需要找到它们的地址。这些被收集在一个常量表(vtbl)中,其地址存储在每个对象的隐藏字段(vptr)中。对虚拟成员的调用本质上是:

obj->_vptr[member_idx](obj, params...);

将虚拟成员添加到其基类的派生类也需要放置它们。因此为他们提供了一个新的 vtbl 和一个新的 vptr。对继承的虚拟成员的调用仍然是

obj->_vptr[member_idx](obj, params...);

对新虚拟成员的调用是:

obj->_vptr2[member_idx](obj, params...);

如果基础不是虚拟的,可以安排第二个 vtbl 紧跟在第一个 vtbl 之后,从而有效地增加 vtbl 的大小。 _vptr2 不再需要。因此,对新虚拟成员的调用是:

obj->_vptr[member_idx+num_inherited_members](obj, params...);

在(非虚拟)多重继承的情况下,一个继承两个vtbl和两个vptr。它们不能合并,调用必须注意为对象添加偏移量(以便在正确的位置找到继承的数据成员)。对第一个基类成员的调用将是

obj->_vptr_base1[member_idx](obj, params...);

第二次

obj->_vptr_base2[member_idx](obj+offset, params...);

新的虚拟成员可以再次放入新的 vtbl 中,或者附加到第一个 base 的 vtbl 中(这样以后的调用中就不会添加偏移量)。

如果基础是虚拟的,则不能将新的 vtbl 附加到继承的 vtbl 上,因为这可能会导致冲突(在您给出的示例中,如果 B 和 C 都附加了它们的虚拟函数,那么 D 如何能够构建它的版本?)。

因此,A 需要一个 vtbl。 B 和 C 需要一个 vtbl,它不能附加到 A 的一个,因为 A 是两者的虚拟基础。 D 需要一个 vtbl 但它可以附加到 B one 因为 B 不是 D 的虚拟基类。

【讨论】:

    【解决方案3】:

    这一切都与编译器如何计算出方法函数的实际地址有关。编译器假定虚拟表指针位于距对象基址的已知偏移量处(通常位于偏移量 0)。编译器还需要知道每个类的虚拟表的结构——换句话说,如何在虚拟表中查找指向函数的指针。

    B 类和 C 类将具有完全不同的虚拟表结构,因为它们具有不同的方法。 D 类的虚拟表可以看起来像 B 类的虚拟表,后跟 C 类方法的附加数据。

    当你生成一个 D 类的对象时,你可以将它转换为指向 B 的指针或指向 C 的指针,甚至是指向 A 类的指针。你可以将这些指针传递给甚至不知道存在的模块D 类的,但可以调用 B 类或 C 类或 A 类的方法。这些模块需要知道如何定位到类的虚拟表的指针,它们需要知道如何定位到类 B/C/ 的方法的指针A 在虚拟表中。这就是为什么您需要为每个类设置单独的 VPTR。

    D类很清楚B类的存在及其虚拟表的结构,因此可以扩展其结构并重用对象B的VPTR。

    当你将指向对象 D 的指针转换为指向对象 B 或 C 或 A 的指针时,它实际上会将指针更新一些偏移量,以便它从对应于该特定基类的 vptr 开始。

    【讨论】:

      【解决方案4】:

      我看不出有什么原因 是否需要单独的内存 vptr的每个类

      在运行时,当您通过指针调用(虚拟)方法时,CPU 不知道分配该方法的实际对象。如果您有B* b = ...; b->some_method();,则变量 b 可能指向通过new B()new D() 创建的对象或 甚至new E(),其中E 是继承自BD 的其他类。这些类中的每一个都可以为some_method() 提供自己的实现(覆盖)。因此,调用b->some_method() 应该从BDE 分派实现,具体取决于b 指向的对象。

      对象的 vptr 允许 CPU 找到对该对象有效的 some_method 的实现地址。每个类都定义了自己的 vtbl(包含所有虚拟方法的地址),并且该类的每个对象都以指向该 vtbl 的 vptr 开头。

      【讨论】:

        【解决方案5】:

        我认为 D 需要 2 或 3 个 vptr。

        这里 A 可能需要也可能不需要 vptr。 B 需要一个不应该与 A 共享的(因为 A 实际上是继承的)。 C 需要一个不应该与 A 共享的(同上)。 D 可以将 B 或 C 的 vftable 用于其新的虚函数(如果有),因此它可以共享 B 或 C。

        我的旧论文“C++: Under the Hood”解释了虚拟基类的 Microsoft C++ 实现。 http://www.openrce.org/articles/files/jangrayhood.pdf

        并且(MS C++)您可以使用 cl /d1reportAllClassLayout 进行编译以获得类内存布局的文本报告。

        黑客愉快!

        【讨论】:

        • 如果您需要vptr 来定位虚拟基础子对象,您不能使用虚拟基础子对象vptr 作为您的vptr。
        猜你喜欢
        • 2016-07-05
        • 1970-01-01
        • 2014-06-23
        • 1970-01-01
        • 2012-06-10
        • 2014-05-16
        • 1970-01-01
        • 2020-04-14
        相关资源
        最近更新 更多