为什么从构造函数中对纯虚函数进行虚拟调用是未定义行为,而对非纯虚函数的调用是被标准所允许的?

20

从标准中10.4节的抽象类第6段:

“可以从抽象类的构造函数(或析构函数)调用成员函数;但直接或间接地为正在从这样的构造函数(或析构函数)创建(或销毁)的对象做出虚函数调用以调用纯虚函数时的效果是未定义的。”

如果标准允许在构造函数(或析构函数)中调用非纯虚函数,为什么会有这种差异?

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

§ 10.4/2 通过在类定义中的函数声明中使用纯说明符(9.2),可以指定虚函数为纯函数。只有当使用限定符ID语法(5.1)调用、或像使用限定符ID语法一样调用(12.4)时,才需要定义纯虚函数。 [ 注意:函数声明不能同时提供纯说明符和定义 - 结束说明 ]

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

需要回答的一些问题是:

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

  • 如果为纯虚函数提供了实现,为什么在这种情况下不能将其定义良好并调用该函数?


1
我之前听说过这个问题(在其他地方);似乎没有很好的原因。 - Lightness Races in Orbit
可能是从构造函数和析构函数中调用纯虚函数的重复问题。 - Lightness Races in Orbit
2
你可以调用一个非纯虚函数,但它不会调用派生类的版本,而是会调用构造函数/析构函数所属类的版本。 - CashCow
1
@CashCow:没错,问题是,为什么调用一个纯虚函数成员不会做完全相同的事情呢? - Lightness Races in Orbit
1
@CashCow 那么,为什么标准不允许“已定义”的纯虚函数做同样的事情呢? - Belloc
显示剩余4条评论
4个回答

16

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

现在除了构造函数或析构函数外,这是由于您实际上永远无法拥有抽象类的对象。您必须拥有某个非抽象派生类的对象,该类覆盖了纯虚函数(如果没有覆盖它,则该类将是抽象类)。但是,在运行构造函数或析构函数时,您可能拥有中间状态的对象。但是,由于标准规定在此状态下虚拟调用纯虚函数会导致未定义的行为,因此编译器可以不必特殊处理以正确执行此操作,从而为实现纯虚函数提供了更大的灵活性。特别地,编译器可以像实现非纯虚函数一样来实现纯虚函数(无需特殊处理),并且如果您从构造函数/析构函数中调用纯虚函数,编译器可能会崩溃或以其他方式失败。


4

我认为这段代码是标准所引用的未定义行为的一个例子。尤其是,编译器很难注意到这个问题。

顺便说一下,当我说“编译器”时,实际上是指“编译器和链接器”,如果有任何困惑请谅解。

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(),而这个函数并不存在。
此时,你可能认为编译器应该根据调用了纯虚函数来警告或报错。但是,你应该考虑派生的非抽象类,在这个类中,pure已经被实现了。如果在构造后调用该类的foo(并假设您没有重写foo),则会得到良好定义的行为。因此,对于foo没有任何警告的基础。只要不在Abstract的构造函数中调用foo,foo就是良好定义的。
因此,每个方法(构造函数和foo)在其自身上看起来都相对OK。我们知道存在问题的唯一原因是因为我们可以看到整个情况。一个非常聪明的编译器将把每个特定的实现/非实现分为以下三类:
完全定义:在对象层次结构的每个级别上,它及其调用的所有方法都是完全定义的。
构造后定义:具有实现但可能因为调用的方法的状态而失败的函数(如foo)。
纯虚函数。
希望编译器和链接器跟踪所有这些依赖关系,并不是一件容易的事情,因此标准允许编译器进行干净的编译,但给出未定义行为。
(我没有提到可以给纯虚方法提供实现的事实。这对我来说是新的。它是否被正确定义,或者只是特定于编译器的扩展?void Abstract :: pure() { })
因此,它不仅仅是未定义的“因为标准这样规定”,你必须问自己“你会为上述代码定义什么行为?”唯一合理的答案要么是将其保留为未定义,要么是强制发生运行时错误。编译器和链接器很难分析所有这些依赖关系。
而更糟糕的是,考虑成员函数指针!编译器或链接器实际上无法确定“有问题”的方法是否会被调用 - 这可能取决于运行时发生的很多其他事情。如果编译器在构造函数中看到 (this->*mem_fun)(),则不能指望它知道 mem_fun 定义得有多好。

