269

假设我有两个 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。为什么?

4

15 回答 15

253

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

C++ FAQ Lite在第23.7 节中非常详细地介绍了这一点。我建议阅读该内容(以及常见问题解答的其余部分)以进行跟进。

摘抄:

[...] 在构造函数中,虚拟调用机制被禁用,因为尚未发生从派生类的覆盖。对象是从基础向上构建的,“在派生之前的基础”。

[...]

销毁是“在基类之前的派生类”完成的,因此虚函数的行为就像在构造函数中一样:仅使用本地定义 - 并且不调用覆盖函数以避免触及对象的(现在已销毁的)派生类部分。

编辑大部分更正(感谢 litb)

于 2009-06-07T15:52:28.263 回答
92

在大多数 OO 语言中,从构造函数调用多态函数是灾难的根源。遇到这种情况时,不同的语言会有不同的表现。

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

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

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 构造函数之前,构建等效的虚拟表。含义是不同的(而且对我来说更危险)。如果基类构造函数调用在派生类中被覆盖的方法,则调用实际上将在派生级别处理,调用未构造对象上的方法,从而产生意外结果。在构造函数块中初始化的派生类的所有属性尚未初始化,包括“最终”属性。在类级别定义了默认值的元素将具有该值。

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++ 中,至少您可以保证它永远不会在尚未构造的对象上调用方法...

于 2009-06-07T16:56:53.117 回答
68

原因是 C++ 对象从内到外像洋葱一样构造。基类在派生类之前构造。因此,在制作 B 之前,必须先制作 A。当 A 的构造函数被调用时,它还不是 B,所以虚函数表仍然有 A 的 fn() 副本的条目。

于 2009-06-07T15:46:40.930 回答
30

C++ FAQ Lite很好地涵盖了这一点:

本质上,在调用基类构造函数期间,对象还不是派生类型,因此调用的是基类型的虚函数实现,而不是派生类型的。

于 2009-06-07T16:03:12.153 回答
17

您的问题的一种解决方案是使用工厂方法来创建您的对象。

  • 为您的类层次结构定义一个公共基类,其中包含一个虚方法 afterConstruction():
类对象
{
上市:
  虚空 afterConstruction() {}
  // ...
};
  • 定义工厂方法:
模板<C类>
C* factoryNew()
{
  C* pObject = 新 C();
  pObject->afterConstruction();

  返回 p 对象;
}
  • 像这样使用它:
MyClass 类:公共对象
{
上市:
  虚空 afterConstruction()
  {
    // 做点什么。
  }
  // ...
};

MyClass* pMyObject = factoryNew();

于 2009-06-07T17:09:10.293 回答
6

其他答案已经解释了为什么virtual从构造函数调用时函数调用不能按预期工作。相反,我想提出另一种可能的解决方法,以从基类型的构造函数中获取类似多态的行为。

通过向基类型添加模板构造函数,以便始终将模板参数推断为派生类型,可以了解派生类型的具体类型。从那里,您可以调用static该派生类型的成员函数。

此解决方案不允许static调用非成员函数。虽然执行是在基类型的构造函数中,但派生类型的构造函数甚至没有时间检查它的成员初始化列表。正在创建的实例的派生类型部分尚未开始对其进行初始化。而且由于非static成员函数几乎肯定会与数据成员交互,因此想要static从基类型的构造函数调用派生类型的非成员函数是不寻常的。

这是一个示例实现:

#include <iostream>
#include <string>

struct Base {
protected:
    template<class T>
    explicit Base(const T*) : class_name(T::Name())
    {
        std::cout << class_name << " created\n";
    }

public:
    Base() : class_name(Name())
    {
        std::cout << class_name << " created\n";
    }


    virtual ~Base() {
        std::cout << class_name << " destroyed\n";
    }

    static std::string Name() {
        return "Base";
    }

private:
    std::string class_name;
};


struct Derived : public Base
{   
    Derived() : Base(this) {} // `this` is used to allow Base::Base<T> to deduce T

    static std::string Name() {
        return "Derived";
    }
};

int main(int argc, const char *argv[]) {

    Derived{};  // Create and destroy a Derived
    Base{};     // Create and destroy a Base

    return 0;
}

这个例子应该打印

Derived created
Derived destroyed
Base created
Base destroyed

构造a 时DerivedBase构造函数的行为取决于正在构造的对象的实际动态类型。

于 2018-10-19T17:00:40.453 回答
2

