为什么在构造函数中调用虚成员函数是非虚函数调用?

325
假设我有两个C++类:
class A
{
public:
  A() { fn(); }

  virtual void fn() { _n = 1; }
  int getn() { return _n; }

protected:
  int _n;
};

class B : public A
{
public:
  B() : A() {}

  virtual void fn() { _n = 2; }
};

如果我写下以下代码:
int main()
{
  B b;
  int n = b.getn();
}

可能有人会期望n被设为2。
事实证明,n被设为1。为什么呢?

17
我自问自答是因为我希望将这个C++玄学问题的解释发布到Stack Overflow上。类似的问题在我们开发团队中出现了两次,所以我猜这些信息可能对其他人有用。如果您能用不同/更好的方式解释清楚,请写下您的答案... - David Coufal
9
我在想为什么这个被踩了?当我第一次学习C++时,这个真的让我很困惑。+1 - Zifre
4
让我惊讶的是缺少编译器警告。在构造函数中,编译器会将“当前构造函数类中定义的函数”替换为在派生类中被“最重载”的函数。如果编译器提示“用Base::foo()替换调用虚拟函数foo()”,程序员就会被警告代码将不会像他们预期的那样执行。这比默默地进行替换要有更多的帮助,因为默默地替换会导致神秘的行为、大量的调试,最终需要到stackoverflow上寻求答案。 - Craig Reynolds
3
@CraigReynolds 不一定。在构造函数内部进行虚函数调用时,不需要特殊的编译器处理。基类构造函数仅为当前类创建vtable,因此此时编译器可以像通常一样通过该vtable调用虚函数。但是,vtable尚未指向任何派生类中的任何函数。在基类构造函数返回后,派生类的构造函数将调整派生类的vtable,这就是派生类构造完成后覆盖工作的方式。 - user207421
15个回答

289
在构造函数或析构函数中调用虚函数是危险的,应尽可能避免。所有C++实现都应该在当前构造函数所在层级定义的版本中调用函数,不再进一步调用。

C++ FAQ Lite在第23.7节中详细介绍了此问题。建议阅读该文档(以及其他FAQ)进行跟进。

摘录:

[...] 在构造函数中,由于来自派生类的覆盖还没有发生,因此虚调用机制被禁用。对象从基类向上构造,“从基类到派生类”。

[...]

销毁时执行“从派生类到基类”的操作,因此虚函数的行为与构造函数中的行为相同:仅使用本地定义-不调用覆盖函数以避免触及(现已销毁的)对象的派生类部分。

编辑 更正为所有(感谢litb)


70
所有的C++实现都需要调用当前类的版本,而不是大多数C++实现,如果有一些不这样做,那么它们就有一个错误 :). 我仍然同意你的观点,从基类调用虚函数是不好的 - 但语义是明确定义的。 - Johannes Schaub - litb
27
不危险,只是非虚拟的。实际上,如果从构造函数中调用的方法被虚拟调用,那么这将是危险的,因为该方法可能访问未初始化的成员。 - Steven Sudit
6
为什么在析构函数中调用虚函数是危险的?难道当析构函数运行时对象不仍然完整,只有在析构函数完成后才被销毁吗? - Siyuan Ren
16
“是危险的”不准确,实际上只有在Java中才存在危险,因为可能会发生downcall;而C++规则通过一种相当昂贵的机制消除了这种危险。 - Cheers and hth. - Alf
17
从构造函数中调用虚函数有什么“危险”?这完全是无稽之谈。 - Lightness Races in Orbit
显示剩余15条评论

110

在大多数面向对象语言中,从构造函数中调用多态函数通常是灾难性的。不同的语言在遇到这种情况时表现不同。

基本问题在于,在所有语言中,派生类必须在基类之前构建。现在,问题是从构造函数中调用多态方法意味着什么。你期望它表现如何?有两种方法:在基类级别调用方法(C++风格)或在层次结构底部的未构造对象上调用多态方法(Java方式)。

在C++中,基类将在进入其自身构造之前构建其版本的虚方法表。此时对虚方法的调用将最终调用基类方法的版本,或者在该层次结构的该级别没有实现时产生纯虚方法调用。在基类完全构造后,编译器将开始构建派生类,并覆盖方法指针以指向层次结构下一级别的实现。

