Search code examples
c++c++11shared-ptr

C++ destructor order of member with shared_ptr


If class A contains class B, then when A destruct, B's destructor will be called first, i.e., the reversed order of their nested relationship.

But what if A contains a shared_ptr of B, while B contains a raw pointer to A, how should we handle the destructor to make it safe?

Considering the following example:

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

You can see in A's destructor, I explicitly set b to nullptr, which will trigger B's destructor immediately, and blocking until it finish. The output will be:

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

but if I comment out that line

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

The output will be:

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

it seems that A's destructor finished without waiting B to destruct. But in this case, I expected segment fault, since when A already destructed, B tried to access the member of A, which is invalid. But why the program doesn't produce segment fault? Does it happen to be OK (i.e., undefined behavior)?

Also, when I change

 {
    A a;
 }

to

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

the output is still the same, no segment fault.


Solution

  • It is important to be precise about what is happening. When A is destroyed, the following events happen in the following order:

    • A::~A() is called.
    • The A object's lifetime ends. The object still exists, but is no longer within its lifetime. ([basic.life]/1.3)
    • The body of A::~A() is executed.
    • A::~A() implicitly calls the destructors of direct non-static members of A in reverse declaration order ([class.dtor]/9, [class.base.init]/13.3)
    • A::~A() returns.
    • The A object ceases to exist ([class.dtor]/16). The memory that it used to occupy becomes "allocated storage" ([basic.life]/6) until it is deallocated.

    (All references are to the C++17 standard).

    In the second version of the destructor:

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

    after the statement is printed, the member b is destroyed, which causes the owned B object to be destroyed. At that point, i has not yet been destroyed, so it is safe to access it. After that, the B destructor returns. Then, i is "destroyed" (see CWG 2256 for some subtleties). Finally, the destructor of A returns. At that point, it would no longer be legal to attempt to access the member i.