什么时候不使用虚拟析构函数?


48

我相信我搜索了许多有关虚拟析构函数的内容,其中大多数提到虚拟析构函数的目的以及为什么需要虚拟析构函数。我还认为,在大多数情况下,析构函数必须是虚拟的。

然后的问题是:为什么c ++默认不将所有析构函数设置为虚拟?或其他问题:

什么时候不需要使用虚拟析构函数?

在这种情况下,我不应该使用虚拟析构函数?

即使我不需要虚拟析构函数,使用虚拟析构函数的成本是多少?


6
如果不应该继承您的类怎么办?查看许多标准库类,很少有虚拟函数,因为它们不是为继承而设计的。
2015年

4
我还认为,在大多数情况下,析构函数必须是虚拟的。不。一点也不。只有那些滥用继承权(而不是赞成继承权)的人才会这样认为。我看到整个应用程序只有少数几个基类和虚函数。
Matthieu M.

1
@underscore_d对于典型的实现,除非所有这样的隐式内容都已进行了虚拟化和优化,否则将为任何多态类生成额外的代码。在常见的ABI中,每个类至少涉及一个vtable。类的布局也必须更改。一旦将此类发布为某些公共接口的一部分,您就无法可靠地返回,因为再次更改它会破坏ABI兼容性,因为通常很难(如果有可能)期望将虚拟化作为接口合同。
FrankHB

1
@underscore_d“在编译时”这个短语是不准确的,但是我认为这意味着虚构的析构函数既不琐碎,也没有指定constexpr,因此很难避免额外的代码生成(除非您完全完全避免破坏此类对象)这或多或少会损害运行时性能。
FrankHB

2
@underscore_d“指针”似乎是红色鲱鱼。它可能应该是指向成员的指针(按定义不是指针)。对于普通的ABI,指向成员的指针通常不适合一个机器字(作为典型的指针),并且将类从非多态转换为多态通常会更改指向该类成员的指针的大小。
FrankHB

Answers:


41

如果将虚拟析构函数添加到类中:

  • 在大多数(所有)当前C ++实现中,该类的每个对象实例都需要为运行时类型存储指向虚拟调度表的指针,并且该虚拟调度表本身已添加到可执行映像中

  • 虚拟调度表的地址不一定跨进程有效,这可能会阻止在共享内存中安全地共享此类对象

  • 具有一个嵌入式虚拟指针使创建类的内存布局与某些已知的输入或输出格式相匹配(例如,因此a Price_Tick*可以直接针对传入的UDP数据包中适当对齐的内存,并用于解析/访问或更改数据),或者放置new此类以将数据写入输出数据包)

  • 在某些条件下,销毁者自己调用可能必须虚拟地调度,因此是脱机的;而非虚拟销毁者可能对于调用者而言琐碎或无关紧要地进行内联或优化

如果“以非继承的方式进行设计”并非如上述那样在实际情况下也较差,那么它也不是始终不具有虚拟析构函数的实际原因。但更糟糕的是,这是何时支付费用的主要标准:如果您的类打算用作基类,则默认为使用虚拟析构函数。这并非总是必要的,但是如果使用基类指针或引用调用派生类析构函数,则可以确保继承体系中的类可以更自由地使用,而不会发生意外的未定义行为。

“在大多数情况下,析构函数必须是虚拟的”

并非如此……许多班级都没有这种需要。有太多的例子说明了在不必要的地方枚举它们是很愚蠢的,但是只要浏览一下标准库或说boost,就会发现大多数类没有虚拟析构函数。在提升1.53中,我计算了494个虚拟析构函数中的72个。


23

在这种情况下,我不应该使用虚拟析构函数?

  1. 对于不想被继承的具体类。
  2. 对于没有多态删除的基类。客户端都不能使用指向Base的指针来多态删除。

顺便说一句,

在哪种情况下应使用虚拟析构函数?

对于具有多态删除的基类。


7
为#2 +1,特别是没有多态删除。如果您的析构函数永远无法通过基本指针进行调用,则将其虚拟化是不必要且多余的,尤其是如果您的类以前不是虚拟的(因此,它在RTTI中变得newly肿了)。为了防止任何用户违反此规定,正如Herb Sutter所建议的那样,您应使基类的dtor受保护且是非虚拟的,以便只能由派生的析构函数/在派生的析构函数之后调用它。
underscore_d

