【问题标题】:Why do we need a pure virtual destructor in C++?为什么我们需要 C++ 中的纯虚析构函数?
【发布时间】:2010-11-16 05:13:24
【问题描述】:

我了解虚拟析构函数的必要性。但是为什么我们需要一个虚拟析构函数呢?在其中一篇 C++ 文章中,作者提到我们想要抽象类时使用纯虚析构函数。

但我们可以通过将任何成员函数设为纯虚拟来使类抽象。

所以我的问题是

  1. 我们什么时候才能真正使析构函数成为纯虚函数?谁能给出一个很好的实时示例?

  2. 当我们创建抽象类时,将析构函数也设为纯虚拟是一种好习惯吗?如果是……那为什么?

【问题讨论】:

标签: c++ destructor pure-virtual


【解决方案1】:
  1. 可能允许纯虚析构函数的真正原因可能是禁止它们意味着在语言中添加另一条规则,并且不需要此规则,因为允许纯虚析构函数不会产生任何不良影响。

  2. 不,普通的旧虚拟就足够了。

如果您为其虚拟方法创建一个具有默认实现的对象,并希望使其抽象而不强制任何人覆盖任何特定方法,您可以使析构函数纯虚拟。我认为这没什么意义,但这是可能的。

请注意,由于编译器将为派生类生成隐式析构函数,如果类的作者不这样做,则任何派生类将不是是抽象的。因此,在基类中拥有纯虚析构函数不会对派生类产生任何影响。它只会使基类抽象(感谢@kappa的评论)。

也可以假设每个派生类都可能需要特定的清理代码并使用纯虚拟析构函数来提醒编写一个,但这似乎是做作的(并且没有强制执行)。

注意:析构函数是唯一的方法,即使它纯虚拟必须有一个实现来实例化派生类(是的,纯虚函数可以有实现)。

struct foo {
    virtual void bar() = 0;
};

void foo::bar() { /* default implementation */ }

class foof : public foo {
    void bar() { foo::bar(); } // have to explicitly call default implementation.
};

【讨论】:

  • "是的,纯虚函数可以有实现" 那么就不是纯虚函数了。
  • 如果你想把一个类抽象化,把所有的构造函数都保护起来不是更简单吗?
  • @GMan,你错了,纯虚拟意味着派生类必须覆盖这个方法,这与实现是正交的。如果您想亲自查看,请查看我的代码并注释掉 foof::bar
  • @GMan:C++ FAQ 精简版说“请注意,可以为纯虚函数提供定义,但这通常会使新手感到困惑,最好在以后避免。” parashift.com/c++-faq-lite/abcs.html#faq-22.4 Wikipedia(正确性的堡垒)也说同样的话。我相信 ISO/IEC 标准使用了类似的术语(不幸的是,我的副本目前正在使用)......我同意它令人困惑,并且在我提供定义时,我通常不会在没有澄清的情况下使用该术语,尤其是围绕新程序员...
  • @Motti:这里有趣并提供更多混淆的是纯虚拟析构函数不需要在派生(和实例化)类中显式覆盖。在这种情况下,使用隐式定义:)
【解决方案2】:

抽象类只需要一个纯虚函数即可。任何功能都可以;但碰巧,析构函数是 任何 类都会有的东西——所以它总是作为候选者存在。此外,使析构函数成为纯虚拟的(而不仅仅是虚拟的)除了使类抽象之外没有其他行为副作用。因此,许多风格指南建议一致地使用纯虚拟析构函数来指示一个类是抽象的——如果没有其他原因,它提供了一个一致的位置,阅读代码的人可以查看该类是否是抽象的。

【讨论】:

  • 但还是为什么要提供纯virtaul析构函数的实现。什么可能会出错我将析构函数设为纯虚拟并且不提供它的实现。我假设只声明了​​基类指针,因此从不调用抽象类的析构函数。
  • @Surfing:因为派生类的析构函数隐式调用其基类的析构函数,即使该析构函数是纯虚拟的。因此,如果没有实现它,就会发生未定义的行为。
【解决方案3】:

如果要创建抽象基类:

  • 不能被实例化(是的,这与术语“抽象”是多余的!)
  • 需要虚拟析构函数行为(您打算携带指向 ABC 的指针而不是指向派生类型的指针,并通过它们删除)
  • 不需要任何其他虚拟调度行为用于其他方法(也许没有没有其他方法?考虑一个需要构造函数的简单受保护“资源”容器/析构函数/赋值,但仅此而已)

...通过将析构函数设为纯虚拟为其提供定义(方法体),使类抽象化是最简单的。

对于我们假设的 ABC:

你保证它不能被实例化(即使在类本身内部,这就是为什么私有构造函数可能不够),你得到你想要的析构函数的虚拟行为,你不必找到并标记另一个不需要将虚拟分派为“虚拟”的方法。