正如已经指出的那样,对象是在构造时创建的。在构造基础对象时,派生对象还不存在,因此虚函数覆盖无法工作。

但是,如果您的 getter 返回常量,或者可以在静态成员函数中表示,则可以使用使用静态多态性而不是虚拟函数的多态 getter 来解决此问题,此示例使用 CRTP ( https://en.wikipedia.org/wiki /Curiously_recurring_template_pattern)。

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,因为信息是在编译时提供的。

于 2018-04-11T03:30:42.417 回答
1

你知道 Windows 资源管理器的崩溃错误吗?!“纯虚函数调用……”
同样的问题……

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

因为函数 pureVitualFunction() 没有实现,并且该函数在构造函数中被调用,所以程序将崩溃。

于 2009-06-07T16:51:29.630 回答
1

vtables 由编译器创建。一个类对象有一个指向它的 vtable 的指针。当它开始生命时,该 vtable 指针指向基类的 vtable。在构造函数代码的末尾,编译器生成代码以将 vtable 指针重新指向该类的实际 vtable。这确保调用虚函数的构造函数代码调用这些函数的基类实现,而不是类中的覆盖。

于 2013-05-07T10:14:14.050 回答
1

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

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

所以,不要virtual从构造函数或析构函数调用试图调用正在构造或销毁的对象的函数,因为构造的顺序是从基类到派生的,而析构函数的顺序是从派生到基类的。

因此,尝试从正在构造的基类调用派生类函数是危险的。同样,对象以与构造相反的顺序被销毁,因此尝试从析构函数调用更派生类中的函数可能会访问已经存在的资源被释放。

于 2017-08-23T07:03:24.333 回答
0

首先,创建对象,然后我们将它的地址分配给指针。构造函数在对象创建时被调用,用于初始化数据成员的值。指向对象的指针在对象创建后进入场景。这就是为什么,C++ 不允许我们将构造函数设为 virtual 。另一个原因是,没有什么像指向构造函数的指针,它可以指向虚拟构造函数,因为虚函数的一个属性是它只能被指针使用。

  1. 虚函数用于动态赋值,因为构造函数是静态的,所以我们不能让它们成为虚拟的。
于 2017-11-10T00:48:26.270 回答
0

作为补充,调用尚未完成构造的对象的虚函数将面临同样的问题。

例如,在对象的构造函数中启动一个新线程,并将对象传递给新线程,如果新线程在对象完成构造之前调用该对象的虚函数会导致意外结果。

例如:

#include <thread>
#include <string>
#include <iostream>
#include <chrono>

class Base
{
public:
  Base()
  {
    std::thread worker([this] {
      // This will print "Base" rather than "Sub".
      this->Print();
    });
    worker.detach();
    // Try comment out this code to see different output.
    std::this_thread::sleep_for(std::chrono::seconds(1));
  }
  virtual void Print()
  {
    std::cout << "Base" << std::endl;
  }
};

class Sub : public Base
{
public:
  void Print() override
  {
    std::cout << "Sub" << std::endl;
  }
};

int main()
{
  Sub sub;
  sub.Print();
  getchar();
  return 0;
}

这将输出:

Base
Sub
于 2021-01-29T03:36:49.990 回答
0

为了回答运行该代码时会发生什么/为什么,我通过 编译它 g++ -ggdb main.cc,并使用 gdb 逐步完成。

主.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(),打印thisptr,进入 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在 0x7fffffffde80 的堆栈上构造的派生 B obj。下一步是进入基 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->fn() 给定活动的this_vptr.A. 再走一步,我们又回到了 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发生变化以反映当前正在构造的子对象, - 所以它指向最派生的类型。因此,我们希望从 ctors 中调用的虚函数为该级别选择函数,即,结果与非虚函数相同。对于 dtors 也是如此,但相反。并且this在构造成员时成为成员的 ptr,因此它们也可以正确调用为它们定义的任何虚函数。

于 2021-09-28T21:38:34.713 回答
-2

我在这里没有看到虚拟关键字的重要性。b 是静态类型变量,其类型由编译器在编译时确定。函数调用不会引用 vtable。b 被构造的时候,它的父类的构造函数被调用,这就是为什么 _n 的值被设置为 1 的原因。

于 2013-10-28T06:58:10.737 回答
-4

在对象的构造函数调用期间,虚函数指针表并未完全建立。这样做通常不会给您预期的行为。在这种情况下调用虚函数可能有效,但不能保证,应避免可移植并遵循 C++ 标准。

于 2009-06-07T17:00:14.470 回答