【问题标题】:C++ destructor order of member with shared_ptr具有 shared_ptr 的成员的 C++ 析构函数顺序
【发布时间】:2020-05-28 11:09:57
【问题描述】:

如果类A包含类B,那么当A析构时,会先调用B的析构函数,即它们嵌套关系的倒序。

但是如果A 包含B 的shared_ptr,而B 包含指向A 的原始指针,我们应该如何处理析构函数以使其安全?

考虑以下示例:

#include <iostream>
#include <memory>
#include <unistd.h>

struct B;
struct A {
  int i = 1;
  std::shared_ptr<B> b;

  A() : b(std::make_shared<B>(this)) {}

  ~A() {
    b = nullptr;
    std::cout << "A destruct done" << std::endl;
  }
};

struct B {
  A *a;

  B(A *aa) : a(aa) {}

  ~B() {
    usleep(2000000);
    std::cout << "value in A: " << a->i << std::endl;
    std::cout << "B destruct done" << std::endl;
  }
};

int main() {
  std::cout << "Hello, World!" << std::endl;
  {
    A a;
  }
  std::cout << "done\n";
  return 0;
}

你可以在A的析构函数中看到,我明确地将b设置为nullptr,这将立即触发B的析构函数,并阻塞直到它完成。 输出将是:

Hello, World!
value in A: 1
B destruct done
A destruct done
done

但是如果我注释掉那一行

  ~A() {
    // b = nullptr; // <---
    std::cout << "A destruct done" << std::endl;
  }

输出将是:

Hello, World!
A destruct done
value in A: 1
B destruct done
done

似乎A 的析构函数在没有等待B 销毁的情况下完成。但是在这种情况下,我预计会出现段错误,因为当A 已经被破坏时,B 试图访问A 的成员,这是无效的。但是为什么程序不会产生段错误呢?碰巧没问题(即undefined behavior)吗?

还有,当我改变时

 {
    A a;
 }

到

  A * a = new A();
  delete a;

输出还是一样,没有段错误。

【问题讨论】:

  • 破坏以相反的顺序发生。您应该能够遵循这一点并弄清楚会发生什么。这是一个直接的串行过程。顺便说一句;将变量设置为nullptr(或类似)会释放 no 内存。如果你的程序包含UB,那么all都是无效的,anything都是OK的。
  • 当你使用不完整的类型B时,我什至不明白代码如何工作,甚至编译
  • "A destruct done" 有点误导:它在销毁过程中被打印在 dtor 内部。不是在它之后。我看到你之前已经重置了智能指针,所以没有什么重要的事情要做了。但一般来说,在破坏成员和基地之前在体内打印它是一个坏主意(这里没有基地,但我提出一个一般性观点)。
  • 显然,只要您稍后提供完整的定义,GCC 就可以容忍前向声明。这对我来说似乎很可疑。

标签: c++ c++11 shared-ptr


【解决方案1】:

准确了解正在发生的事情很重要。当A被销毁时,以下事件按以下顺序发生:

  • A::~A() 被调用。
  • A 对象的生命周期结束。该对象仍然存在,但不再在其生命周期内。 ([basic.life]/1.3)
  • A::~A() 的主体被执行。
  • A::~A() 以相反的声明顺序隐式调用A 的直接非静态成员的析构函数([class.dtor]/9,[class.base.init]/13.3)
  • A::~A() 返回。
  • A 对象不再存在 ([class.dtor]/16)。它曾经占用的内存成为“已分配存储”([basic.life]/6),直到它被释放。

(所有引用均指向 C++17 标准)。

在第二版的析构函数中:

~A() {
    std::cout << "A destruct done" << std::endl;
}

语句打印后,成员b被销毁,这导致拥有的B对象被销毁。此时,i 尚未被销毁,因此可以安全地访问它。之后,B 析构函数返回。然后,i 被“销毁”(请参阅​​CWG 2256 了解一些细节)。最后,A 的析构函数返回。届时,尝试访问成员 i 将不再合法。

【讨论】:

  • 我在想同样的主题,但奇怪的是当我们更改声明顺序时,它不会崩溃并且工作正常。当然这并不能证明它是合法的,但无论如何它很奇怪。
  • @arnes 那么根据 CWG 2256 的决议,这将是不合法的。但是,在实践中,这永远不会导致段错误,因为 i 成员不能独立于A 包含它的对象。
  • 是的,你是对的,我认为你的回答值得被接受。
  • 请注意,一般来说,由于所有权是共享的,因此指向的B 不一定会被销毁,因为可能存在其他所有者(尽管在简单示例中并非如此)。在这种情况下,对i 的访问将在~A 返回之后,因此是UB。
【解决方案2】:

B 有一个指向 A 的指针,但没有释放它的内存(例如,没有删除)。所以删除了指针但没有分配的内存,这一切都很好。

基本上,指针在堆栈上,它包含一些(假定的)在堆上分配的内存的地址。是的,它从堆栈中删除,但分配的内存仍然存在。这就是delete 的用途。删除堆上分配的内存。但是,在您的情况下,您不希望删除该内存,并且您的指针就是我们所说的非拥有指针。它指向某些东西,但它不负责清理(实际上 B 并不拥有指针指向的内存)。

【讨论】:

  • 那么碰巧没问题吗? (我的意思是,堆栈中A的内存已经空闲,但内容仍然存在。但是如果有其他东西覆盖了堆栈中的那部分内存,那么当B仍然尝试访问A时它应该是未定义的)
【解决方案3】:

只是想指出您的评论不正确:

~A() {
  std::cout << "A destruct done" << std::endl;
}

当你离开花括号时,析构函数是DONE。您可以在调试器中一步一步地看到这一点。那就是 b 将被删除的地方。

【讨论】:

    【解决方案4】:

    如果类A包含类B,那么当A析构时,会先调用B的析构函数,即它们的嵌套关系的顺序相反。

    没有。如果销毁A类型的对象,则调用A的析构函数,所以先调用。

    但是,对B 的析构函数的调用将首先完成:A 的析构函数首先执行析构函数体,然后继续销毁子对象。析构函数体先完成,然后是子对象析构函数,最后A的析构函数完成。

    但是如果 A 包含 B 的 shared_ptr,而 B 包含指向 A 的原始指针,我们应该如何处理析构函数以使其安全?

    在A 的析构函数体中,使指向的B 指向被销毁对象以外的其他位置:

    ~A() {
        b->a = nullptr;
    }
    

    如果您将它指向 null ,例如在我显示的示例中,那么您还必须确保 B 可以处理 B::a 可能为 null 的情况,即在通过指针访问之前进行检查。

    看来A的析构函数完成了,没有等B去析构。

    这不是我们观察到的。 A的析构函数的body已经完成,但是直到成员析构函数先完成后析构函数才完成。

    【讨论】:

      猜你喜欢
      • 2016-09-19
      • 2011-01-16
      • 1970-01-01
      • 1970-01-01
      • 2012-04-10
      • 1970-01-01
      • 2017-10-20
      • 2018-05-02
      相关资源
      最近更新 更多