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

时间:2012-07-07 18:32:40

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

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

  1. 文中最后一句话究竟是什么意思?
  2. 为什么B::B中的最后一个方法调用未定义?不要只是打电话给a.A::f
  3.   

    可以调用4个成员函数,包括虚函数(10.3)   在施工或毁坏期间(12.6.2)。当一个虚函数   从构造函数或从构造函数直接或间接调用   析构函数,包括在构造或破坏期间   class的非静态数据成员,以及调用的对象   适用于建造或销毁的对象(称之为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
    }
    
         

    -end example]

3 个答案:

答案 0 :(得分:19)

标准的那一部分只是告诉你,当你构建一些"大"对象J,其基类层次结构包含多个继承,并且您当前位于某个基础子对象H的构造函数内,那么您只能使用H的多态性及其直接和间接基础子对象。您不能在该子层次结构之外使用任何多态性。

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

enter image description here

让我们说我们正在构建一个大型的" J类型的对象。我们目前正在执行类H的构造函数。在H的构造函数内部,您可以享受红色椭圆内部子层次结构的典型构造函数限制多态。例如,您可以调用类型为B的基础子对象的虚函数,并且多态行为将在带圆圈的子层次结构中按预期工作("正如所期望的那样#34;意味着多态行为将会降低在层次结构中为H,但不低于)。您还可以调用AEX以及属于红色椭圆内的其他子对象的虚拟函数。

但是,如果您以某种方式访问​​ 之外的层次结构并尝试在其中使用多态,则行为将变为未定义。例如,如果您以某种方式从G的构造函数访问H子对象并尝试调用G的虚函数 - 则行为未定义。关于从D的构造函数调用IH的虚函数也可以这么说。

获得此类访问权限的唯一途径是"外部"子层次结构是指某人以某种方式将G子对象的指针/引用传递给H的构造函数。因此,引用"显式类成员访问"在标准文本中(尽管似乎过多)。

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

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


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

看看标准中的示例。班级A派生自班级V。这意味着A的VMT指针实际上属于V子对象。 V引入的所有虚拟函数调用都是通过V引入的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中。为了使构造函数限制的多态性正常工作,编译器会将指向B的VMT的指针放入存储在V中的VMT指针中(因为虽然B&# 39; s构造函数处于活动状态V子对象必须作为B)的一部分。

如果此时你以某种方式试图打电话

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

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

通过实际实验验证这一点非常简单。我们将自己的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

这恰好是出于我上面描述的原因(大多数编译器以我上面描述的方式实现多态)。这就是语言将此类调用描述为未定义的原因。

有人可能会注意到,在这个特定示例中,实际上是虚拟继承会导致这种异常行为。是的,这恰好是因为V子对象在AB子对象之间共享。很有可能没有虚拟继承,行为就会更加可预测。但是,语言规范显然决定只绘制在我的图表中绘制的方式:当你构建H时,你不能走出&#34;沙盒&#34; H的子层次结构,无论使用什么继承类型。

答案 1 :(得分:1)

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

  

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

诚然,这是相当复杂的。这句话的存在是为了限制在存在多重继承的情况下可以在构造期间调用哪些函数。

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

调用B构造函数时,x 的完整对象的类型D。在该构造函数中,a是指向A x基类子对象的指针。因此,我们可以说以下a->f()

  • 正在构建的对象B对象的D基类子对象(因为这个基类子对象是当前正在构建的对象,它是文本引用的内容为x)。

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

  • x 的完整对象的类型为D,因为这是正在构建的派生程度最高的类型

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

  • 对象表达式引用的基类子对象不是x,并且不是x的基类子对象:ABA不是B的基类。

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

  

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

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

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

答案 2 :(得分:0)

以下是我对此的理解:在构造对象期间,每个子对象构造其部分。在示例中,这意味着V::V()初始化V的成员; A初始化A的成员,依此类推。由于VAB之前初始化,因此他们都可以依赖V的成员进行初始化。

在示例中,B的构造函数接受两个指向自身的指针。其V部分已经构建,因此可以安全地调用v->g()。但是,此时D的{​​{1}}部分尚未初始化。因此,调用A访问未初始化的内存,这是未定义的行为。

修改

在上面a->f()中,DA之前初始化,因此无法访问B未初始化的内存。另一方面,一旦A被完全构造,其虚函数被A的那些覆盖(实际上:它的vtable在构造期间被设置为D,并且A一旦施工结束了)。因此,在D初始化之前,对a->f()的调用将调用D::f()。因此,无论哪种方式 - D都是在A之前或之后构建的 - 您将在未初始化的对象上调用方法。

此处已讨论虚函数部分,但为了完整性:对B的调用使用f(),因为V::f尚未初始化,并且{{1}关心的是,这是A的唯一实现。 B调用f,因为g()会覆盖B::g