【问题标题】:Is it safe to make destructor not virtual, and delete base pointer in special circumstances?使析构函数不是虚拟的,在特殊情况下删除基指针是否安全?
【发布时间】:2019-09-05 16:14:35
【问题描述】:

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

在派生类only中声明析构函数时的规则是什么?据我了解,所有的析构函数都是同一个函数,我们可以调用它 destructor() 然后当我们删除一个基指针时,析构函数只为基类调用,但是当删除派生类时,析构函数也会被分派到子派生类中。

【问题讨论】:

  • 我相信这是未定义的行为。如果用-fsanitize=undefined编译,那么通过基类删除的时候应该会有发现。
  • 问题 1:我用 valgrind 尝试过,只有当我在 AVL_Node 中动态分配一些内存时它才会报告内存泄漏(但这并不意味着你不会与其他编译器发生泄漏)。 Q2:我认为您误解了虚拟 DTors 的概念。基类应始终具有虚拟 DTor,因为如果您在指向 Base 的指针上调用 delete,编译器只知道它应该删除 Base,它不知道您创建的类型(可能是派生的,更复杂的)可能是什么new.

标签: c++ virtual destructor


【解决方案1】:

逃跑

在没有虚拟析构函数时通过指向基的指针删除派生对象是未定义的行为。无论派生类型多么简单,这都是正确的。

现在,在运行时,每个编译器都会将delete foo 转换为“查找析构函数代码,运行它,然后清理内存”。但是您不能基于编译器发出的运行时代码来理解 C++ 代码的含义。

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

您甚至去测试它,然后查看生成的程序集,一切正常!你得出的结论是这里没有问题。

你错了。

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

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

您的 C++ 程序在 C++ 标准指定的“抽象机器”中的含义事项。优化和代码转换正是在那个抽象机器中发生的。了解孤立的 sn-p 代码是如何在您的物理机器上发出的,并不能告诉您该 sn-p 代码的作用。

这是一个具体的例子:

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;
}

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

在其中,我们基于cond1 创建一个指向BarFoo 的指针。

那么我们可能会做一些危险的事情。

最后,如果cond2 为真,我们清理Foo* foo

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

但如果foo 是一个指向实际Foo 的指针,那么显然cond1 必须为假,因为只有当它为假时,foo 才会指向实际的@987654339 @。

因此,从逻辑上讲,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 分支进行逻辑反向传播,但编译器会使用 other 种类做类似的事情UB,甚至像有符号整数溢出这样看似无害的事情。

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

逃跑

【讨论】:

    【解决方案2】:

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

    您可能不知道,但这是两个不同的问题。

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

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

    当您编写像delete mynode; 这样的代码时,编译器必须确定要调用哪个析构函数。如果mynode 的析构函数不是虚拟的,那么它将始终使用基析构函数,执行基析构函数需要做的任何事情,而不是派生析构函数需要做的任何事情。

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

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

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

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

    一般来说,如果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析构函数的类),但是作为一个程序员,你保证你永远不会引用@的实例987654337@ 带有一个A 指针,那么你就没有什么可担心的了。因此,在这种情况下,就像我的示例试图表明的那样,可能有正当理由声明派生类的析构函数 virtual 而让超类的析构函数不是virtual

    【讨论】:

    • 感谢#2,std::unique_ptr&lt;B&gt; c3 = std::make_unique&lt;C&gt;(); //Safe; B has a virtual destructor 部分回答了它。
    • 这假定描述了未定义行为导致的行为。这不是一个好计划;这是未定义的行为。编译器可以做任何事情并且仍然是合规的。它可能会假设您的代码由于 UB 而无法访问,并随着时间的推移进行反向优化以排除调用您的函数的可能性。相反,它说“好吧,它可能会在不同的情况下泄漏”;不,它可能会导致您的程序在 OP 的确切情况下将您的浏览器历史记录通过电子邮件发送给您的岳母,并且仍然是一个兼容的 C++ 程序。特定编译器的特定版本?你可以得出不同的结论。
    • @Yakk-AdamNevraumont “编译器可以做任何事情并且仍然是合规的。” 这是我经常看到的论点之一,它是一个论点那是“真”(小写-t)而不是“准确”(大写-A)。事实上,编译器不能“随心所欲”,并且准确地指出,大多数编译器下的行为是众所周知的,这并没有损害任何人对未定义行为的理解。访问联合的非活动成员在 C++ 中是未定义的行为,但我们仍然经常支持明确使用基于联合的类型双关语的答案。
    • @Xirema 你可能会投票;我不会没有描述为什么该操作是危险的以及为什么他们认为它是“安全”和有效的答案。你不这样做;不说“不,那是错误的”的人可能会被误导,认为通过指向基类的指针删除只添加微不足道的类型的派生是完全安全的。编译器已经并且确实使用了 UB 检测来消除分支的可能性;证明一个特定的编译器不可能用delet-through-base来做到这一点需要很多时间,更不用说所有编译器了从现在到时间的尽头。
    • @Yakk-AdamNevraumont “你不这样做” 我的回答很清楚,这种做法是不安全的。我什至在答案的开头加粗了那部分。您甚至阅读了整个答案吗?
    猜你喜欢
    • 1970-01-01
    • 2020-12-27
    • 2017-08-08
    • 2021-01-31
    • 2021-02-27
    • 1970-01-01
    • 2017-06-02
    • 2010-10-30
    相关资源
    最近更新 更多