ChatGPT解决这个技术问题 Extra ChatGPT

在构造函数中调用虚函数

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

我在问并回答我自己的问题,因为我想将这一点 C++ 深奥的解释放到 Stack Overflow 中。这个问题的一个版本已经两次袭击了我们的开发团队,所以我猜这个信息可能对那里的人有用。如果您能以不同/更好的方式解释它,请写下答案...
我想知道为什么这被否决了?当我第一次学习 C++ 时,这真的让我很困惑。 +1
令我惊讶的是缺少编译器警告。编译器将调用“在当前构造函数的类中定义的函数”替换为派生类中“最被覆盖”的函数。如果编译器说“在构造函数中用 Base::foo() 代替对虚函数 foo() 的调用”,那么程序员会被警告代码不会按照他们的预期执行。这将比进行无声替换更有帮助,这会导致神秘的行为、大量的调试,并最终访问 stackoverflow 以获得启发。
@CraigReynolds 不一定。编译器不需要对构造函数中的虚调用进行特殊处理 基类构造函数只为当前类创建虚表,所以此时编译器可以像往常一样通过虚表调用虚函数。但是 vtable 还没有指向任何派生类中的任何函数。派生类的 vtable 在基类构造函数返回后由派生类的构造函数进行调整,这就是在构造派生类后覆盖将如何工作。

C
Community

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

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

摘抄:

[...] 在构造函数中,虚拟调用机制被禁用,因为尚未发生从派生类的覆盖。对象是从基础向上构建的,“在派生之前的基础”。 [...] 销毁是“在基类之前的派生类”完成的,因此虚函数的行为就像在构造函数中一样:仅使用本地定义 - 并且不调用覆盖函数以避免触及(现已销毁的)派生类部分的对象。

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


不是大多数 C++ 实现,但所有 C++ 实现都必须调用当前类的版本。如果有些人没有,那么那些有一个错误:)。我仍然同意你的观点,从基类调用虚函数是不好的——但语义是精确定义的。
这并不危险,它只是非虚拟的。事实上,如果从构造函数调用的方法被虚拟调用,那将是危险的,因为该方法可以访问未初始化的成员。
为什么从析构函数调用虚函数很危险?析构函数运行时对象不是仍然完整,只有在析构函数完成后才被销毁?
−1 “很危险”,不,这在 Java 中很危险,可能会发生向下调用; C++ 规则通过一种相当昂贵的机制消除了危险。
从构造函数“危险”调用虚函数的方式是什么?这完全是胡说八道。
D
David Rodríguez - dribeas

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


很好地解释了为什么替代方案(也)容易出错。
“如果基类构造函数调用了在派生类中被覆盖的方法,则调用实际上将在派生级别处理,调用未构造对象上的方法......”如果 base 已经初始化,该怎么办。除非您在初始化其他成员之前明确调用“init”,否则这是不可能的。
一个解释! +1,优秀的答案恕我直言
对我来说,问题是 C++ 类中有太多限制,以至于很难实现任何好的设计。 C++ 规定“如果它可能是危险的,禁止它”,即使它的直觉导致问题,例如:“为什么这种直觉行为不起作用”一直在发生。
@VinGarcia 什么?在这种情况下,C++ 不会“禁止”任何事情。该调用被简单地视为对当前正在执行其构造函数的类的方法的非虚拟调用。这是对象构建时间线的合乎逻辑的结果——而不是阻止你做傻事的严厉决定。事实上,它也巧合地实现了后一个目的,这对我来说只是一个奖励。
M
Macke

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


C++ 通常不使用术语“超类”——它更喜欢“基类”。
这在大多数 OO 语言中是相同的:如果基础部分已经构建,您不可能构建派生对象。
@DavidRodríguez-dribeas 其他语言确实做到了。例如在 Pascal 中,首先为整个对象分配内存,然后只调用派生最多的构造函数。构造函数必须包含对其父构造函数的显式调用(不必是第一个操作 - 它只是必须在某个地方),或者如果它不包含,就好像构造函数的第一行进行了该调用.
感谢您对细节的清晰和避免直接导致结果
如果调用仍然使用 vptr(因为 vptr 也设置为您提到的当前级别)方式,或者只是静态调用当前级别的版本。
C
Cheers and hth. - Alf

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

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


