假设我们有一个类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
。