【问题标题】:C++ constructors: why is this virtual function call not safe?C++ 构造函数:为什么这个虚函数调用不安全?
【发布时间】:2012-07-07 18:32:40
【问题描述】:

这是来自 C++11 标准 sec 12.7.4。这很令人困惑。

  1. 文中最后一句究竟是什么意思?
  2. 为什么B::B 中的最后一个方法调用未定义?不应该直接叫a.A::f吗?

可以调用4个成员函数,包括虚函数(10.3) 在建造或销毁期间 (12.6.2)。当一个虚函数 从构造函数或从 析构函数,包括在构造或销毁 类的非静态数据成员,以及调用的对象 apply 是正在构造或销毁的对象(称为 x), 调用的函数是构造函数中的最终覆盖器或 析构函数的类,而不是在派生更多的类中覆盖它。 如果虚函数调用使用显式类成员访问 (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
}

——结束示例]

【问题讨论】:

  • 不,不是那个问题。这是一个不同的。虚函数问题是次要的。
  • @Harvey:这不是一个骗局。这个问题超出了您所引用的范围。
  • @Thomas 对此感到抱歉。我正在想办法撤消关闭。
  • @Thomas 请接受我的道歉。看起来没有办法撤消关闭。 meta.stackexchange.com/questions/915/… 但他们表示将在几天内淡出退出投票

标签: c++ c++11 constructor virtual-functions virtual-inheritance


【解决方案1】:

标准的那部分只是告诉你,当你正在构造一些“大”对象J,其基类层次结构包括多重继承,并且你当前坐在一些基本子对象H的构造函数中,那么您只能使用 H 的多态性及其直接和间接基础子对象。不允许在该子层次结构之外使用任何多态性。

例如,考虑这个继承图(从派生类指向基类的箭头)

假设我们正在构造一个J 类型的“大”对象。而我们目前正在执行类H的构造函数。在H 的构造函数内,您可以享受红色椭圆内子层次结构的典型构造函数限制多态性。例如,您可以调用B 类型的基本子对象的虚函数,多态行为将在圆圈子层次结构内按预期工作(“如预期”意味着多态行为将在层次结构中低至H ,但不低于)。还可以调用AEX等红色椭圆内的子对象的虚函数。

但是,如果您以某种方式访问​​椭圆形外部 的层次结构并尝试在那里使用多态性,则行为将变得未定义。例如,如果您以某种方式从H 的构造函数中获得了对G 子对象的访问权,并尝试调用G 的虚函数- 行为未定义。从H的构造函数调用DI的虚函数也是如此。

获得对“外部”子层次结构的这种访问的唯一方法是,如果有人以某种方式将指向G 子对象的指针/引用传递给H 的构造函数。因此,标准文本中对“显式类成员访问”的引用(尽管似乎过度)。

标准在示例中包含虚拟继承,以展示此规则的包容性。在上图中,基本子对象X 由椭圆形内部的子层次结构和椭圆形外部的子层次结构共享。标准说可以从H的构造函数中调用X子对象的虚函数。

请注意,即使DGI 子对象的构造在H 的构造开始之前已经完成,此限制也适用。


本规范的根源导致了实现多态机制的实际考虑。在实际实现中,VMT 指针作为数据字段被引入到层次结构中最基本的多态类的对象布局中。派生类不引入自己的 VMT 指针,它们只是为基类(可能还有更长的 VMT)引入的指针提供自己特定的

看看标准中的例子。类A 派生自类V。这意味着A的VMT指针物理上属于V子对象。所有V引入的虚函数调用都是通过V引入的VMT指针调度的。 IE。无论何时打电话

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 中。为了使构造函数限制的多态性正常工作,编译器会将指向B 的VMT 的指针放入存储在V 中的VMT 指针中(因为当B 的构造函数处于活动状态时V 子对象必须作为B 的一部分)。

如果此时你以某种方式尝试调用

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

上述算法将找到存储在其V 子对象中的B 的VMT 指针,并将尝试通过该VMT 调用f()。这显然毫无意义。 IE。通过B 的VMT 调度A 的虚拟方法是没有意义的。行为未定义。

这很容易通过实际实验来验证。让我们将自己的f 版本添加到B 并执行此操作

#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!同时,this指针值B::f在这样的调用中接收到的完全是假的。

http://ideone.com/Ua332

这正是由于我上面描述的原因而发生的(大多数编译器都按照我上面描述的方式实现多态性)。这就是语言将此类调用描述为未定义的原因。

人们可能会注意到,在这个特定示例中,实际上是 virtual 继承导致了这种不寻常的行为。是的,这正是因为V 子对象在AB 子对象之间共享。如果没有虚拟继承,行为很可能会更加可预测。但是,语言规范显然决定按照我的图表中绘制的方式画线:当您构建 H 时,无论是什么,都不允许走出 H 的子层次结构的“沙箱”使用继承类型。

