在特殊情况下使析构函数不是虚拟的,并删除基指针是否安全



假设我们有一个类BST_Node:

struct BST_Node {
BST_Node* left;
BST_Node* right;
}

和一类AVL_Node:

struct AVL_Node : BST_Node {
int height;
}

在某些功能中

void destroyTree() {
BST_Node *mynode = new AVL_Node;
delete mynode; //  Is it ok ?
}

问题 #1

当析构函数是非虚拟的,但派生中只有基元类型时,在基类上调用 delete 是否安全?(不会有内存泄漏吗?

问题 #2

仅在派生类中声明析构函数虚拟时的规则是什么?据我了解,所有的析构函数都是相同的函数,我们可以称之为 destructor(),然后当我们删除基指针时,仅针对基类调用析构函数,但是在删除派生类时,析构函数也会被调度到子派生类中。

逃跑

在没有虚拟析构函数的情况下,通过指向 base 的指针删除派生对象是未定义的行为。 无论派生类型多么简单,都是如此。

现在,在运行时,每个编译器都会delete foo变成"找到析构函数代码,运行它,然后清理内存"。 但是,您不能根据编译器发出的运行时代码来理解C++代码的含义。

所以你可以天真地认为"我不在乎我们是否运行错误的破坏代码;我唯一添加的是int。 内存清理代码处理过度分配。 所以我们很好!

你甚至去测试它,你看看生产的组件,一切正常! 你得出结论,这里没有问题。

你错了。

编译器做两件事。 首先,发出运行时代码。 其次,他们使用程序的结构来推理它。

第二部分是一个强大的功能,但它也使做未定义的行为变得极其危险。

您的C++程序在"抽象机器"中的含义很重要C++标准。 正是在那个抽象机器中,优化和代码转换发生了。 知道一个孤立的代码片段是如何在你的物理机器上发出的,并不能告诉你这段代码片段的作用。

下面是一个具体的例子:

struct Foo {};
struct Bar:Foo{};
Foo* do_something( bool cond1, bool cond2 ) {
Foo* foo = nullptr;
if (cond1)
foo = new Bar;
else
foo = new Foo;
if (cond2 && !cond1)
inline_code_to_delete_user_folder();
if (cond2) {
delete foo;
foo = nullptr;
}
return foo;
}

这是一个带有一些玩具类型的玩具。

在其中,我们创建了一个指向Bar或基于Foo的指针cond1.

然后我们可能会做一些危险的事情。

最后,如果cond2属实,我们清理Foo* foo

问题是,如果我们调用delete foo并且foo不是Foo,那就是未定义的行为。 编译器可以合法地推理"好的,所以我们调用delete foo,因此*foo是类型Foo的对象"。

但是如果foo是指向实际Foo的指针,那么显然cond1一定是假的,因为只有当它是假的时,foo指向一个实际的Foo

因此,从逻辑上讲,cond2为真意味着cond1为真。 总是。 到处。 追溯。

因此,编译器实际上知道这是程序的合法转换:

Foo* do_something( bool cond1, bool cond2 ) {
if (cond2) {
Foo* foo = new Foo;
inline_code_to_delete_user_folder();
delete foo;
return nullptr;
}       
Foo* foo = nullptr;
if (cond1)
foo = new Bar;
else
foo = new Foo;
return foo;
}

这很危险,不是吗? 我们只是省略了检查cond1,并在您将true传递给cond2时删除了用户文件夹。

我不知道是否有任何当前或未来的编译器使用UB检测来删除错误的类型来对UB分支进行逻辑反向传播,但是编译器确实对其他类型的UB做了类似的事情,甚至是像有符号整数溢出这样看似无害的事情。

为了确保这种情况不会发生,您需要从将编译代码的每个编译器审核每个编译器中的每个优化。

跑掉

当析构函数是非虚拟的,但派生中只有基元类型时,在基类上调用 delete 是否安全?(不会有内存泄漏吗?

您可能没有意识到这一点,但这是两个不同的问题。

后一个答案是:不,此特定示例不会有任何内存泄漏,但其他示例可能会有内存泄漏。

而原因是前一个问题的答案:不,这样做是不安全的。这构成了未定义的行为,即使几乎所有编译器都很好地理解了该行为 - 并且"理解"并不是"安全做"的协同,只是为了清楚。

当你像delete mynode;一样编写代码时,编译器必须弄清楚要调用哪个析构函数。如果mynode的析构函数不是虚拟的,那么它将始终使用基析构函数,执行基本析构函数需要执行的任何操作,但不会执行派生析构函数需要执行的任何操作。

在这种情况下,这没什么大不了的:AVL_Node添加的唯一内容是本地分配的int变量,该变量将作为清理整个指针的同一过程的一部分进行清理。

但是如果你的代码是这样的:

struct AVL_Node : public BST_Node {
std::unique_ptr<int> height = std::make_unique<int>();
};

那么这段代码肯定会导致内存泄漏,即使我们在派生对象的构造中明确使用了智能指针!智能指针并不能使我们免于使用非virtual析构函数delete基本指针的磨难。

通常,如果AVL_Node负责其他对象,则代码可能会导致任何类型的泄漏,包括但不限于资源泄漏、文件句柄泄漏等。例如,考虑一下AVL_Node是否有这样的东西,这在某些类型的图形代码中非常常见:

struct AVL_Node : public BST_Node {
int handle;
AVL_Node() {
glGenArrays(1, &handle);
}
/*
* Pretend we implemented the copy/move constructors/assignment operators as needed
*/
~AVLNode() {
glDeleteArrays(1, &handle);
}
};

您的代码不会泄漏内存(在您自己的代码中),但它会泄漏 OpenGL 对象(以及该对象分配的任何内存)。

仅在派生类中声明析构函数虚拟时的规则是什么?

如果您从不打算存储指向基类的指针,那么这很好。

除非您还计划创建派生类的更多派生实例,否则也没有必要这样做。

因此,为了清楚起见,我们将使用以下示例:

struct A {
std::unique_ptr<int> int_ptr = std::make_unique<int>();
};
struct B : A {
std::unique_ptr<int> int_ptr_2 = std::make_unique<int>();
virtual ~B() = default;
};
struct C : B {
std::unique_ptr<int> int_ptr_3 = std::make_unique<int>();
//virtual ~C() = default; // Unnecessary; implied by B having a virtual destructor
};

现在,以下是与这三个类一起使用的所有安全和不安全的代码:

auto a1 = std::make_unique<A>(); //Safe; a1 knows its own type
std::unique_ptr<A> a2 = std::make_unique<A>(); //Safe; exactly the same as a1
auto b1 = std::make_unique<B>(); //Safe; b1 knows its own type
std::unique_ptr<B> b2 = std::make_unique<B>(); //Safe; exactly the same as b1
std::unique_ptr<A> b3 = std::make_unique<B>(); //UNSAFE: A does not have a virtual destructor!
auto c1 = std::make_unique<C>(); //Safe; c1 knows its own type
std::unique_ptr<C> c2 = std::make_unique<C>(); //Safe; exactly the same as c1
std::unique_ptr<B> c3 = std::make_unique<C>(); //Safe; B has a virtual destructor
std::unique_ptr<A> c4 = std::make_unique<C>(); //UNSAFE: A does not have a virtual destructor!

因此,如果B(具有virtual析构函数的类)继承自A(没有virtual析构函数的类),但作为程序员,您保证永远不会引用带有A指针的B实例,那么您就没有什么可担心的了。因此,在这种情况下,就像我的示例试图展示的那样,可能有正当理由声明派生类的析构函数virtual而使超类的析构函数不virtual

最新更新