class Base {
public:
   Base() { f(); }
   virtual void f() { std::cout << "Base" << std::endl; } 
};
class Derived : public Base
{
public:
   Derived() : Base() {}
   virtual void f() { std::cout << "Derived" << std::endl; }
};
int main() {
   Derived d;
}
// outputs: "Base" as the vtable still points to Base::f() when Base::Base() is run
在Java中,编译器会在构造的第一步之前,即进入Base构造函数或Derived构造函数之前,建立等效的虚拟表。这样做会产生不同的影响(并且更加危险)。如果基类构造函数调用一个在派生类中被重写的方法,那么调用实际上将在派生级别处理,调用未构造对象上的一个方法,从而产生意外的结果。派生类中在构造函数块内初始化的所有属性都尚未初始化,包括'final'属性。在类级别定义了默认值的元素将具有该值。
public class Base {
   public Base() { polymorphic(); }
   public void polymorphic() { 
      System.out.println( "Base" );
   }
}
public class Derived extends Base
{
   final int x;
   public Derived( int value ) {
      x = value;
      polymorphic();
   }
   public void polymorphic() {
      System.out.println( "Derived: " + x ); 
   }
   public static void main( String args[] ) {
      Derived d = new Derived( 5 );
   }
}
// outputs: Derived 0
//          Derived 5
// ... so much for final attributes never changing :P

正如你所看到的,调用多态(C++术语中为虚拟)方法是常见的错误来源。在C++中,至少你可以保证它不会在尚未构造完成的对象上调用方法...


3
讲得很好,解释了为什么替代方案也容易出错。 - DS.
7
解释!+1,我认为这是一个优秀的回答。 - underscore_d
4
@VinGarcia 什么?在这种情况下,C++ 并没有“禁止”任何东西。该调用被简单地视为非虚拟调用,对当前执行构造函数的类的方法进行调用。这是对象构建时间线的逻辑结果,而不是某种严厉的限制你做蠢事的决定。它巧合地实现了后一目的只是对我来说的额外好处。 - underscore_d
3
@VinGarcia 只有那些在使用语言之前不费心学习它的人才会觉得这是“意外”的,他们只是假设自己的期望会反映现实而没有进行检查 - 在这种情况下,我没有同情心:按照定义,语言是你需要学习的东西,而不是“期望”。我认为,作为一项设计决策,它已经被清楚地记录并且非常合理。而且我不想使用另一种语言,尽管您没有解释为什么它是一个相关的反例。 - underscore_d
1
@underscore_d 它不被视为非虚拟调用。它与任何其他虚拟调用一样处理。关键是在构造函数中,vtable尚未指向任何派生类的重写方法,因此虚拟调用无法“看到”当前类范围之外的内容。 - user207421
显示剩余6条评论

81

之所以如此,是因为C++对象的构造方式类似于洋葱,从内而外逐层构建。在派生类之前需要先构造基类。因此,在创建B之前,必须先创建A。当调用A的构造函数时,它还没有成为B,因此虚函数表仍然具有A的fn()函数的条目。


19
C++通常不使用术语"super class",而更喜欢使用"base class"。 - anon
大多数面向对象的编程语言都是如此:你不可能在基础部分构建完成之前构建派生对象。 - David Rodríguez - dribeas
3
在 Pascal 等其他语言中,实际上也是这样做的。例如,在 Pascal 中,首先会为整个对象分配内存,但仅调用最派生的构造函数。构造函数必须包含对其父类构造函数的显式调用(不一定是第一个动作 - 它只需要在某处),如果没有,则好像构造函数的第一行进行了该调用。 - M.M
感谢您的清晰表述和避免冗长细节,直接阐明结果。 - user5193682
如果调用仍然使用vptr(由于vptr也设置为当前级别,正如您所提到的)方式或只是静态调用当前级别的版本。 - BAKE ZQ
当程序员不知道洋葱不是由建筑制造的时候 :DD - Ambrus Tóth

32

C++ FAQ Lite对此进行了详细的解释:

实际上,在调用基类的构造函数期间,对象尚未成为派生类型,因此调用基类类型的虚函数实现而不是派生类型的虚函数实现。


3
这仍然是我想要得到一些关注的功能。我讨厌不得不编写所有这些愚蠢的initializeObject()函数,用户在构造之后被迫立即调用,这对于非常普遍的用例来说很糟糕。我明白其中的困难。但这就是生活。 - moodboom
1
@moodboom 你提出的“爱”是什么?请记住,你不能直接更改当前的工作方式,因为那会破坏大量现有代码。那么,你会如何做呢?不仅要介绍新的语法来允许(实际的、非虚拟化的)构造函数中进行虚函数调用,还要修改对象构造/生命周期模型,以便这些调用可以在派生类型的完整对象上运行。这将是有趣的。 - underscore_d
@underscore_d 我刚注意到你下面的另一个评论,解释了在构造函数中vtable不可用,再次强调了这里的困难。 - moodboom
1
@moodboom 当我写关于构造函数中vtable不可用时,我犯了一个错误。它是可用的,但是构造函数只能看到其自己类的vtable,因为每个派生类的构造函数都会更新实例的vptr以指向当前派生类型的vtable,而不再往下。因此,当前构造函数只能看到具有其自己覆盖的vtable,这就是为什么它无法调用任何虚函数的更多派生实现的原因。 - underscore_d
@underscore_d 说得很有道理,谢谢。关于标准的更改,完全同意。任何标准的更改都必须考虑到庞大的现有代码库,这个概念可能会带来破坏性。 - moodboom
显示剩余3条评论