@underscore_d恕我直言,这是我错过了答案,在继承的情况下,我不需要一个虚拟的构造函数时,我可以确保它永远不会唯一需要的情况下,重要的一点
formerlyknownas_463035818

14

即使我不需要虚拟析构函数,使用虚拟析构函数的成本是多少?

任何虚函数引入类(继承或类定义的一部分)的成本对于每个对象存储的虚拟指针而言,其初始成本可能非常高(或不取决于对象),如下所示:

struct Integer
{
    virtual ~Integer() {}
    int value;
};

在这种情况下,存储器成本相对较大。现在,在64位体系结构上,类实例的实际内存大小通常看起来像这样:

struct Integer
{
    // 8 byte vptr overhead
    int value; // 4 bytes
    // typically 4 more bytes of padding for alignment of vptr
};

此类的总数为16个字节,Integer而仅有4个字节。如果将一百万个这样的内存存储在一个阵列中,最终将占用16兆字节的内存:这是典型的8 MB L3 CPU高速缓存大小的两倍,而反复循环访问此类数组可能会比4兆字节慢很多倍。由于额外的高速缓存未命中和页面错误而导致没有虚拟指针。

但是,随着更多的虚拟功能,每个对象的虚拟指针成本不会增加。一个类中可以有100个虚拟成员函数,每个实例的开销仍然是单个虚拟指针。

从开销的角度来看,虚拟指针通常是更直接的关注​​点。但是,除了虚拟指针之外,每个实例还需要支付每个类的费用。每个具有虚拟函数的类都会vtable在内存中存储一个内存,该地址将在进行虚拟函数调用时实际应调用的函数(虚拟/动态调度)。然后,vptr每个实例存储的指向该特定于类的vtable。通常,这种开销较少,但是如果不必要地为复杂代码库中的一千个类支付了这种开销,则可能会增加二进制文件的大小并增加一点运行时成本,例如,这vtable方面的开销实际上会随着更多和更多部分成比例地增加。混合了更多虚拟功能。

在性能至关重要的领域工作的Java开发人员非常了解这种开销(尽管通常在装箱说明中进行了描述),因为Java用户定义的类型隐式继承自中央object基类并且Java中的所有函数都是隐式虚拟的(可重写的) ),除非另有说明。结果,由于每个实例相关联Integer的这种vptr样式元数据,Java 同样倾向于在64位平台上需要16个字节的内存,并且在Java中通常不可能在int不付出运行时的情况下将单个对象包装到类中性能成本。

然后的问题是:为什么c ++默认不将所有析构函数设置为虚拟?

C ++确实以“随用随付”的心态来支持性能,并且仍然从C继承了许多裸机硬件驱动的设计。它不想不必要地包括vtable生成和动态分配所需的开销。每个涉及的类/实例。如果性能不是使用C ++这样的语言的关键原因之一,那么您可能会从其他编程语言中受益更多,因为许多C ++语言的安全性和难度要比理想情况下的理想状态要差得多。支持这种设计的关键原因。

什么时候不需要使用虚拟析构函数?

经常。如果一个类不被设计为继承的,那么它就不需要虚拟的析构函数,而最终只会为不需要的东西付出可能的大笔开销。同样,即使将一个类设计为继承的,但您绝不会通过基指针删除子类型实例,那么它也不需要虚拟析构函数。在这种情况下,一种安全的做法是定义一个受保护的非虚拟析构函数,如下所示:

class BaseClass
{
protected:
    // Disallow deleting/destroying subclass objects through `BaseClass*`.
    ~BaseClass() {}
};

在这种情况下,我不应该使用虚拟析构函数?

实际上,当您应该使用虚拟析构函数时,它更容易涵盖。通常,代码库中的更多类都不是为继承而设计的。

std::vector,例如,不是设计为可以继承的,并且通常不应该被继承(非常不稳定的设计),否则std::vector,除了笨拙的对象切片问题之外,这还会导致此基本指针删除问题(故意避免使用虚拟析构函数)派生类会添加任何新状态。

通常,继承的类应该具有公共虚拟析构函数或受保护的非虚拟析构函数。从C++ Coding Standards第50章开始:

50.使基类析构函数成为公共的和虚拟的,或受保护的和非虚拟的。删除,不删除 问题是:如果应该允许通过指向基本Base的指针进行删除,则Base的析构函数必须是公共的和虚拟的。否则,应该对其进行保护并且是非虚拟的。

C ++倾向于隐含地强调的一件事(因为设计往往变得非常脆弱,笨拙,甚至可能不安全),这是这样的想法,即继承不是旨在用作事后思考的机制。这是一种考虑了多态性的可扩展性机制,但它需要对需要扩展性的地方具有远见。因此,您的基类应被设计为预先继承层次结构的根,而不是您事后从事后继承的东西,而无需事先进行任何此类预见。

在那些您只想继承以重用现有代码的情况下,通常强烈建议进行组合(复合重用原理)。


9

为什么C ++默认不将所有析构函数设置为虚拟? 额外存储和调用虚拟方法表的成本。C ++用于可能会造成负担的系统,低延迟,rt编程。


硬实时系统中不应首先使用析构函数,因为不能使用诸如动态内存之类的许多资源来提供强大的期限保证
Marco

9
@MarcoA。从何时起析构函数暗示动态内存分配?
chbaker0 2015年

@ chbaker0我使用了“赞”。根据我的经验,根本就没有使用它们。
Marco A.

6
不能在硬实时系统中使用动态内存也是胡说八道。证明具有固定分配大小和分配位图的预配置堆将在扫描该位图所需的时间内分配内存或返回内存不足的条件是相当琐碎的。
MSalters 2015年

@msalters确实让我觉得:想象一个程序,其中每个操作的成本都存储在类型系统中。允许实时保证的编译时检查。
Yakk

5

这是何时不使用虚拟析构函数的一个很好的例子:来自Scott Meyers:

如果一个类不包含任何虚函数,则通常表明它不打算用作基类。当不打算将一个类用作基类时,将析构函数虚拟化通常不是一个好主意。考虑以下示例,基于ARM中的讨论:

// class for representing 2D points
class Point {
public:
    Point(short int xCoord, short int yCoord);
    ~Point();
private:
    short int x, y;
};

如果short int占用16位,则Point对象可以放入32位寄存器中。此外,Point对象可以作为32位量传递给用其他语言(例如C或FORTRAN)编写的函数。但是,如果将Point的析构函数设为虚拟,情况将发生变化。

添加虚拟成员后,虚拟指针即被添加到您的类中,该指针指向该类的虚拟表。


If a class does not contain any virtual functions, that is often an indication that it is not meant to be used as a base class.t 还有其他人还记得“旧时光”吗?在那儿,我们被允许使用类和继承来构建可重复使用的成员和行为的连续层,而根本不必关心虚拟方法?来吧,斯科特。我明白了核心要点,但是“常”确实可以实现。
underscore_d

3

虚拟析构函数会增加运行时成本。如果该类没有任何其他虚拟方法,则代价尤其巨大。仅在一种特定情况下才需要虚拟析构函数,在该特定情况下,通过指向基类的指针删除或破坏对象。在这种情况下,基类的析构函数必须是虚的,并且任何派生类的析构函数都将是隐式虚的。在某些情况下,使用多态基类的方式不需要析构函数是虚拟的:

  • 如果派生类的实例未分配在堆上,例如仅直接在堆栈上或在其他对象内部。(除非您使用未初始化的内存和new的放置运算符。)
  • 如果在堆上分配了派生类的实例,但是删除仅通过指向最派生类的指针发生,例如存在一个std::unique_ptr<Derived>,并且多态性仅通过非拥有的指针和引用发生。另一个示例是使用分配对象时std::make_shared<Derived>()std::shared_ptr<Base>只要初始指针是a ,就可以使用它std::shared_ptr<Derived>。这是因为共享指针对析构函数(删除程序)具有自己的动态分配,这些动态分配不一定依赖于虚拟基类的析构函数。

当然,仅以上述方式使用对象的任何约定都可以轻易打破。因此,赫伯·萨特(Herb Sutter)的建议仍然一如既往:“基类析构函数应该是公共的和虚拟的,或者应该是受保护的并且是非虚拟的”。这样,如果有人尝试删除具有非虚拟析构函数的基类的指针,则他很可能在编译时收到访问冲突错误。

