使用非虚拟析构函数有什么特定的原因吗?

21

据我所知,任何被指定为有子类的类都应该声明为虚析构函数,这样当通过指针访问它们时,类实例可以被正确地销毁。

但是为什么可以声明非虚析构函数的类呢?我相信编译器可以决定何时使用虚析构函数。那么,这是C++设计上的疏忽还是我漏掉了什么?


+1. 我想问一个类似的问题:如果一个基类有一个virtual函数,那么为什么我们 仍然需要 将析构函数声明为虚函数?为什么编译器不能自动将其视为虚函数? - Nawaz
当派生类的析构函数什么也不做时。 - Vinayak Garg
请参阅雷蒙德·陈的博客 - user703016
请参见https://dev59.com/s2s05IYBdhLWcg3wLO2Q。 - Raedwald
5个回答

20
有什么理由需要使用非虚析构函数吗?
是的,有。
主要是出于性能考虑。一个虚函数无法被内联化,而是需要先确定正确的函数调用方式(这需要运行时信息),然后调用该函数。
在性能敏感的代码中,没有代码和“简单”的函数调用之间的差异可能会有所不同。与许多语言不同,C++不认为这种差异微不足道。
但是为什么可以声明具有非虚析构函数的类呢?
因为(对于编译器来说)很难知道该类是否需要虚析构函数。
当需要虚析构函数时:
- 通过基类调用派生对象上的 `delete` - 当编译器看到类定义时,它不能知道您打算从这个类派生 - 毕竟,您可以从没有虚方法的类派生。 - 更重要的是,它不能知道您打算在此类上调用 `delete`。
许多人认为多态性需要实例化才能应用,这只是缺乏想象力:
class Base { public: virtual void foo() const = 0; protected: ~Base() {} };

class Derived: public Base {
  public: virtual void foo() const { std::cout << "Hello, World!\n"; }
};

void print(Base const& b) { b.foo(); }

int main() {
  Derived d;
  print(d);
}

在这种情况下,在销毁时不涉及多态,因此无需支付虚拟析构函数的代价。

最终,这是一种哲学问题。在实际情况下,C++默认选择性能和最小服务(主要例外是RTTI)。


关于警告。有两个警告可以用来发现问题:

  • -Wnon-virtual-dtor(gcc,Clang):警告每当具有虚函数的类没有声明虚析构函数时,除非在基类中的析构函数被标记为protected。这是一种悲观的警告,但至少你不会错过任何东西。

  • -Wdelete-non-virtual-dtor(Clang,也已移植到gcc中):警告每当使用delete删除具有虚函数但没有虚析构函数的类的指针时,除非标记该类为final。它有0%的误报率,但"晚了"(可能多次警告)。


@Nawaz:感谢您的提醒,这让我得以编辑并注意到gcc现在已经采纳了我的小警告 :) - Matthieu M.

3

2
您的问题基本上是这样的,“如果类有任何虚成员,为什么C++编译器不会强制将析构函数设置为虚函数?” 这个问题背后的逻辑是,应该在打算从中派生的类中使用虚析构函数。
有许多原因解释为什么C++编译器没有试图超越程序员的思维。
  1. C++的设计原则是“一分钱一分货”。 如果您想要某些东西是虚拟的,必须明确地要求。 每个类中的虚函数都必须明确声明(除非它覆盖了基类版本)。

  2. 如果具有虚成员的类的析构函数自动设置为虚函数,那么如果您希望使其成为非虚函数,您将如何选择? C++没有能力显式地声明方法为非虚函数。 那么您将如何覆盖此由编译器驱动的行为。

    虚类具有非虚析构函数的特定有效用例吗? 我不知道。 也许某个地方有一个退化案例。 但是,如果您出于某种原因需要它,则无法在您的建议下说出它。

您真正应该问自己的问题是,为什么更多的编译器在具有虚成员的类没有虚析构函数时不发出警告。 毕竟,这就是警告的用途。

我有点同意警告可能是个好主意,但实际上你也会有人因为它们感到困惑和/或抱怨,就像GCC的“类具有虚函数和可访问的非虚析构函数”的情况一样:https://dev59.com/bm025IYBdhLWcg3wvIko;不确定解决办法是什么-编译器给出的建议和理由?“请编写安全的代码”,或者引用我之前发布的GotW指南第4条的内容。 :-) - Matt

1

当一个类毕竟是非虚拟的时候(注意1),非虚析构函数似乎很有意义。

然而,我并没有看到其他好用处来使用非虚析构函数。

我很欣赏这个问题。非常有趣的问题!

编辑:

注意1:在性能关键的情况下,使用没有任何虚函数表的类可能是有利的,因此根本没有任何虚析构函数。

例如:想想只包含三个浮点值的class Vector3。 如果应用程序存储它们的数组,则该数组可以以紧凑的方式存储。

如果我们需要虚函数表,并且如果我们甚至需要在堆上进行存储(如Java&co.),则该数组将仅包含指向实际元素“SOMEWHERE”的指针。

编辑2:

我们甚至可能具有没有任何虚方法的类继承树。

为什么?

因为即使“具有“虚拟”方法似乎是常见和可取的情况”,但它并不是我们-人类-所能想象的唯一情况。

和这门语言的许多细节一样,C++ 提供了选择。你可以选择其中一个提供的选项,通常你会选择其他人选择的选项。但有时候你不想要那个选项!

在我们的例子中,一个名为 Vector3 的类可以继承自 Vector2 类,并且仍然不需要虚函数调用的开销。尽管如此,那个例子并不是很好 ;)


1

我在这里没有看到提到的另一个原因是DLL边界:您希望使用相同的分配器释放对象,以便与用于分配它的分配器相同。

如果方法存在于DLL中,但客户端代码使用直接new实例化对象,则客户端的分配器用于获取对象的内存,但对象填充了来自DLL的vtable,该vtable指向使用DLL链接的分配器来释放对象的析构函数。

当在客户端中从DLL子类化类时,问题就消失了,因为不使用DLL中的虚拟析构函数。


析构函数不会释放内存。它是被释放内存的函数所调用的。如果类重载了new()和delete()运算符,那么你的答案可能是正确的,但否则我认为不是这样。 - mjfgates
1
如果派生类重载了 operator delete,那么通过基类指针销毁对象的代码就不知道这一点,因此你要么发明一个机制让析构函数返回内存是否已经被释放,要么让析构函数直接调用释放函数。G++ 和 MSVC 都采用后者。 - Simon Richter

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