2
这是类的构造和析构方式。
首先构造Base,然后构造Derived。因此,在Base的构造函数中,Derived尚未创建。因此,不能调用其任何成员函数。因此,如果Base的构造函数调用虚函数,则它不能是来自Derived的实现,而必须是来自Base的实现。但是,Base中的函数是纯虚函数,并没有可以调用的内容。
在销毁时,首先销毁Derived,然后销毁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() { }
};

讨论已经转向建议以下替代方案:

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

示例代码无疑会是这样的: class Base { // 其他内容 virtual void init() = 0; virtual void cleanup() = 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)被调用。它们只会被显式地调用(使用完整的类名限定),这意味着实际上它们并不真正是虚拟的。你被允许给它们实现,这可能是误导人的,可能并不是一个好主意,如果你想要这样一个可以通过派生类调用的函数,也许更好的方法是将其保护并且非虚拟的。
本质上:如果你想让函数具有非纯虚拟函数的行为,这样你就可以给它一个实现,并在构造函数和析构函数阶段调用它,那么就不要定义它为纯虚拟的。为什么要将它定义为你不想要的东西呢?
如果你想做的只是防止实例被创建,你可以用其他方法来做到这一点,例如: - 使析构函数为纯虚拟的。 - 使所有构造函数都受保护。

2
在这种情况下,纯虚函数必须被定义。 - Belloc
1
“但是Base中的函数是纯虚函数,没有可调用的内容。” 不,纯虚成员函数可以有实现。实际上,如果你所说的是真的,那么你将面临编译错误的情况,而不是未定义行为。 - Lightness Races in Orbit
1
@CashCow:它可能被定义。我真的不明白对象的动态类型是基类型还是派生类型有什么区别。为什么这会是未定义行为,而对已实现的纯虚函数的正常调用却不是呢? - Lightness Races in Orbit
@LightnessRacesinOrbit:难道不应该是链接器失败吗?在类定义中没有任何信息能告诉编译器该函数是否在另一个编译单元中有定义。 - Ben Voigt
各位,有一个问题。在给定的类中,虚函数要么是纯虚函数,要么不是。初始类定义是否必须声明它是否为纯虚函数=0;?那个信息是否可以记录在其他地方,甚至在另一个翻译单元中?如果实现可以在其他地方指定,那么它的“纯度”是否可以在其他地方指定? - Aaron McDaid
显示剩余17条评论

1
在讨论为什么它是未定义的之前,让我们先澄清一下问题是关于什么的。
#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.

第二和第三行很容易理解;这些方法最初是在抽象类中定义的,但在 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();或类似方法来执行此代码。那么,允许这样的定义有什么意义呢?

请参见this demo。编译器会发出警告,但现在可以从构造函数中调用Abstract::pure()方法。这是唯一可以调用Abstract::pure()的途径。

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


1
假设您定义了 Abstract::pure() { cout << "Abstract pure" << endl; },并保持声明 virtual void pure() = 0;。与 impure() 虚函数相比,有什么区别? - Belloc
@user1042389,直到几分钟前我才意识到这种可能性。因此,我已经更新了这个答案。我曾经认为虚拟方法要么是纯的,要么不是 - 这第三种状态(纯定义)对我来说似乎相当奇怪!我真的不知道该说什么了。 - Aaron McDaid
您可以从抽象类或任何有权访问它的派生类中调用Abstract::pure(),只需像这样调用具有完全限定名称的函数即可。 - CashCow
1
我认为这个链接(http://www.open-std.org/jtc1/sc22/wg21/docs/cwg_active.html#230)回答了我的问题。 - Belloc
标准问题已关闭并移至此处:http://www.open-std.org/jtc1/sc22/wg21/docs/cwg_closed.html#230 - vedg

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