【讨论】:

  • 按分数记下答案,这是第一个 1) 正确的答案,2) 用综合语气写成的(而不是依赖示例和附言),3) 答案标题中写的问题和4)显示了一个非常常见的用例(即具有可变大小且没有方法的“纯结构”)。点赞+点赞
【解决方案4】:

从我已阅读到您的问题的答案中,我无法推断出实际使用纯虚拟析构函数的充分理由。比如下面这个理由根本说服不了我:

可能允许纯虚析构函数的真正原因是,禁止它们意味着在语言中添加另一条规则,并且不需要这条规则,因为允许纯虚析构函数不会产生任何不良影响。

在我看来,纯虚拟析构函数很有用。例如,假设您的代码中有两个类 myClassA 和 myClassB,并且 myClassB 继承自 myClassA。由于 Scott Meyers 在他的“更有效的 C++”一书中第 33 条“使非叶类抽象化”中提到的原因,更好的做法是实际创建一个抽象类 myAbstractClass,myClassA 和 myClassB 继承自该抽象类。这提供了更好的抽象并防止了一些问题,例如对象副本。

在抽象过程(创建类myAbstractClass)中,myClassA 或myClassB 的任何方法都可能不适合成为纯虚方法(这是myAbstractClass 成为抽象的前提)。在这种情况下,您定义了抽象类的析构函数 pure virtual。

以下是我自己编写的一些代码的具体示例。我有两个类,Numerics/PhysicsParams,它们共享共同的属性。因此,我让它们从抽象类 IParams 继承。在这种情况下,我手头上绝对没有纯虚拟的方法。例如,setParameter 方法对于每个子类必须具有相同的主体。我唯一的选择是让 IParams 的析构函数成为纯虚拟的。

struct IParams
{
    IParams(const ModelConfiguration& aModelConf);
    virtual ~IParams() = 0;

    void setParameter(const N_Configuration::Parameter& aParam);

    std::map<std::string, std::string> m_Parameters;
};

struct NumericsParams : IParams
{
    NumericsParams(const ModelConfiguration& aNumericsConf);
    virtual ~NumericsParams();

    double dt() const;
    double ti() const;
    double tf() const;
};

struct PhysicsParams : IParams
{
    PhysicsParams(const N_Configuration::ModelConfiguration& aPhysicsConf);
    virtual ~PhysicsParams();

    double g()     const; 
    double rho_i() const; 
    double rho_w() const; 
};

【讨论】:

  • 我喜欢这种用法,但另一种“强制”继承的方法是声明 IParam 的构造函数受到保护,正如其他评论中所述。
【解决方案5】:

这里我想告诉我们什么时候需要虚拟析构函数,什么时候需要纯虚拟析构函数

class Base
{
public:
    Base();
    virtual ~Base() = 0; // Pure virtual, now no one can create the Base Object directly 
};

Base::Base() { cout << "Base Constructor" << endl; }
Base::~Base() { cout << "Base Destructor" << endl; }


class Derived : public Base
{
public:
    Derived();
    ~Derived();
};

Derived::Derived() { cout << "Derived Constructor" << endl; }
Derived::~Derived() {   cout << "Derived Destructor" << endl; }


int _tmain(int argc, _TCHAR* argv[])
{
    Base* pBase = new Derived();
    delete pBase;

    Base* pBase2 = new Base(); // Error 1   error C2259: 'Base' : cannot instantiate abstract class
}
  1. 当你希望没有人应该能够直接创建基类的对象时,使用纯虚析构函数virtual ~Base() = 0。通常至少需要一个纯虚函数,我们以virtual ~Base() = 0作为这个函数。

  2. 当你不需要上面的东西时,只需要Derived类对象的安全销毁

    Base* pBase = new Derived(); 删除 pBase; 不需要纯虚拟析构函数,只有虚拟析构函数可以完成这项工作。

