19

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

在此处输入图像描述

在此处输入图像描述

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

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

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

B和D可以共享一个vptr

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

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

5 回答 5

29

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

免责声明:我不是编译器作者,虽然我确实研究过这个主题,但我的话应该谨慎行事。我会有不准确的地方。而且我对 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对象中,theBCsubparts 共享相同的子对象。怎么做到呢 ?

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

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

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

C显然,同上。

D中,优化允许构造函数在对象中为Vright 保留空间,因为D不虚拟继承自Bor C,给出您显示的图表(尽管我们还没有虚拟方法)。

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

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

现在请注意,从Bto转换A比简单的指针算术稍微棘手:您需要跟随指针B而不是简单的指针算术。

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

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

如果我们在没有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(); };

您现在有两种虚拟方法,一种是 overrides 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

很简单,不是吗?

于 2011-04-16T11:26:58.973 回答
1

如果一个类有虚拟成员,则需要找到它们的地址。这些被收集在一个常量表(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 的虚拟基类。

于 2011-04-16T05:54:22.083 回答
0

这一切都与编译器如何计算出方法函数的实际地址有关。编译器假定虚拟表指针位于距对象基址的已知偏移量处(通常位于偏移量 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 开始。

于 2011-04-16T05:54:32.117 回答
0

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

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

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

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

快乐黑客!

于 2011-06-27T22:01:07.100 回答
0

我看不出为什么在每个类中都需要单独的内存用于 vptr

在运行时,当您通过指针调用(虚拟)方法时,CPU 不知道在其上调度该方法的实际对象。如果你有,B* b = ...; b->some_method();那么变量 b 可以潜在地指向通过new B()或通过创建的对象new D(),甚至是从(或者)或继承的某个其他类的new E()位置。这些类中的每一个都可以为. 因此,调用应该从或根据 b 指向的对象调度实现。EBDsome_method()b->some_method()BDE

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

于 2011-04-16T06:00:02.903 回答