为什么从构造函数对纯虚函数的虚拟调用是 UB,而标准允许调用非纯虚函数

Why a virtual call to a pure virtual function from a constructor is UB and a call to a non-pure virtual function is allowed by the Standard?

本文关键字:函数 调用 标准 许调用 构造函数 虚拟 UB 为什么      更新时间:2023-10-16

来自标准中的 10.4 抽象类第 6 段:

"成员函数可以从抽象类的构造函数(或析构函数(调用;对于从这样的构造函数(或析构函数(创建(或销毁(的对象直接或间接地对纯虚函数进行虚拟调用的效果是不确定的。

假设标准允许从构造函数(或析构函数(调用非纯虚函数,为什么会有区别?

[编辑] 更多关于纯虚函数的标准引用:

§ 10.4/2 在类定义的函数声明中使用纯说明符 (9.2( 指定纯虚函数。纯虚函数仅在使用 (12.4( 限定 id 语法 (5.1( 调用或类似调用时需要定义。 ...[ 注意:函数声明不能同时提供纯说明符和定义 —尾注 ]

§ 12.4/9 析构函数可以声明为虚拟(10.3(或纯虚拟(10.4(;如果在程序中创建了该类或任何派生类的任何对象,则应定义析构函数。

需要回答的一些问题是:

  • 如果纯虚函数没有被赋予实现,这不应该是编译器或链接器错误吗?

  • 如果纯虚函数已经得到了实现,为什么在这种情况下不能很好地定义它来调用这个函数?

因为虚拟调用永远不能调用纯虚函数 -- 调用纯虚函数的唯一方法是使用显式(限定(调用。

现在,在构造函数或析构函数之外,这是由于您实际上永远无法拥有抽象类的对象这一事实强制执行的。 相反,你必须有一个覆盖纯虚函数的非抽象派生类的对象(如果它没有覆盖它,则该类将是抽象的(。但是,当构造函数或析构函数运行时,您可能具有中间状态的对象。但是,由于标准规定尝试在这种状态下虚拟调用纯虚函数会导致未定义的行为,因此编译器可以自由地不必在特殊情况下正确使用它,从而为实现纯虚函数提供了更大的灵活性。 特别是,编译器可以自由地实现纯虚拟,就像实现非纯虚拟一样(不需要特殊情况(,如果从 ctor/dtor 调用纯虚拟,则会崩溃或以其他方式失败。

我认为这段代码是标准引用的未定义行为的一个例子。特别是,编译器不容易注意到这是未定义的。

(顺便说一句,当我说"编译器"时,我的意思是"编译器和链接器"。对于任何困惑,我们深表歉意。

struct Abstract {
    virtual void pure() = 0;
    virtual void foo() {
        pure();
    }
    Abstract() {
        foo();
    }
    ~Abstract() {
        foo();
    }
};
struct X : public Abstract {
    virtual void pure() { cout << " X :: pure() " << endl; }
    virtual void impure() { cout << " X :: impure() " << endl; }
};
int main() {
    X x;
}

如果Abstract的构造函数直接调用pure(),这显然是一个问题,编译器可以很容易地看到没有要调用Abstract::pure(),并且 g++ 给出了警告。但是在这个例子中,构造函数调用foo()foo()是一个非纯虚函数。因此,编译器或链接器没有直接的基础来发出警告或错误。

作为旁观者,我们可以看到,如果从 Abstract 的构造函数调用foo是一个问题。 Abstract::foo()本身是定义的,但它试图调用Abstract::pure,但这不存在。

在此阶段,您可能会认为编译器应该发出有关foo的警告/错误,理由是它调用纯虚函数。但相反,您应该考虑派生的非抽象类,其中pure被赋予了实现。如果你在构造后调用该类的foo(并假设你没有覆盖foo(,那么你将得到明确定义的行为。所以再一次,关于foo的警告是没有根据的。 只要foo不在 Abstract 的构造函数中调用,它就是明确定义的。

因此,每个方法

(构造函数和 foo(如果单独查看它们,则每个方法都相对正常。我们知道存在问题的唯一原因是因为我们可以看到大局。一个非常聪明的编译器会将每个特定的实现/非实现分为三类:

    完全定义
  • :它及其调用的所有方法在对象层次结构中的每个级别都完全定义
  • 施工后定义。像 foo 这样的函数,它有一个实现,但可能会适得其反,具体取决于它调用的方法的状态。
  • 纯虚拟。

期望编译器和链接器跟踪所有这些需要做很多工作,因此该标准允许编译器干净地编译它,但给出未定义的行为。

(我没有提到可以将实现提供给纯虚拟方法的事实。这对我来说是新的。它的定义是否正确,还是只是一个特定于编译器的扩展? void Abstract :: pure() { } (

因此,它不仅仅是未定义的"因为标准是这样说的"。你必须问问自己'你会为上面的代码定义什么行为?唯一明智的答案是要么不定义它,要么强制要求运行时错误。编译器和链接器不会发现分析所有这些依赖项并不容易。

更糟糕的是,请考虑指向成员函数的指针!编译器或链接器无法真正判断是否会调用"有问题"的方法 - 它可能取决于运行时发生的大量其他事情。如果编译器在构造函数中看到(this->*mem_fun)(),则不能期望它知道mem_fun的定义有多好。

它是类的构造和构造方式。

首先构建基础,然后派生。所以在 Base 的构造函数中,Derived 尚未创建。因此,不能调用其任何成员函数。因此,如果 Base 的构造函数调用虚函数,它不能是来自 Derived 的实现,它必须是来自 Base 的实现。但是 Base 中的函数是纯虚拟的,没有什么可调用的。

在破坏中,首先派生被摧毁,然后基地被摧毁。因此,在 Base 的析构函数中,没有 Derived 的对象来调用函数,只有 Base。

顺便说一下,它只是在函数仍然是纯虚拟的情况下未定义。所以这是明确定义的:

struct Base
{
virtual ~Base() { /* calling foo here would be undefined */}
  virtual void foo() = 0;
};
struct Derived : public Base
{
  ~Derived() { foo(); }
  virtual void foo() { }
};

讨论转向提出以下替代方案:

  • 它可能会产生编译器错误,就像尝试创建抽象类的实例一样。

示例代码无疑如下所示: 类基础 { 其他东西 虚拟虚空 init(( = 0; 虚拟空隙清理(( = 0; };

Base::Base()
{
    init(); // pure virtual function
}
Base::~Base()
{
   cleanup(); // which is a pure virtual function. You can't do that! shouts the compiler.
}

在这里很明显,你正在做的事情会给你带来麻烦。一个好的编译器可能会发出警告。

  • 它可能会产生链接错误

另一种方法是查找 Base::init()Base::cleanup() 的定义,并调用它(如果存在(,否则调用链接错误,即出于构造函数和析构函数的目的将清理视为非虚拟。

问题是,如果您有一个非虚拟函数调用虚拟函数,这将不起作用。

class Base
{
   void init();
   void cleanup(); 
  // other stuff. Assume access given as appropriate in examples
  virtual ~Base();
  virtual void doinit() = 0;
  virtual void docleanup() = 0;
};
Base::Base()
{
    init(); // non-virtual function
}
Base::~Base()
{
   cleanup();      
}
void Base::init()
{
   doinit();
}
void Base::cleanup()
{
   docleanup();
}

在我看来,这种情况超出了编译器和链接器的能力。请记住,这些定义可以位于任何编译单元中。构造函数和析构函数在这里调用 init(( 或 cleanup(( 并不违法,除非你知道它们要做什么,init(( 和 cleanup(( 调用纯虚函数也不违法,除非你知道它们从哪里调用。

编译器或链接器完全不可能做到这一点。

因此,标准必须允许编译和链接,并将其标记为"未定义的行为"。

当然,如果

实现确实存在,编译器可以自由使用它(如果可以的话(。未定义的行为并不意味着它必须崩溃。只是标准没有说它必须使用它。

请注意,在这种情况下,析构函数正在调用调用纯虚拟的成员函数,但你怎么知道它会这样做呢?它可能是在调用纯虚函数的完全不同的库中调用某些内容(假设存在访问权限(。

Base::~Base()
{
   someCollection.removeMe( this );
}
void CollectionType::removeMe( Base* base )
{
    base->cleanup(); // ouch
}

如果 CollectionType 存在于完全不同的库中,则此处不会发生任何链接错误。简单的问题是,这些调用的组合是不好的(但这两个调用都不是错误的(。如果 removeMe 要调用纯虚拟 cleanup((,则不能从 Base 的析构函数调用它,反之亦然。

关于这里Base::init()Base::cleanup(),你必须记住的最后一件事是,即使它们有实现,也永远不会通过虚函数机制(v-table(调用它们。它们只会被显式调用(使用完整的类名限定(,这意味着实际上它们并不是真正的虚拟的。允许你给他们实现可能是误导性的,可能不是一个好主意,如果你想要这样一个可以通过派生类调用的函数,也许最好是受保护的和非虚拟的。

本质上:如果你希望函数具有非纯虚函数的行为,以便你给它一个实现,并在构造函数和析构函数阶段被调用,那么不要将其定义为纯虚拟。为什么要将其定义为您不希望它成为的东西?

如果您只想阻止创建实例,则可以通过其他方式执行此操作,例如: - 使析构函数纯虚拟。 - 使构造函数全部受到保护

在讨论为什么它未定义之前,让我们首先澄清这个问题是关于什么的。

#include<iostream>
using namespace std;
struct Abstract {
        virtual void pure() = 0;
        virtual void impure() { cout << " Abstract :: impure() " << endl; }
        Abstract() {
                impure();
                // pure(); // would be undefined
        }
        ~Abstract() {
                impure();
                // pure(); // would be undefined
        }
};
struct X : public Abstract {
        virtual void pure() { cout << " X :: pure() " << endl; }
        virtual void impure() { cout << " X :: impure() " << endl; }
};
int main() {
        X x;
        x.pure();
        x.impure();
}

其输出为:

Abstract :: impure()  // called while x is being constructed
X :: pure()           // x.pure();
X :: impure()         // x.impure();
Abstract :: impure()  // called while x is being destructed.

第二行和第三行很容易理解;这些方法最初是在Abstract中定义的,但是X中的覆盖接管了。即使x是抽象类型的引用或指针而不是 X 类型,此结果也是相同的。

但是这个有趣的事情是X的构造函数和析构函数内部发生的事情。构造函数中对impure()的调用调用Abstract::impure(),而不是X::impure(),即使构造的对象是X类型。析构函数中也会发生同样的情况。

当构造 X 类型的对象时,构造的第一件事只是一个Abstract对象,而且至关重要的是,它不知道它最终会是一个X对象的事实。同样的过程反向发生销毁。

现在,假设你理解这一点,很明显为什么行为必须是未定义的。构造函数或析构函数Abstract :: pure没有可以调用的方法,因此尝试定义此行为是没有意义的(除非可能作为编译错误(。

更新:我刚刚发现可以在虚拟类中给出纯虚拟方法的实现。问题是:这有意义吗?

struct Abstract {
    virtual void pure() = 0;
};
void Abstract :: pure() { cout << "How can I be called?!" << endl; }

永远不会有一个对象的动态类型是 Abstract,因此你永远无法通过正常调用 abs.pure(); 或类似的东西来执行此代码。那么,允许这样的定义有什么意义呢?

请参阅此演示。编译器发出警告,但现在 Abstract::pure() 方法可以从构造函数调用。这是可以调用Abstract::pure()的唯一路由。

但是,这在技术上是不确定的。另一个编译器有权忽略Abstract::pure的实现,甚至做其他疯狂的事情。我不知道为什么没有定义 - 但我写这篇文章是为了帮助澄清这个问题。