为什么在C++中的抽象类中应该声明虚析构函数?

177

我知道在C++中为基类声明虚析构函数是一种良好的编程实践,但即使作为接口的抽象类,也有必要声明virtual析构函数吗?请提供一些原因和示例。

7个回答

216

对于一个接口来说,这一点更加重要。你的类的任何用户可能会持有指向接口而非具体实现的指针。当他们试图删除它时,如果析构函数不是虚函数,它们将调用接口的析构函数(或者如果你没有指定默认值,则调用编译器提供的默认值),而不是派生类的析构函数。这将导致内存泄漏。

例如

class Interface
{
   virtual void doSomething() = 0;
};

class Derived : public Interface
{
   Derived();
   ~Derived() 
   {
      // Do some important cleanup...
   }
};

void myFunc(void)
{
   Interface* p = new Derived();
   // The behaviour of the next line is undefined. It probably 
   // calls Interface::~Interface, not Derived::~Derived
   delete p; 
}

4
delete p 调用未定义的行为。不能保证调用 Interface::~Interface - Mankarse
@Mankarse:你能解释一下是什么导致它变成未定义的吗?如果Derived没有实现自己的析构函数,那么它是否仍然是未定义行为? - Ponkadoodle
16
由于 [expr.delete]/ 规定:“如果要删除的对象的静态类型与其动态类型不同,则静态类型必须具有虚析构函数,否则行为未定义......”,所以这段代码是未定义的。即使派生类使用了隐式生成的析构函数,情况仍然是如此。 - Mankarse
5
由于这是最佳答案,我想进一步澄清一下。这里的解决方法是在Interface类中添加virtual ~Interface() - snibbe

38
你的问题的答案通常是肯定的,但并非总是如此。如果你的抽象类禁止客户端调用指向它的指针的删除操作(或者如果在其文档中这样说明),则可以自由地不声明虚析构函数。
你可以通过使其析构函数protected来防止客户端在指向该指针时调用删除操作。这样做是完全安全和合理的,可以省略虚析构函数。
最终你将没有虚方法表,并通过指向它的指针将其标记为不可删除,因此在这些情况下确实有理由不声明虚析构函数。
[见本文第4条:http://www.gotw.ca/publications/mill18.htm]

使你的答案正常工作的关键是“在其中没有调用删除的地方”。通常,如果您有一个旨在作为接口的抽象基类,则将在接口类上调用删除。 - John Dibling
正如上面的John所指出的,你提出的建议非常危险。你依赖于这样一个假设,即接口的客户端只知道基本类型,就不会销毁对象。如果它是非虚拟的,你唯一能保证的方法就是将抽象类的析构函数设置为受保护的。 - Michel
Michel,我已经说过了 :) “如果你这样做,你会使你的析构函数受保护。如果你这样做,客户端将无法使用指向该接口的指针进行删除。”实际上它并不依赖于客户端,但它必须强制执行告诉客户端“你不能这样做……”。我没有看到任何危险。 - Johannes Schaub - litb
我现在修正了我的回答的措辞不当。现在它明确说明它不依赖于客户端。实际上,我认为依赖客户端做某事是行不通的,这是显而易见的。谢谢 :) - Johannes Schaub - litb
2
提到受保护的析构函数,这是解决删除指向基类的指针时意外调用错误析构函数的另一种“出路”,值得加1。 - j_random_hacker

25

我决定进行一些研究并尝试总结您的答案。以下问题将帮助您决定需要哪种析构函数:

  1. 您的类是否被设计用作基类?
    • 否:声明公共非虚析构函数以避免每个对象上的虚指针 *
    • 是:阅读下一个问题。
  2. 您的基类是否为抽象类?(即是否有任何虚纯方法?)
    • 否:尝试通过重新设计类层次结构使其成为抽象类
    • 是:阅读下一个问题。
  3. 您想允许通过基类指针进行多态删除吗?
    • 否:声明受保护的虚析构函数以防止不必要的使用。
    • 是:声明公共虚析构函数(在这种情况下没有额外开销)。

希望这可以帮到您。

*重要的是要注意,在C++中没有标记类为final(即不可子类化)的方法,因此如果您决定将析构函数声明为非虚和公共的,则请明确警告您的同事不要从您的类派生。

参考文献:


12
这个答案部分过时了,C++现在有一个final关键字。 - Étienne

11

是的,这非常重要。派生类可能会分配内存或持有对其他资源的引用,在对象销毁时需要清理这些资源。如果您不为接口/抽象类提供虚拟析构函数,则每次通过基类句柄删除派生类实例时,都不会调用派生类的析构函数。

