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

时间:2012-02-08 00:03:37

标签: c++ standards pure-virtual

来自10.4 抽象类 parag。标准中的6:

“可以从抽象类的构造函数(或析构函数)调用成员函数;对于从这样的构造函数创建(或销毁)的对象直接或间接虚拟调用纯虚函数的效果(或析构函数)未定义。“

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

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

10.4 / 2通过在类定义中的函数声明中使用纯说明符(9.2)来指定虚函数。 只有在使用(12.4)valid-id语法(5.1)调用时才需要定义纯虚函数。 ... [注意:函数声明不能​​同时提供纯说明符和定义-end note]

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

需要回答的一些问题是:

  • 如果纯虚函数没有给出实现,那么这不应该是编译器或链接器错误吗?

  • 在为纯虚函数提供实现的情况下,为什么在这种情况下不能很好地定义它来调用这个函数?

4 个答案:

答案 0 :(得分:10)

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

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

答案 1 :(得分:3)

我认为this code是标准引用的未定义行为的示例。特别是,编译器不容易注意到这是未定义的。

(顺便说一句,当我说'编译'时,我的意思是'编译器和链接器'。对任何混淆道歉。)

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()是非纯虚函数。因此,编译器或链接器没有直接的基础来发出警告或错误。

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

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

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

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

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

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

所以,它不仅仅是未定义的,因为标准是这么说的。你必须问自己'你会为上面的代码定义什么行为?'。唯一明智的答案是要么保持未定义,要么强制执行运行时错误。编译器和链接器将无法轻松分析所有这些依赖项。

更糟糕的是,考虑指向成员函数的指针!编译器或链接器无法确定是否会调用“有问题”的方法 - 它可能依赖于运行时发生的其他事情的整个负载。如果编译器在构造函数中看到(this->*mem_fun)(),则不能指望它知道定义良好的mem_fun

答案 2 :(得分: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() { }
};

讨论已经提出建议的替代方案:

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

示例代码无疑会是这样的:     班级基地     {       // 其他的东西       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将要调用pure-virtual cleanup(),则无法从Base的析构函数调用它,反之亦然。

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

基本上:如果您希望函数具有非纯虚函数的行为,以便您为其提供实现并在构造函数和析构函数阶段调用它,则不要将其定义为纯虚函数。为什么要将它定义为你不想要它的东西?

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

答案 3 :(得分: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.

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

但是这个有趣的事情是在X的构造函数和析构函数中发生的事情。构造函数中对impure()的调用调用Abstract::impure(),而不是X::impure(),即使正在构造的对象是类型为X。在析构函数中也是如此。

当构造X类型的对象时,构造的第一件事只是一个Abstract对象,而且至关重要的是,它不知道它最终将是{{{ 1}}对象。对于破坏,相同的过程反向发生。

现在,假设您了解这一点,很明显为什么必须定义行为。没有方法X可以被构造函数或析构函数调用,因此尝试定义此行为没有意义(除非可能是编译错误。)

更新:我只是discovered可以为虚拟方法提供虚拟类中的实现。问题是:这有意义吗?

Abstract :: pure

永远不会有一个动态类型为Abstract的对象,因此您永远无法通过正常调用struct Abstract { virtual void pure() = 0; }; void Abstract :: pure() { cout << "How can I be called?!" << endl; } 或类似的东西来执行此代码。那么,允许这样的定义有什么意义呢?

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

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