19
您的问题有一个解决方案,那就是使用工厂方法创建对象。
  • 为类层次结构定义一个共同的基类,其中包含一个虚方法afterConstruction():
class Object
{
public:
  virtual void afterConstruction() {}
  // ...
};
  • 定义一个工厂方法:
template< class C >
C* factoryNew()
{
  C* pObject = new C();
  pObject->afterConstruction();
return pObject; }
  • 在这样使用它:
class MyClass : public Object 
{
public:
  virtual void afterConstruction()
  {
    // do something.
  }
  // ...
};
MyClass* pMyObject = factoryNew();

1
模板函数需要指定类型 MyClass* pMyObject = factoryNew<MyClass>(); - Raj

3
正如所指出的那样,在构建时,对象是基于底部创建的。当正在构建基础对象时,派生对象尚不存在,因此虚函数重载无法起作用。
然而,如果您的getter返回常量或者可以表示为静态成员函数,则可以使用多态getter来解决这个问题,而不是使用虚函数,并使用静态多态性。该示例使用CRTP(奇异递归模板模式)。
template<typename DerivedClass>
class Base
{
public:
    inline Base() :
    foo(DerivedClass::getFoo())
    {}

    inline int fooSq() {
        return foo * foo;
    }

    const int foo;
};

class A : public Base<A>
{
public:
    inline static int getFoo() { return 1; }
};

class B : public Base<B>
{
public:
    inline static int getFoo() { return 2; }
};

class C : public Base<C>
{
public:
    inline static int getFoo() { return 3; }
};

int main()
{
    A a;
    B b;
    C c;

    std::cout << a.fooSq() << ", " << b.fooSq() << ", " << c.fooSq() << std::endl;

    return 0;
}

通过使用静态多态性,基类知道在编译时提供信息时调用哪个类的getter。

1
我认为我会避免这样做。这不再是单一的基类了。实际上,你创建了许多不同的基类。 - Wang
@王 具体而言,Base<T>仅是一个帮助类,而不是通用接口类型,不能用于运行时多态(例如异构容器)。这些也很有用,只是用途不同。一些类既继承了一个作为运行时多态接口类型的基类,又继承了另一个作为编译时模板助手的基类。 - curiousguy

3

C++标准(ISO/IEC 14882-2014)中说:

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

因为构造函数的顺序是从基类到派生类,而析构函数的顺序是从派生类到基类,所以不要在构造函数或析构函数中调用试图调用正在构建或销毁的对象的虚函数。因此,在基类构造函数中尝试调用派生类函数是很危险的。同样,对象的销毁顺序与构造相反,因此在析构函数中尝试调用更多派生类的函数可能会访问已经被释放的资源。

1

你知道Windows资源管理器的崩溃错误吗?!"Pure virtual function call ..."
同样的问题...

class AbstractClass 
{
public:
    AbstractClass( ){
        //if you call pureVitualFunction I will crash...
    }
    virtual void pureVitualFunction() = 0;
};

由于纯虚函数pureVitualFunction()没有实现,而且在构造函数中调用了该函数,所以程序将崩溃。


很难看出这是同一个问题,因为你没有解释原因。在构造函数期间调用非纯虚函数是完全合法的,但它们只是不通过(尚未构建的)虚拟表,因此执行的方法版本是在我们所在的类类型的构造函数中定义的那个版本。所以这些不会崩溃。这个会崩溃,因为它是纯虚函数且未实现(附注:基类中可以实现纯虚函数),所以不存在要调用的此类类型的该方法版本,并且编译器假定您不编写错误的代码,所以崩溃了。 - underscore_d
糟糕。调用确实通过虚函数表进行,但它尚未更新为指向最派生类的覆盖函数:仅指向当前正在构造的类。尽管如此,崩溃的结果和原因仍然相同。 - underscore_d
@underscore_d(旁注:可以在基类中实现纯虚函数) 不,你不能这样做,否则该方法就不再是_纯_虚函数了。你也不能创建抽象类的实例,因此如果您尝试从构造函数调用纯方法,则TimW的示例将无法编译。现在它可以编译,因为构造函数不调用纯虚方法并且不包含任何代码,只有一个注释。 - user2943111
@user2943111:是的,你可以。在C++中,纯虚函数意味着该类是抽象类,除非它覆盖了纯虚成员函数,否则每个派生类也都是抽象的。没有任何定义的虚成员函数必须是纯虚的,但反过来则不成立。 - Ben Voigt

