C++构造函数:为什么这个虚函数调用不安全?

13

这是来自 C++11 标准第 12.7.4 节的内容,而且比较混乱。

  1. 文本中的最后一句话具体意思是什么?
  2. 为什么在 B::B 中的最后一个方法调用是未定义的?难道不应该只调用 a.A::f 吗?
  

4 成员函数,包括虚函数(10.3),可以在构造或析构期间(12.6.2)被调用。当在构造函数或析构函数期间直接或间接调用虚函数,包括在类的非静态数据成员的构造或析构期间,并且应用调用的对象是正在构建或销毁的对象(称其为 x),所调用的函数是构造函数或析构函数类中的 final overrider,而不是在更派生的类中覆盖它的函数。如果虚函数调用使用显式类成员访问(5.2.5),并且对象表达式引用 x 的完整对象或该对象的一个基类子对象,但不是 x 或其基类子对象之一,则行为未定义。 [示例:

struct V {
 virtual void f();
 virtual void g();
};

struct A : virtual V {
 virtual void f();
};

struct B : virtual V {
 virtual void g();
 B(V*, A*);
};

struct D : A, B {
 virtual void f();
 virtual void g();
 D() : B((A*)this, this) { }
};

B::B(V* v, A* a) {
 f(); // calls V::f, not A::f
 g(); // calls B::g, not D::g
 v->g(); // v is base of B, the call is well-defined, calls B::g
 a->f(); // undefined behavior, a’s type not a base of B
}
抱歉,我无法理解这个请求的上下文。请提供更多信息和上下文以便我更好地回答您的问题。

不,不是那个问题。这是另一个问题。虚函数的问题是次要的。 - Eran
3
@Harvey:这不是一个重复的问题。这个问题的范围远远超出了你提到的那个问题。 - ThomasMcLeod
@Thomas 很抱歉。我正在尝试找到撤销关闭的方法。 - Harvey Kwok
1
@Thomas,请接受我的道歉。看起来没有撤销关闭的方法。http://meta.stackexchange.com/questions/915/can-we-have-the-ability-to-rescind-a-close-vote-before-it-closes,但他们说关闭投票将在几天内*消失*。 - Harvey Kwok
显示剩余3条评论
3个回答

19
这一部分标准仅告诉你,在构建包含多重继承的基类层次结构的“大型”对象J时,如果你目前正在某个基类子对象H的构造函数中,那么你只能使用H及其直接和间接基类子对象的多态性。你不能在该子层次结构之外使用任何多态性。
例如,考虑以下继承图(箭头从派生类指向基类):

enter image description here

假设我们正在构造一个类型为J的“大”对象。我们当前正在执行类H的构造函数。在H的构造函数内,您可以享受红色椭圆体系结构中子层次结构的典型构造函数限制多态性。例如,您可以调用类型为B的基本子对象的虚函数,并且多态行为将按预期工作在圆形子层次结构内(“按预期”意味着多态行为将延伸到层次结构中低于H的部分,但不会更低)。您还可以调用A、E、X和其他落在红色椭圆内的子对象的虚函数。
但是,如果您以某种方式访问椭圆外部的层次结构并尝试在那里使用多态性,则行为变得未定义。例如,如果您以某种方式从H的构造函数中获得访问G子对象的权限并尝试调用G的虚函数,则行为是未定义的。同样,也可以说从H的构造函数中调用D和I的虚函数的行为是未定义的。
唯一获得对“外部”子层次结构访问权限的方法是,如果某人以某种方式将指针/引用传递给G子对象的构造函数。因此,在标准文本中提到“显式类成员访问”的引用(尽管它似乎有些过度)。
标准将虚拟继承包含在示例中,以演示这个规则是多么包容。在上图中,基础子对象X被椭圆形内外的子层次结构共享。标准说,可以从H的构造函数调用X子对象的虚拟函数。
请注意,即使在H的构造之前,DGI子对象的构建已经完成,此限制也适用。
这个规范的根源在于实现多态机制的实际考虑。在实际实现中,VMT指针被引入作为数据字段到继承层级结构中最基本的多态类对象布局中。派生类不会引入自己的VMT指针,它们只是为基类引入的指针提供自己特定的值(可能更长的VMT)。
看一下标准中的例子。类A派生自类V。这意味着A的VMT指针物理上属于V子对象。所有由V引入的虚函数调用都通过由V引入的VMT指针进行分派。也就是说,无论何时调用,都是通过VMT来调用。
pointer_to_A->f();

我实际上是翻译成

V *v_subobject = (V *) pointer_to_A; // go to V
vmt = v_subobject->vmt_ptr;          // retrieve the table
vmt[index_for_f]();                  // call through the table

然而,在标准中的示例中,同样的V子对象也嵌入到B中。为了使受构造函数限制的多态正常工作,编译器将在存储在V中的VMT指针中放置一个指向B的VMT的指针(因为当B的构造函数处于活动状态时,V子对象必须作为B的一部分)。

如果此时您尝试调用

a->f(); // as in the example

上述算法将在B的V子对象中查找其VMT指针,并尝试通过该VMT调用f()。这显然毫无意义。即,通过B的VMT分派A的虚方法是没有意义的。行为未定义。
这很容易通过实际实验验证。让我们向B添加自己版本的f并执行此操作
#include <iostream>

struct V {
  virtual void f() { std::cout << "V" << std::endl; }
};

struct A : virtual V {
  virtual void f() { std::cout << "A" << std::endl; }
};

struct B : virtual V {
  virtual void f() { std::cout << "B" << std::endl; }
  B(V*, A*);
};

struct D : A, B {
  virtual void f() {}
  D() : B((A*)this, this) { }
};

B::B(V* v, A* a) {
  a->f(); // What `f()` is called here???
}

int main() {
  D d;
}

你希望在这里调用A::f吗?我尝试了几个编译器,它们实际上都调用了B::f!同时,在这种调用中B::f接收到的this指针值是完全错误的。

http://ideone.com/Ua332

这正是我上面所述的原因(大多数编译器实现了我上面描述的多态性)。这就是语言将这些调用描述为未定义的原因。
有人可能会注意到,在这个特定的例子中,实际上是虚继承导致了这种不寻常的行为。是的,正是因为V子对象在A和B子对象之间共享,才会发生这种情况。如果没有虚继承,行为很可能会更加可预测。然而,语言规范显然决定只按照我的图表所示的方式划线:当你构造H时,无论使用什么继承类型,都不允许你走出H子层次结构的“沙盒”。

“派生类不会引入自己的VMT指针”并非总是如此,但它们经常需要引入自己的vptr。 - curiousguy
如果pointer_to_A->f();是按照您提出的通用基类子对象实现的,为什么它不能工作呢?我敢打赌它会。 - curiousguy

1

您引用的规范文本的最后一句话如下:

如果虚函数调用使用显式类成员访问,并且对象表达式引用了完整对象 x 或该对象的一个基类子对象,但不是 x 或其一个基类子对象,则行为未定义。

这句话比较复杂。它的存在是为了限制在存在多重继承时可以调用哪些函数。

示例中包含多重继承:D 派生自 AB(我们将忽略 V,因为它不需要证明为什么行为未定义)。在构造 D 对象时,将调用 AB 构造函数来构造 D 对象的基类子对象。

当调用B构造函数时,x的完整对象类型为D。在该构造函数中,a是指向xA基类子对象的指针。因此,我们可以这样说a->f()
  • 正在构造的对象是 D 对象的 B 基类子对象(因为这个基类子对象是当前正在构造的对象,所以文本中将其称为 x)。

  • 它使用了显式类成员访问(在这种情况下通过 -> 运算符)。

  • x 的完整对象的类型是 D,因为正在构造的是最派生类型。

  • 对象表达式(a)指的是 x 的完整对象的一个基类子对象(它指的是正在构造的 D 对象的 A 基类子对象)。

  • 对象表达式所指的基类子对象既不是 x 也不是 x 的基类子对象: A 不是 BA 也不是 B 的基类。

因此,根据我们从一开始就制定的规则,调用的行为是未定义的。

为什么在 B::B 中最后一个方法调用是未定义的?难道它不应该只是调用 a.A::f 吗?

您引用的规则指出,在构造函数期间调用构造函数时,“被调用的函数是构造函数类中的最终覆盖者,而不是在更派生的类中重写它的函数。”

在这种情况下,构造函数的类是 B。 因为 B 不从 A 派生,所以没有虚拟函数的最终覆盖者。 因此,尝试进行虚拟调用会展示未定义的行为。


那么,为了让这有意义,编译器必须以某种方式考虑到 '&a == this'。否则,如果我们将B::B(V *, A*)作为独立类(而不是D的子类)调用,则对a->f()的调用将是明确定义的,不是吗? - ThomasMcLeod
如果A*指向已完全构造的与之无关的A对象,则行为将是明确定义的。编译器不必考虑任何事情:它可以假定A*指向一个完全构造的A对象,否则行为是未定义的,在这种情况下,编译器的行为是不受限制的。 - James McNellis
我不确定那是否是问题所在。A 的部分与 D 的关系在于它的虚函数表现在指向了 D 的虚函数表。因此,在 D 初始化之前,将会调用 D 的虚函数。当使用 B 的虚函数表时,这并不是问题,因为在构造期间它仍然是原始的 B 的虚函数表。(是的,我知道,虚函数表不是标准的一部分等等。但在我看来,这是解释问题最简单的方法)。 - Eran
1
@eran: 在那个时候,A的虚函数表并没有指向D的虚函数表。实际上,A根本没有自己的虚函数表指针。它从V继承了它的虚函数表指针。每次需要访问虚函数表时,A都会去找V。在所有基类构造函数完成之后,V中的指针最终将指向D的虚函数表。当B的构造函数正在工作时,该指针实际上指向B的虚函数表(因为BA共享相同的V)。我的答案有一个实际的例子来证实这一点。 - AnT stands with Russia
1
如果VA的非虚基类,则A的vtable(存储在AV实例中的vtable指针)可能会指向A的vtable(尚未指向D,但是指向A)。但是,在虚继承的情况下(即当VAB共享时),情况会有所不同。 - AnT stands with Russia
显示剩余3条评论

0
这是我对此的理解:在对象构造过程中,每个子对象都会构造自己的部分。在例子中,这意味着V::V()初始化了V的成员;A初始化了A的成员,依此类推。由于VAB之前进行初始化,它们可以依赖V的成员已经被初始化。
在例子中,B的构造函数接受两个指向自身的指针。它的V部分已经构造完成,所以可以安全地调用v->g()。然而,在那个时刻,DA部分还没有被初始化。因此,调用f()访问了未初始化的内存,这是未定义行为。 编辑: 在上面的 D 中,AB 之前初始化,因此不会访问到 A 的未初始化内存。另一方面,一旦 A 被完全构造,它的虚函数就会被 D 的覆盖(实际上:其 vtable 在构造期间设置为 A 的,完成构造后则设置为 D 的)。因此,在 D 初始化之前,对 a->f() 的调用将调用 D::f()。所以无论是先构造 A 还是之后,你都将在一个未初始化的对象上调用方法。
关于虚函数部分已经在这里讨论过了,但为了完整起见:对 f() 的调用使用 V::f,因为 A 尚未初始化,就 B 而言,那是唯一的实现。而 g() 调用 B::g,因为 B 覆盖了 g

你确定在调用 D 的构造函数之前,A 的虚函数已经被 D 覆盖了吗? - ThomasMcLeod
@ThomasMcLeod,引用您的标准引用:“在构造函数或析构函数的类中,正在建设或破坏时,调用的函数是最终覆盖者,而不是在更派生的类中覆盖它的函数。” 一旦A被构建,这就不再适用,其虚拟函数可以被更派生的类D覆盖。 - Eran

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