虚析构函数和内存释放

3

我不太确定我理解虚析构函数和在堆上分配空间的概念。让我们看一下以下示例:

class Base
{
public:
    int a;
};

class Derived : public Base
{
public:
    int b;
};

我想象如果我这样做的话。
Base *o = new Derived;

在堆上分配了8个字节(或者系统需要的两个整数),看起来像这样:

... | a | b | ...

现在,如果我这样做:

delete o;

'delete' 如何知道 o 的实际类型以便从堆中删除所有内容?我想它必须假设它是 Base 类型,因此只能从堆中删除 a(因为不能确定 b 是否属于对象 o):... | b | ...

然后 b 将留在堆中且无法访问。

以下代码是否可以解决这个问题:

Base *o = new Derived;
delete o;

这段代码是否真的会引起内存泄漏,我需要在这里使用虚析构函数吗?或者delete知道o实际上是Derived类而不是Base类吗?如果是这样的话,它是如何工作的呢?

谢谢大家。 :)


好问题,但是o应该是一个指针,就像Base* o一样。 - David G
1
你的 delete 调用了未定义的行为。 - Luchian Grigore
@LuchianGrigore 这是唯一正确的答案。你通常回答得很快---这次你应该这样做,因为你是正确的,而我在发布之前看到的两个基本相同的答案是不正确的。(而你的评论真的是你需要知道的全部。) - James Kanze
@JamesKanze 啊,我的 FGITW 时代已经过去了 :) - Luchian Grigore
3个回答

4
您对实现做了很多假设,这些假设可能成立也可能不成立。在一个delete表达式中,动态类型必须与静态类型相同,除非静态类型具有虚析构函数。否则,行为是未定义的。这就是您需要知道的全部内容 - 在某些情况下,否则您使用的实现将崩溃;我使用过一些实现,其中这样做会破坏自由空间区域,以致于代码稍后会在完全不相关的代码中崩溃。(值得一提的是,VC++和g++都属于第二种情况,至少在编译后释放代码的常规选项下)。

所以我的代码引发了未定义的行为。但是我该怎么办呢?哪个更好:a)找出(例如基于标志)指针的类型,并在删除之前进行正确的转换还是b)让Base有一个虚析构函数(如果是这样:Derived需要一个显式析构函数吗,还是可以保持不变?) - user2623674
1
很多情况下,继承的原因取决于你为什么要继承。在某些情况下,“错误”在于一开始就有一个指向基类的指针。(想想std::exception。)但对于通常情况下使用继承来实现多态性的情况,基类应该有一个虚析构函数。 - James Kanze

2

删除对象的大小没有问题 - 这是已知的。虚析构函数解决的问题可以如下所示:

class Base
{
public:
    Base() { x = new char[1]; }
    /*virtual*/ ~Base() { delete [] x; }

private:
    char* x;
};

class Derived : public Base
{
public:
    Derived() { y = new char[1]; }
    ~Derived() { delete [] y;}
private:
    char* y;
};

那么有以下内容:

Derived* d = new Derived();
Base* b = new Derived();

delete d;   // OK
delete b;   // will only call Base::~Base, and not Derived::~Derived

第二个删除操作不会正确地完成对象的销毁。如果取消注释virtual关键字,那么第二个delete语句将按预期执行,并调用Derived::~DerivedBase::~Base
正如评论中指出的那样,严格来说,第二个delete操作产生未定义的行为,但这里仅出于说明虚析构函数的目的而使用它。

2
首先,您在示例中声明的类具有微不足道的内部结构。从纯实用角度来看,为了正确销毁此类对象,运行时代码无需知道正在删除的对象的实际类型,只需要知道要释放的内存块的正确大小。这实际上已经由类似于C风格的库函数(如malloc和free)实现。您可能知道,free隐含地“知道”要释放多少内存。您的示例以上没有涉及任何其他内容。换句话说,您的示例不够详尽,无法真正说明任何特定于C++的内容。
然而,从形式上讲,您的示例的行为是未定义的,因为虚析构函数在C++语言中被正式要求进行多态删除,而不管类的内部结构有多么微不足道。因此,您的“如何知道delete...”问题根本不适用。您的代码有问题。它不起作用。
其次,当您开始要求非微不足道的类的销毁时,实际的具体C++效果开始显现: 通过定义析构函数的显式主体或向类添加非微不足道的成员子对象。例如,如果您向派生类添加std::vector成员,则派生类的析构函数将负责(隐式)销毁该子对象。为了使其工作,您必须声明虚析构函数。适当的虚拟析构函数通过与任何其他虚拟函数调用相同的机制进行调用。这基本上是对您的问题的答案:运行时代码不关心对象的实际类型,因为普通的虚分发机制将确保调用正确的析构函数(就像使用任何其他虚拟函数一样)。
第三,另一个虚拟销毁的重要影响出现在为类定义专用的operator delete函数时。语言规范要求选择适当的operator delete函数,就像它从被删除的类的析构函数内部查找一样。许多实现字面上实现了这个要求:它们实际上从类析构函数内部隐式调用operator delete。为了使该机制正常工作,析构函数必须是虚拟的。
第四,您问题的一部分似乎表明您认为未定义虚拟析构函数会导致“内存泄漏”。这是一个流行但完全不正确且毫无用处的城市传说,由低质量来源延续。在没有虚拟析构函数的类上执行多态删除会导致未定义的行为和完全不可预测的灾难性后果,而不是“内存泄漏”。在这种情况下,“内存泄漏”不是问题。

如果基类的析构函数不是虚函数,那么通过指向基类的指针删除派生对象是未定义的行为。这就是需要说的全部了。实际上,真正的问题在多重继承中开始显现,错误的指针将被传递给operator delete(但这只是大多数编译器布局类的方式的产物——你也可以在单一继承中遇到同样的问题)。 - James Kanze
@JamesKanze 你确定这对于“平凡”和“标准布局”类是正确的吗(这种情况下)?无论编译器想要如何布局,它都必须遵守这些类型类的C结构布局。而在这种情况下,编译器必须将“Base”部分放在“Derived”对象的开头。 - lapk
4
是的。同时,C-结构体布局对派生类没有影响。根据§5.3.5/3,“在第一个选择(删除对象)中,如果操作数的静态类型与其动态类型不同,则静态类型应为操作数的动态类型的基类,并且该静态类型应具有虚拟析构函数,否则行为未定义。”这无疑是非常明确的。 - James Kanze
@JamesKanze 谢谢。感谢您提供的标准引用。 - lapk

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