清晰,直接,最简单的答案。我仍然希望看到它得到一些爱。我讨厌编写所有这些愚蠢的initializeObject() 函数,用户在构造之后就被迫调用,这对于一个非常常见的用例来说是一种糟糕的形式。不过我理解其中的困难。这就是生活。
@moodboom 你建议什么“爱”?请记住,您不能仅仅改变当前工作的方式,因为这会严重破坏现有代码的大量使用。那么,你会怎么做呢?不仅您将引入什么新语法以允许(实际的、非虚拟化的)构造函数中的虚拟调用 - 而且您将如何以某种方式修改对象构造/生命周期的模型,以便这些调用将具有派生类型的完整对象运行哪个。这会很有趣。
@underscore_d 我认为不需要任何语法更改。也许在创建对象时,编译器会添加代码来遍历 vtable 并查找这种情况并修补东西?我从来没有写过 C++ 编译器,我很确定我最初给这个“爱”的评论是天真的,这永远不会发生。 :-) 无论如何,虚拟的 initialize() 函数并不是一个非常痛苦的解决方法,您只需要记住在创建对象后调用它即可。
@underscore_d 我刚刚注意到您在下面的其他评论,解释说 vtable 在构造函数中不可用,再次强调这里的困难。
@moodboom 我在写关于构造函数中不可用的 vtable 时搞砸了。它是可用的,但构造函数只能看到它自己的类的 vtable,因为每个派生构造函数都会更新实例的 vptr 以指向当前派生类型的 vtable,而不是进一步。因此,当前 ctor 看到了一个只有自己的覆盖的 vtable,因此它不能调用任何虚拟函数的任何派生实现。
d
davidA

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

为您的类层次结构定义一个公共基类,其中包含一个虚方法 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();

需要为模板函数指定类型 MyClass* pMyObject = factoryNew();
F
François Andrieux

其他答案已经解释了为什么 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

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


这种方法无法扩展,如果我们需要另一个继承 Derived 并提供自己的 Name impl 的类怎么办。 @stands2reason 发布的 CRTP 解决方案实际上是事实上的解决方案
就 Derived 中的虚拟覆盖触及其数据成员而言,第 3 段也说明了如果对虚拟的调用表现得像 OP 中的那样“可能期望……”替代方案时的危险。
m
msc

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

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

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

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


s
stands2reason

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

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


我想我会避免这样做。这不再是单一的基类了。您实际上创建了许多不同的基类。
@Wang 确切地说:Base<T> 只是一个辅助类,而不是可用于运行时多态性的通用接口类型(例如异构容器)。这些也很有用,只是不适用于相同的任务。有些类既继承自作为运行时多态的接口类型的基类,又继承自作为编译时模板帮助器的基类。
T
TimW

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

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

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


很难看出这是同一个问题,因为您没有解释原因。在 ctor 期间调用非纯虚函数是完全合法的,但它们只是不通过(尚未构造的)虚表,因此执行的方法版本是为我们的 ctor 的类类型定义的版本在里面。所以那些不会崩溃。这是因为它是纯虚函数且未实现(旁注:可以在基类中实现纯虚函数),因此对于此类类型没有要调用的方法版本,并且编译器假定您没有写错代码,所以繁荣
哦。这些调用确实通过了 vtable,但它还没有被更新为指向最派生类的覆盖:只有现在正在构造的那个。尽管如此,坠机的结果和原因仍然是一样的。
@underscore_d“(旁注:可以在基础中实现纯虚函数)”不,您不能,否则该方法不再是纯虚函数。您也不能创建抽象类的实例,因此如果您尝试从构造函数调用纯方法,则 TimW 的示例将无法编译。它现在可以编译,因为构造函数不调用纯虚方法并且不包含代码,只是一个注释。
Y
Yogesh

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