【讨论】:

  • "派生类不引入自己的 VMT 指针" 并非总是如此,但它们经常需要引入自己的 vptr。
  • 如果pointer_to_A-&gt;f(); 是按照您的建议按照公共基类子对象实现的,为什么它不起作用?我敢打赌。
【解决方案2】:

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

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

诚然,这相当令人费解。这句话的存在是为了限制在存在多重继承的情况下,在构造过程中可以调用哪些函数。

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

当调用B构造函数时,x的完整对象的类型D。在该构造函数中,a 是指向xA 基类子对象的指针。所以,对于a-&gt;f(),我们可以这样说:

  • 正在构建的对象D对象的B基类子对象(因为这个基类子对象是当前正在构建的对象,所以是文中所指以x)。

  • 它使用显式类成员访问(在本例中通过-&gt; 运算符)

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

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

  • 对象表达式引用的基类子对象不是x,也不是x的基类子对象:A不是B和@ 987654352@ 不是B 的基类。

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

为什么B::B 中的最后一个方法调用未定义?不应该直接调用a.A::f吗?

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

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

【讨论】:

  • 因此,为了使这一点有意义,编译器必须以某种方式考虑到&amp; a == this。否则,如果我们将 B::B(V *, A*) 作为独立类(而不是 D 的子类)调用,则对 a-&gt;f() 的调用将是明确定义的,不是吗?
  • 如果A* 指向一些完全不相关的A 对象,该对象已经完全构造,那么行为将是明确定义的。编译器不需要考虑任何事情:它可以假设 A* 指向一个完全构造的 A 对象,否则行为是未定义的,在这种情况下编译器的行为是不受约束的。
  • 我不确定是不是这个问题。 A 的一部分 D 的问题是它的 vtable 现在指向 D 的 vtable。所以D的虚函数会在D被初始化之前被调用。这在使用B 的vtable 时不是问题,因为在构建过程中它仍然是原始B 的。 (是的,我知道,vtables 不是标准等的一部分。但这是解释 IMO 问题的最简单方法)。
  • @eran:A 的 vtable 没有指向 D 的 vtable。事实上A 根本没有自己的 vtable 指针。它从V 继承其 vtable 指针。 A 每次需要访问 vtable 时都会转到 VV 中的指针最终将指向 D 的 vtable,但这只会在 所有 基类构造函数完成时发生。当B 的构造函数工作时,该指针实际上指向B 的vtable(因为BA 共享相同的V)。我的回答有一个实际的例子可以证实这一点。
  • @eran: 如果VA 的非虚拟基础,那么A vtable(存储在AV 实例中的vtable 指针)可能指向A 的vtable(还不是D,而是A)。但在虚拟继承的情况下(即当VAB 共享时),情况会有所不同。
【解决方案3】:

我是这样理解的:在一个对象的构建过程中,每个子对象都构建了它的一部分。例子中,表示V::V()初始化V的成员; A 初始化A 的成员,以此类推。由于VAB之前初始化,它们都可以依赖V的成员来初始化。

在示例中,B 的构造函数接受两个指向自身的指针。它的V 部分已经构建好了,所以调用v-&gt;g() 是安全的。但是,此时DA 部分尚未初始化。因此,调用a-&gt;f() 访问的是未初始化的内存,这是未定义的行为。

编辑:

在上面的D中,AB之前被初始化,所以不会有任何访问A的未初始化内存。另一方面,一旦A 完全构造完成,它的虚函数就会被D 的虚函数覆盖(实际上:它的vtable 在构造过程中被设置为A,并且一次被设置为D施工结束)。因此,对a-&gt;f() 的调用将在D 初始化之前调用D::f()。所以无论哪种方式——A 是在 B 之前或之后构造的——你都会调用一个未初始化对象的方法。

这里已经讨论了虚函数部分,但是为了完整起见:对f() 的调用使用V::f,因为A 尚未初始化,而就B 而言,这是唯一的实现fg() 调用B::g 因为B 覆盖g

【讨论】:

  • 你确定A的虚函数在D的构造函数被调用之前被D的覆盖了吗?
  • @ThomasMcLeod,引用了您对标准的引用:“在构造或销毁中,调用的函数是构造函数或析构函数类中的最终覆盖器,而不是在派生更多的类中覆盖它”。一旦构造了A,这将不再适用,它的虚函数可能会被更多派生类D 覆盖。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-04-29
  • 2010-11-06
  • 2013-04-25
  • 1970-01-01
  • 1970-01-01
  • 2013-10-01
相关资源
最近更新 更多