1
编译器创建虚表。 类对象有一个指向其虚表的指针。当它开始生命时,该虚表指针指向基类的虚表。 在构造函数代码结束时,编译器生成重新指向虚表指针到类的实际虚表的代码。 这确保调用虚函数的构造函数代码调用这些函数的基类实现,而不是类中的重写。

1
在构造函数结束时,vptr没有被改变。在 C::C 构造函数体内,虚函数调用会使用 C 的重写版本,而不是任何基类的版本。 - curiousguy
对象的动态类型是在调用基类构造函数之后,构造成员之前定义的。因此,在构造函数结束时,vptr 不会改变。 - curiousguy
@curiousguy 我的意思和你一样,即在基类构造函数结束时vptr没有改变,它将在派生类构造函数结束时改变。我希望你也是这么说的。这是一个编译器/实现相关的问题。你认为vptr应该何时更改?有什么好的理由可以反对吗? - Yogesh
1
vptr 的更改时间不依赖于实现。它由语言语义规定:当类实例的动态行为发生变化时,vptr 会发生变化。这里没有自由。在 ctor T::T(params) 的主体内,动态类型是 T。vptr 将反映出这一点:它将指向 T 的 vtable。你不同意吗? - curiousguy
也许有一个继承的真实例子会更容易讨论。 - curiousguy
你可以像我上面所做的那样跟踪它的变化。 - Don Slowik

0
回答什么会发生或者为什么运行那段代码时会发生什么,我通过g++ -ggdb main.cc进行了编译,并使用gdb逐步调试。

main.cc:

class A { 
  public:
    A() {
      fn();
    }
    virtual void fn() { _n=1; }
    int getn() { return _n; }

  protected:
    int _n;
};


class B: public A {
  public:
    B() {
      // fn();
    }
    void fn() override {
      _n = 2;
    }
};


int main() {
  B b;
}

main 中设置断点,然后进入 B() 函数,在打印出 this 指针后,进入 A()(基类构造函数):

(gdb) step
B::B (this=0x7fffffffde80) at main2.cc:16
16    B() {
(gdb) p this
$27 = (B * const) 0x7fffffffde80
(gdb) p *this
$28 = {<A> = {_vptr.A = 0x7fffffffdf80, _n = 0}, <No data fields>}
(gdb) s
A::A (this=0x7fffffffde80) at main2.cc:3
3     A() {
(gdb) p this
$29 = (A * const) 0x7fffffffde80

显示this最初指向派生的B对象b,该对象在栈上构造于0x7fffffffde80处。下一步进入基类A()的ctor,this变成了A * const并指向相同的地址,这是有道理的,因为基类A位于B对象的开头,但它仍未被构造:

(gdb) p *this
$30 = {_vptr.A = 0x7fffffffdf80, _n = 0}

还有一步:

(gdb) s
4       fn();
(gdb) p *this
$31 = {_vptr.A = 0x402038 <vtable for A+16>, _n = 0}

_n已被初始化,它的虚函数表指针包含virtual void A::fn()的地址:

(gdb) p fn
$32 = {void (A * const)} 0x40114a <A::fn()>
(gdb) x/1a 0x402038
0x402038 <_ZTV1A+16>:   0x40114a <_ZN1A2fnEv>

因此,通过活动的this_vptr.A执行A :: fn()使得下一步非常合理。再走一步,我们回到B()ctor:

(gdb) s
B::B (this=0x7fffffffde80) at main2.cc:18
18    }
(gdb) p this
$34 = (B * const) 0x7fffffffde80
(gdb) p *this
$35 = {<A> = {_vptr.A = 0x402020 <vtable for B+16>, _n = 1}, <No data     fields>}

已经构建了基类A。请注意,存储在虚函数表指针中的地址已更改为派生类B的vtable。因此,对fn()的调用将通过this->fn()选择派生类覆盖B::fn(),给定活动的this和_vptr.A(取消注释B()中的对B::fn()的调用以查看此内容)。再次检查存储在_vptr.A中的1个地址,现在它指向派生类的覆盖:
(gdb) p fn
$36 = {void (B * const)} 0x401188 <B::fn()>
(gdb) x/1a 0x402020
0x402020 <_ZTV1B+16>:   0x401188 <_ZN1B2fnEv>

通过查看这个例子,以及一个具有3级继承的例子,似乎编译器在下降构造基本子对象时,this*的类型和_vptr.A中相应的地址会发生变化,以反映当前正在构造的子对象 - 因此它被留在指向最派生类型的位置。因此,我们期望从构造函数内部调用的虚函数选择该级别的函数,即与非虚拟函数相同的结果。同样适用于析构函数但是反过来。而且,在构造成员时,this变成了成员指针,因此它们也可以正确地调用为它们定义的任何虚函数。

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