再有,有些类并非设计为(公共)基类。我个人的建议是使它们final使用C ++ 11或更高版本。如果将其设计为方形钉,则可能无法很好地用作圆形钉。这与我倾向于在基类和派生类之间具有显式继承协定,对于NVI(非虚拟接口)设计模式,用于抽象而不是具体的基类以及我不喜欢受保护的成员变量等有关。 ,但我知道所有这些观点都在一定程度上引起争议。


1

virtual仅在计划使其class可继承时,才需要声明析构函数。通常,标准库的类(例如std::string)不提供虚拟析构函数,因此不打算用于子类化。


3
原因是子类化+多态性的使用。仅当需要动态解析时才需要虚拟析构函数,即对主类的引用/指针/无论其实际上可以引用子类的实例。
米歇尔·比洛

2
@MichelBillaud实际上,如果没有虚拟dtor,您仍然可以具有多态性。多态删除仅需要虚拟dtor,即调用delete指向基类的指针。
chbaker0

1

创建vtable的构造函数中会产生开销(如果您没有其他虚函数,则在这种情况下,您可能(但并非总是)也应该具有虚拟析构函数)。而且,如果您没有任何其他虚函数,它会使您的对象的指针大小比其他情况大一个。显然,增大尺寸会对小物体产生很大影响。

读取了额外的内存以获取vtable,然后通过该内存调用indirectory函数,这在调用析构函数时比非虚拟析构函数要大。因此,当然,每次对析构函数的调用都会生成一些额外的代码。这是针对编译器无法推断实际类型的情况-在那些可以推断实际类型的情况下,编译器将不使用vtable,而是直接调用析构函数。

应该有一个虚析构函数,如果你的类的目的是作为一个基类,特别是如果它可以创建/通过其他一些实体比知道它是什么类型的创作,那么你需要一个虚拟析构函数的代码破坏。

如果不确定,请使用虚拟析构函数。如果将虚拟显示为问题,则将其删除要比尝试查找“未调用正确的析构函数”引起的错误要容易得多。

简而言之,在以下情况下,您不应具有虚拟析构函数:1.您没有任何虚拟函数。2.不要从类派生(final在C ++ 11中标记它,那样编译器会告诉您是否尝试从它派生)。

在大多数情况下,除非有“大量内容”,否则创建和销毁不是使用特定对象所花费时间的主要部分(创建1MB字符串显然要花费一些时间,因为至少1MB的数据需要从当前所在的位置复制)。销毁1MB的字符串并不比销毁150B的字符串更糟,两者都需要取消分配字符串存储空间,并且不需要太多其他操作,因此花费的时间通常是相同的。 “毒药模式”-但这不是您要在生产中运行实际应用程序的方式]。

简而言之,开销很小,但是对于较小的对象,这可能会有所作为。

还要注意,在某些情况下,编译器可以优化虚拟查找,因此这只是一个惩罚。

与往常一样,在性能,内存占用等方面:进行基准测试,性能分析和测量,将结果与替代方法进行比较,查看在时间/内存上花费的大部分时间,而不要尝试优化90%的时间/内存。运行很少的代码(大多数应用程序大约有10%的代码对执行时间有很大影响,而90%的代码根本没有太大影响)。以较高的优化级别执行此操作,因此您已经受益于编译器的出色表现!并重复一次,再次检查并逐步改善。除非您在特定类型的应用程序上有丰富的经验,否则不要试图变得聪明,不要弄清楚什么是重要的,什么不是什么。


1
“这将是创建vtable的构造函数中的开销” -编译器通常在每个类的基础上“创建” vtable,而构造函数仅具有将指针存储在正在构造的对象实例中的开销。
托尼

另外...我全都在避免过早的优化,但是相反,这You **should** have a virtual destructor if your class is intended as a base-class是过分简单化和过早的悲观化。仅当允许任何人通过指向基数的指针删除派生类时才需要此方法。在许多情况下并非如此。如果知道的话,那么肯定会产生开销。其中,顺便说一句,是一直增加,即使实际的呼叫可以被编译器静态解析。否则,当您正确控制人们可以对您的对象执行的操作时,这是不值得的
underscore_d
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.