18

从 10.4抽象类段落开始。标准中的 6 条:

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

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

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

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

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

一些需要回答的问题是:

  • 在没有给纯虚函数实现的情况下,这不应该是编译器或链接器错误吗?

  • 已经给纯虚函数一个实现的地方,为什么在这种情况下不能很好地定义调用这个函数呢?

4

4 回答 4

14

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

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

于 2012-02-08T00:59:39.717 回答
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()是一个非纯虚函数。因此,编译器或链接器没有直接的基础来给出警告或错误。

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

在这个阶段,您可能认为编译器应该发出警告/错误foo,因为它调用了纯虚函数。pure但相反,您应该考虑已经给出实现的派生非抽象类。如果您在构造后调用foo该类并假设您没有覆盖foo),那么您将获得明确定义的行为。再说一次,关于 foo 的警告是没有根据的。foo只要它没有在Abstract.

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

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

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

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

因此,它不仅仅是未定义的'因为标准是这样说的'。您必须问自己“您将为上述代码定义什么行为?”。唯一明智的答案是让它未定义或强制执行运行时错误。编译器和链接器会发现分析所有这些依赖关系并不容易。

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

于 2012-02-08T14:18:03.557 回答
2

这是构建和销毁类的方式。

首先构造基础,然后派生。所以在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 { // other stuff virtual void 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()and的定义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)调用它们。它们只会被显式调用(使用完整的类名限定),这意味着实际上它们并不是真正的虚拟。允许您为他们提供实现可能会产生误导,这可能不是一个好主意,如果您想要这样一个可以通过派生类调用的函数,也许最好是受保护和非虚拟的。

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

如果您只想阻止创建实例,您可以通过其他方式做到这一点,例如: - 使析构函数纯虚拟。- 使构造函数全部受到保护

于 2012-02-08T00:07:06.273 回答
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是抽象类型而不是 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,甚至可以做其他疯狂的事情。我不知道为什么没有定义这个 - 但我写这个是为了帮助澄清这个问题。

于 2012-02-08T01:10:05.333 回答