vptr 在 ctor 的末尾没有改变。在 ctor C::C 的主体中,虚函数调用转到 C 覆盖器,而不是任何基类版本。
对象的动态类型是在 ctor 调用基类 ctor 之后,在它构造其成员之前定义的。因此,在ctor 的末尾不会更改 vptr。
@curiousguy我说的是同样的事情, vptr 在基类的构造函数结束时不会更改,它将在派生类的构造函数结束时更改。我希望你也这么说。它是一个编译器/实现依赖的东西。你什么时候提议 vptr 应该改变。有什么好的理由拒绝投票吗?
vptr 更改的时间与实现无关。它由语言语义规定:vptr 在类实例的动态行为发生变化时发生变化。这里没有自由。在 ctor T::T(params) 的主体内,动态类型是 T。 vptr 将反映:它将指向 T 的 vtable。您不同意吗?
也许有一个继承的真实例子会更容易谈论
P
Priya

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

虚函数用于动态赋值,因为构造函数是静态的,所以我们不能让它们成为虚拟的。


k
keyou

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

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

例如:

#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!请阅读 tourHow do I write a good answer? 例如添加代码段可能会有所帮助。
此解决方案具有未定义的行为。 sleep_for 不同步线程,因此您在构造和销毁期间都会在 this->Print() 上竞争。其次,这有崩溃的风险,因为工作人员需要 this 仍然存在(它是一个成员函数),但不能保证这一点。如果您没有像 getchar() 这样的任意等待,则 Sub 实例可以在线程打印之前轻松到达其生命周期的末尾。依赖 detach() 的解决方案几乎总是被破坏。
D
Don Slowik

为了回答运行该代码时会发生什么/为什么,我通过 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(),打印 this ptr,进入 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 最初指向在 0x7fffffffde80 的堆栈上构造的派生 B obj b。下一步是进入基 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 的情况下,下一步通过 this->fn() 执行 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 中的相应地址发生变化以反映当前正在构造的子对象, - 所以它指向最派生的类型。因此,我们希望从 ctors 中调用的虚函数为该级别选择函数,即,与非虚函数相同的结果。对于 dtors 也是如此,但相反。并且 this 在构造成员时成为成员的指针,因此它们也可以正确调用为为它们定义的任何虚函数


u
user2305329

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


问题是为什么 b 的构造函数调用基 f(),而不是它的派生覆盖。变量 b 的类型与此无关。
“函数调用不会引用 vtable” 这不是真的。如果您认为只有在通过 B* 或 `B&` 访问时才启用虚拟调度,那您就错了。
除了它遵循自己的逻辑得出错误的结论之外......这个答案背后的想法,已知的静态类型,被误用了。编译器可以去虚拟化 b.getN(),因为它知道真正的类型,&直接从 B 分派到版本。但这只是 as-if 规则所允许的。一切仍然必须如同虚拟表被使用&跟着信。在 A 构造函数中也是如此:即使(可能不可能)它被内联了 B ctor,虚拟调用仍然必须as-if它只有base A vtable 可供使用。
@LightnessRacesinOrbit 你能给我一个例子来说明你的断言,即虚拟调度发生而不通过引用或指针(包括隐式 this )调用吗?
@user2305329 你说得对,调用 b.getn() 是非虚拟的。 b 是一个静态类型的对象,将调用为其类型定义的任何 getn()。但是在成员函数内部,包括构造函数,所有成员函数调用都是通过隐式 this 指针进行的,因此如果它是多态类,则它们都是虚函数调用。解决对基类实现的虚拟 fn() 调用的原因和基本原理 - 即使它发生在派生对象的整体构造期间 - 在其他答案中进行了解释。
t
terry

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


“在这种情况下调用虚函数可能有效,但不能保证”这是不正确的。行为得到保证。
@curiousguy ...保证在可用时调用基本版本,或者如果 vfunc 是纯虚拟的,则保证调用 UB。