因此,您会打开潜在的内存泄漏风险。

class IFoo
{
  public:
    virtual void DoFoo() = 0;
};

class Bar : public IFoo
{
  char* dooby = NULL;
  public:
    virtual void DoFoo() { dooby = new char[10]; }
    void ~Bar() { delete [] dooby; }
};

IFoo* baz = new Bar();
baz->DoFoo();
delete baz; // memory leak - dooby isn't deleted

事实上,在那个例子中,可能不仅会出现内存泄漏,还可能导致崩溃 :-/ - Evan Teran

7

这并非总是必须的,但我认为这是一种好的实践方法。它的作用是允许通过基类型的指针安全地删除派生对象。

例如:

Base *p = new Derived;
// use p as you see fit
delete p;

如果Base没有虚析构函数,那么这个语句就是不合法的,因为它会试图像处理Base指针一样删除对象。

你不想把 boost::shared_pointer p(new Derived) 改成 boost::shared_pointer<Base> p(new Derived); 这样人们就能理解你的回答并投票了吗? - Johannes Schaub - litb
编辑:像 litb 建议的那样编码了一些部分,以使角括号可见。请提供要翻译的具体内容。 - j_random_hacker
@EvanTeran:我不确定自从最初发布答案以来是否有所改变(http://www.boost.org/doc/libs/1_52_0/libs/smart_ptr/shared_ptr.htm上的Boost文档表明可能已经改变),但现在`shared_ptr`不会像`Base *`一样尝试删除对象了 - 它会记住您创建它时的类型。请参见引用链接,特别是其中的“即使T没有虚析构函数或为void,析构函数也将使用相同的指针调用delete,包括其原始类型。” - Stuart Golodetz
@StuartGolodetz:嗯,你可能是对的,但我真的不确定。由于缺少虚析构函数,在这个上下文中它可能仍然是不规范的。值得进一步研究。 - Evan Teran
@EvanTeran:如果有帮助的话 - https://dev59.com/Ym865IYBdhLWcg3wR8ah。 - Stuart Golodetz
足够好,让我信服了,我会在这里编辑我的答案以反映这一点 :-) - Evan Teran

5

这不仅是良好的实践,而且是任何类层次结构的第一规则。

  1. C++中层次结构的基类必须有一个虚析构函数。

现在来说为什么。以典型的动物层次结构为例。虚析构函数通过虚分派和其他方法调用一样。看以下示例。

Animal* pAnimal = GetAnimal();
delete pAnimal;

假设Animal是一个抽象类。C++知道调用正确的析构函数的唯一方法是通过虚方法分派。如果析构函数不是虚函数,则只会调用Animal的析构函数,而不会销毁任何派生类中的对象。
在基类中将析构函数设置为虚函数的原因是它简单地从派生类中删除了选择。它们的析构函数默认情况下变成虚函数。

2
大部分同意你的观点,因为通常在定义层次结构时,您希望能够使用基类指针/引用来引用派生对象。但并不是总是如此,在那些其他情况下,将基类的析构函数设为protected也可能足以满足需求。 - j_random_hacker
@j_random_hacker,将其设置为protected并不能保护您免受不正确的内部删除操作的伤害。 - JaredPar
1
@JaredPar:没错,但至少你可以在自己的代码中负责--困难之处是要确保客户端代码不会导致你的代码崩溃。 (同样,将数据成员设置为私有并不能防止内部代码对该成员进行愚蠢的操作。) - j_random_hacker
@j_random_hacker,很抱歉只能用博客文章来回复你,但它确实适用于这种情况。http://blogs.msdn.com/jaredpar/archive/2008/03/24/part-of-being-a-good-programmer-is-learning-not-to-trust-yourself.aspx - JaredPar
@JaredPar:非常好的文章,我完全同意你的观点,特别是在检查零售代码中的合同方面。我只是想说,在某些情况下,你知道你不需要虚拟析构函数。例如:用于模板分派的标签类。它们大小为0,您只使用继承来指示特定情况。 - j_random_hacker
@j_random_hacker,我赞同你的看法。例如,如果您控制着一个内部 API,那么最终可以达到一定程度的信心,即不需要文档。但是,如何将这种约束条件传达给未来的同事?这是我面临的问题。 - JaredPar

4
答案很简单,你需要使它成为虚拟的,否则基类将不是完整的多态类。
    Base *ptr = new Derived();
    delete ptr; // Here the call order of destructors: first Derived then Base.

您可能更喜欢上面的删除方式,但如果基类的析构函数不是虚函数,则只会调用基类的析构函数,派生类中的所有数据都将保持未删除状态。


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