【讨论】:

    【解决方案6】:

    如果您想停止基类的实例化而不对已实现和测试的派生类进行任何更改,您可以在基类中实现一个纯虚析构函数。

    【讨论】:

      【解决方案7】:

      您正在对这些答案进行假设,因此为了清楚起见,我将尝试做一个更简单、更实际的解释。

      面向对象设计的基本关系有两个: IS-A 和 HAS-A。这些不是我编的。他们就是这么称呼的。

      IS-A 表示特定对象标识为在类层次结构中高于它的类。如果香蕉对象是水果类的子类,那么它就是水果对象。这意味着在任何可以使用水果类的地方,都可以使用香蕉。不过,它不是反身的。如果需要特定类,则不能用基类替换特定类。

      Has-a 表示一个对象是复合类的一部分并且存在所有权关系。这意味着在 C++ 中它是一个成员对象,因此拥有类有责任在销毁它之前处理它或移交所有权。

      这两个概念在单继承语言中比在像 c++ 这样的多继承模型中更容易实现,但规则本质上是相同的。当类标识不明确时会出现复杂情况,例如将 Banana 类指针传递给采用 Fruit 类指针的函数。

      首先,虚拟函数是运行时的东西。它是多态性的一部分,因为它用于决定在运行程序中调用它时要运行哪个函数。

      virtual 关键字是一个编译器指令,用于在类标识不明确时按特定顺序绑定函数。虚函数总是在父类中(据我所知),并向编译器指示成员函数与其名称的绑定应该首先使用子类函数,然后是父类函数。

      Fruit 类可以有一个默认返回“NONE”的虚函数 color()。 Banana 类 color() 函数返回“YELLOW”或“BROWN”。

      但是如果接收 Fruit 指针的函数在发送给它的 Banana 类上调用 color() —— 调用哪个 color() 函数? 该函数通常会为 Fruit 对象调用 Fruit::color()。

      这在 99% 的情况下都不是预期的。 但是如果 Fruit::color() 被声明为虚拟,那么会为该对象调用 Banana:color(),因为正确的 color() 函数将在调用时绑定到 Fruit 指针。 运行时将检查指针指向的对象,因为它在 Fruit 类定义中被标记为虚拟。

      这与覆盖子类中的函数不同。在这种情况下 Fruit 指针将调用 Fruit::color() 如果它只知道它是一个指向 Fruit 的指针。

      所以现在出现了“纯虚函数”的想法。 这是一个相当不幸的短语,因为纯度与它无关。这意味着永远不会调用基类方法。 确实不能调用纯虚函数。然而,它仍然必须被定义。必须存在函数签名。为了完整性,许多编码人员制作了一个空实现 {},但如果没有,编译器将在内部生成一个。在这种情况下,即使指针指向 Fruit 也调用函数时,会调用 Banana::color(),因为它是 color() 的唯一实现。

      现在是拼图的最后一块:构造函数和析构函数。

      纯虚构造函数完全是非法的。刚刚出来。

      但纯虚析构函数在您想要禁止创建基类实例的情况下确实有效。如果基类的析构函数是纯虚函数,则只能实例化子类。 惯例是将其分配给 0。

       virtual ~Fruit() = 0;  // pure virtual 
       Fruit::~Fruit(){}      // destructor implementation
      

      在这种情况下,您必须创建一个实现。编译器知道这是你在做什么,并确保你做对了,或者它强烈抱怨它不能链接到它需要编译的所有函数。如果您在如何建模类层次结构方面没有走在正确的轨道上,这些错误可能会令人困惑。

      因此,在这种情况下,您被禁止创建 Fruit 实例,但允许创建 Banana 实例。

      删除指向 Banana 实例的 Fruit 指针的调用 总是会先调用 Banana::~Banana() 然后再调用 Fuit::~Fruit()。 因为不管怎样,调用子类析构函数时,基类析构函数必须跟在后面。

      这是一个坏模型吗?是的,它在设计阶段更复杂,但它可以确保在运行时执行正确的链接,并且在确切访问哪个子类存在歧义的情况下执行子类函数。

      如果您编写 C++ 时只传递准确的类指针,而没有泛型或模糊指针,则实际上不需要虚函数。 但是,如果您需要类型的运行时灵活性(如在 Apple Banana Orange ==> Fruit 中),则函数会变得更容易、更通用,并且冗余代码更少。 您不再需要为每种水果编写一个函数,而且您知道每种水果都会以自己正确的函数响应 color()。

      我希望这种冗长的解释能够巩固概念,而不是混淆事物。有很多很好的例子可以看看, 看够了,实际运行它们并弄乱它们,你就会得到它。

      【讨论】:

        【解决方案8】:

        你问了一个例子,我相信下面提供了一个纯虚拟析构函数的原因。我期待回复这是否是一个好的理由......

        我不希望任何人能够抛出error_base 类型,但是异常类型error_oh_shuckserror_oh_blast 具有相同的功能,我不想写两次。 pImpl 复杂性对于避免将std::string 暴露给我的客户是必要的,而std::auto_ptr 的使用需要复制构造函数。

        公共标头包含客户端可用的异常规范,以区分我的库抛出的不同类型的异常:

        // error.h
        
        #include <exception>
        #include <memory>
        
        class exception_string;
        
        class error_base : public std::exception {
         public:
          error_base(const char* error_message);
          error_base(const error_base& other);
          virtual ~error_base() = 0; // Not directly usable
        
          virtual const char* what() const;
         private:
          std::auto_ptr<exception_string> error_message_;
        };
        
        template<class error_type>
        class error : public error_base {
         public:
           error(const char* error_message) : error_base(error_message) {}
           error(const error& other) : error_base(other) {}
           ~error() {}
        };
        
        // Neither should these classes be usable
        class error_oh_shucks { virtual ~error_oh_shucks() = 0; }
        class error_oh_blast { virtual ~error_oh_blast() = 0; }
        

        这是共享实现:

        // error.cpp
        
        #include "error.h"
        #include "exception_string.h"
        
        error_base::error_base(const char* error_message)
          : error_message_(new exception_string(error_message)) {}
        
        error_base::error_base(const error_base& other)
          : error_message_(new exception_string(other.error_message_->get())) {}
        
        error_base::~error_base() {}
        
        const char* error_base::what() const {
          return error_message_->get();
        }
        

        保持私有的 exception_string 类从我的公共接口中隐藏 std::string:

        // exception_string.h
        
        #include <string>
        
        class exception_string {
         public:
          exception_string(const char* message) : message_(message) {}
        
          const char* get() const { return message_.c_str(); }
         private:
          std::string message_;
        };
        

        然后我的代码抛出一个错误:

        #include "error.h"
        
        throw error<error_oh_shucks>("That didn't work");
        

        error 使用模板有点无缘无故。它以要求客户端捕获错误为代价节省了一些代码:

        // client.cpp
        
        #include <error.h>
        
        try {
        } catch (const error<error_oh_shucks>&) {
        } catch (const error<error_oh_blast>&) {
        }
        

        【讨论】:

          【解决方案9】:

          也许还有另一个纯虚拟析构函数的REAL USE-CASE,我实际上在其他答案中看不到:)

          首先,我完全同意标记的答案:这是因为禁止纯虚析构函数需要语言规范中的额外规则。但这仍然不是 Mark 要求的用例 :)

          首先想象一下:

          class Printable {
            virtual void print() const = 0;
            // virtual destructor should be here, but not to confuse with another problem
          };
          

          类似的东西:

          class Printer {
            void queDocument(unique_ptr<Printable> doc);
            void printAll();
          };
          

          简单地说——我们有接口Printable 和一些“容器”,用这个接口保存任何东西。我认为这里很清楚为什么print() 方法是纯虚拟的。它可以有一些主体,但如果没有默认实现,纯虚拟是一个理想的“实现”(=“必须由后代类提供”)。

          现在想象一下完全一样,只是不是为了打印而是为了销毁:

          class Destroyable {
            virtual ~Destroyable() = 0;
          };
          

          也可能有一个类似的容器:

          class PostponedDestructor {
            // Queues an object to be destroyed later.
            void queObjectForDestruction(unique_ptr<Destroyable> obj);
            // Destroys all already queued objects.
            void destroyAll();
          };
          

          这是我真实应用程序的简化用例。这里唯一的区别是使用了“特殊”方法(析构函数)而不是“普通”print()。但它是纯虚的原因还是一样的——方法没有默认代码。 有点令人困惑的是,必须有一些有效的析构函数,编译器实际上会为它生成一个空代码。但从程序员的角度来看,纯虚拟仍然意味着:“我没有任何默认代码,它必须由派生类提供。”

          我认为这没什么大不了的,只是更多地解释了纯虚拟真正统一地工作 - 也适用于析构函数。

          【讨论】:

            【解决方案10】:

            这是一个十年前的话题 :) 阅读“Effective C++”一书中第 7 条的最后 5 段了解详细信息,从“偶尔给类一个纯虚析构函数......”开始

            【讨论】:

              【解决方案11】:

              我们需要将析构函数设为虚拟,因为如果我们不将析构函数设为虚拟,那么编译器只会破坏基类的内容,所有派生类将保持不变,bacuse编译器不会调用析构函数除了基类之外的任何其他类。

              【讨论】:

              • -1:问题不在于为什么析构函数应该是虚拟的。
              • 此外,在某些情况下,析构函数不必是虚拟的即可实现正确的销毁。仅当您最终在指向基类的指针上调用 delete 而实际上它指向其派生类时,才需要虚拟析构函数。
              • 你是 100% 正确的。这在过去一直是 C++ 程序中泄漏和崩溃的第一大来源之一,仅次于尝试使用空指针和超出数组边界的操作。非虚拟基类析构函数将在泛型指针上调用,如果子类析构函数未标记为虚拟,则完全绕过它。如果有任何动态创建的对象属于子类,它们将不会被基析构函数在调用删除时恢复。 BLUURRK 你玩得很好! (也很难找到。)
              猜你喜欢
              • 2014-02-02
              • 2014-08-05
              • 1970-01-01
              • 1970-01-01
              • 2010-10-12
              • 2020-11-08
              相关资源
              最近更新 更多