首页 文章

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

提问于
浏览
16

从10.4 Abstract Classes parag . 标准中的6:

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

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

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

§10.4/ 2通过在类定义中的函数声明中使用纯说明符(9.2)来指定虚函数 . A pure virtual function needs be defined only if 使用(12.4)调用,使用qualified-id语法(5.1) . ... [注意:函数声明不能同时提供纯指定符和定义-end note]

§12.4/ 9 A destructor can be declared virtual(10.3)或 pure virtual (10.4);如果在程序中创建了该类或任何派生类的任何对象, the destructor shall be defined.

一些需要回答的问题是:

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

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

4 回答

  • 1

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

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

  • 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

    这是构建和破坏类的方式 .

    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将要调用pure-virtual cleanup(),则无法从Base的析构函数调用它,反之亦然 .

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

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

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

  • 10

    在讨论为什么它首先澄清问题是什么之前 .

    #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 对象,而且至关重要的是,它不知道它最终会成为一个 X 对象 . 对于破坏,相同的过程反向发生 .

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

    Update: 我只是discovered可以在虚拟类中提供纯虚方法的实现 . 问题是:这有意义吗?

    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 的实现,甚至做其他疯狂的事情 . 我不知道为什么没有定义 - 但我写了这篇文章试图帮助澄清这个问题 .

相关问题