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

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个回答

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

你好,欢迎来到SO!请阅读tour如何撰写优秀答案? 例如,添加代码片段可能会有所帮助。 - Tomer Shetah
此解决方案存在未定义的行为。sleep_for 不会同步线程,因此在构造和析构期间都存在 this->Print() 的竞争。其次,这会导致崩溃,因为工作线程需要 this 仍然存在(它是一个成员函数),但不能保证如此。如果没有任意等待,比如 getchar(),则 Sub 实例可以很容易地在线程打印之前到达其生命周期的末尾。依赖于 detach() 的解决方案几乎总是有问题的。 - François Andrieux

0

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

  1. 虚拟函数用于动态分配值,因为构造函数是静态的,所以我们不能使它们成为虚拟的。

-1
我在程序中遇到了这个错误。 然后我想:如果构造函数中的方法被标记为纯虚拟,会发生什么?
class Base {
public:
    virtual int getInt() = 0;
    
    Base(){
        printf("int=%d\n", getInt());
    }
};

class Derived : public Base {
    public:
        virtual int getInt() override {return 1;}
};

还有一个有趣的事情!你首先会收到编译器的警告:

warning: pure virtual ‘virtual int Base::getInt() const’ called from constructor

还有来自 ld 的一个错误!

/usr/bin/ld: /tmp/ccsaJnuH.o: in function `Base::Base()':
main.cpp:(.text._ZN4BaseC2Ev[_ZN4BaseC5Ev]+0x26): undefined reference to `Base::getInt()'
collect2: error: ld returned 1 exit status

编译器只给出警告,这完全是不合逻辑的!


-2

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


1
问题是为什么 b 的构造函数调用了基类的 f(),而不是派生类覆盖的 f()。变量 b 的类型与此无关。 - underscore_d
“函数调用不会引用虚表”这种说法是不正确的。如果你认为只有通过 B*B& 访问时才启用了虚拟派发,那么你就错了。 - Lightness Races in Orbit
除了它遵循自己的逻辑到错误的结论之外......这个答案背后的想法,称为静态类型,被误用了。编译器可以去虚拟化 b.getN(),因为它知道真正的类型,并直接分派给来自 B 的版本。但这只是由 as-if 规则做出的容许。一切仍然必须按字面意思使用并遵循虚拟表。在 A 构造函数中,同样适用:即使(可能不可能)与 B 构造函数一起内联,虚拟调用仍必须表现得好像只有基类 A 的虚拟表可用于使用。 - underscore_d
@LightnessRacesinOrbit,你能给我举个例子来证明虚拟派发是在没有通过引用或指针(包括隐式的this)调用的情况下发生的吗? - Peter - Reinstate Monica
@user2305329 你说得对,调用b.getn()是非虚函数。b是一个静态类型的对象,无论其类型定义了什么样的getn()都会被调用。但是,在成员函数内部(包括构造函数),所有成员函数调用都通过隐式的this指针进行,并且如果它是一个多态类,则是虚函数调用。即使在派生对象的整个构造过程中发生,解析虚拟fn()调用到基类的实现的原因和理由在其他答案中已经解释过了。 - Peter - Reinstate Monica
@PeterA.Schneider 包括隐式的 this。这是我认为作者遗漏了的情况。 - Lightness Races in Orbit

-4
在对象的构造函数调用期间,虚函数指针表并没有完全构建。这样做通常不会给您期望的行为。在这种情况下调用虚函数可能会起作用,但不能保证,并且应该避免使用以实现可移植性并遵循C++标准。

5
在这种情况下调用虚函数可能有效,但不能保证。这是不正确的说法。行为是有保障的。 - curiousguy
1
@curiousguy...如果基本版本可用,则保证调用基本版本,否则调用UB(未定义行为),如果vfunc是纯虚函数。 - underscore_d

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