Destruction Ordering
The destruction order is Derived Class -> Derived class members -> base class, which is the inverse order of construction: base class -> Derived class members -> Derived Class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
struct A
{
~A() { std::cout << "A\n"; }
};
struct C
{
~C() { std::cout << "C\n"; }
};
struct B : public A
{
C c1; // data-member declared *after* any implicit A sub-object
~B() { std::cout << "B\n"; }
};
int main()
{
{
B obj; // construct a B on the stack
} // scope ends → destructors run
return 0;
}
Cyclic Shared Pointer Skips Dtor
Destruction ordering only tells you what happens when a destructor runs. It says nothing about whether it runs at all. With shared_ptr, that is a separate question:
1
2
3
int main(){
auto node = std::make_shared<Node>();
}
node is a local variable with automatic storage duration — informally, a “stack object.” But node is only the shared_ptr handle. The Node itself lives on the heap. Two objects, two lifetimes:
1
2
3
4
5
Automatic storage Dynamic storage
───────────────── ───────────────
shared_ptr node ──────────────► Node object
(local handle) (heap allocation)
dtor ALWAYS runs dtor runs only at refcount 0
At the end of scope, the handle’s destructor always runs and decrements the count. The heap object is destroyed only if that count reaches zero. So a shared_ptr guarantees you a decrement, not a destruction.
When the count never reaches zero
The usual way to get stuck is a cycle — two objects that own each other:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <memory>
struct Worker;
struct Node : std::enable_shared_from_this<Node> {
std::shared_ptr<Worker> worker; // Node owns Worker
~Node() { std::cout << "~Node\n"; }
void start();
};
struct Worker {
std::shared_ptr<Node> node; // ...and Worker owns Node back
explicit Worker(std::shared_ptr<Node> n) : node(std::move(n)) {}
~Worker() { std::cout << "~Worker: flushing report\n"; }
};
void Node::start() { worker = std::make_shared<Worker>(shared_from_this()); }
int main() {
auto node = std::make_shared<Node>();
node->start();
}
This program prints nothing. Walk the counts:
Point in main |
count(Node) | count(Worker) |
|---|---|---|
after make_shared<Node>() |
1 — local handle | 0 |
after start() |
2 — local + worker->node |
1 — node->worker |
| end of scope, local handle destroyed | 1 — still held by worker->node |
1 |
The local handle’s destructor runs and drops the count to 1, not 0. So ~Node never runs, node->worker is never released, ~Worker never runs either. Each object is kept alive purely by the other.
“But the process is exiting — doesn’t everything get destroyed?”
Memory, yes. Destructors, no. These are different things, and only one of them happens:
| At process exit | Destructors |
|---|---|
Automatic (stack) objects in main |
run |
| Static / thread-local objects | run, via atexit |
| Heap objects with a nonzero refcount | never run — the OS just reclaims the pages |
If a destructor only frees memory, a cycle is harmless in practice: the kernel reclaims everything when the process dies. That’s exactly why this bug hides so well — nothing crashes, nothing warns.
It stops being harmless the moment a destructor has an effect outside the process:
1
2
3
4
~Worker() {
thread_.join(); // never happens → thread killed mid-flight
ofs_ << summary(); // never happens → file silently never written
}
Reclaiming memory does not join a thread and does not flush a stream that was never written to. You get a clean exit and a missing report.
Fixes
Break the cycle. Ownership should point one way. If the Node owns the Worker, the Worker cannot outlive it, so it does not need to own anything back:
1
2
3
4
5
6
7
struct Worker {
Node* node; // non-owning back-reference
explicit Worker(Node* n) : node(n) {}
~Worker() { std::cout << "~Worker: flushing report\n"; }
};
void Node::start() { worker = std::make_shared<Worker>(this); } // plain `this`
Node no longer needs enable_shared_from_this at all. The same main now prints:
1
2
~Node
~Worker: flushing report
Use std::weak_ptr<Node> instead of a raw pointer when the back-reference might genuinely outlive the owner, and lock() it before each use.
If you cannot break it, stop relying on the destructor and call the cleanup explicitly, while the process is still alive:
1
2
3
4
5
int main() {
auto node = std::make_shared<Node>();
node->start();
node->finalize(); // join threads, write reports — do it here
}
That works, but it is a workaround: finalize() is now a second destructor that the compiler will never call for you, and every early return or exception path has to remember it. Prefer breaking the cycle.
Leak detectors help less than you would hope
A cycle is not reported as “still reachable” — LeakSanitizer classifies it as an indirect leak, since each object is only kept alive by another leaked object:
1
2
3
Indirect leak of 48 byte(s) in 1 object(s) <- the Node
Indirect leak of 32 byte(s) in 1 object(s) <- the Worker
SUMMARY: AddressSanitizer: 80 byte(s) leaked in 2 allocation(s).
But compile the exact main from earlier with -fsanitize=address and LSan reports nothing. The destroyed handle’s bytes are still lying in main’s dead stack frame, and LSan scans the stack conservatively — it sees something that looks like a pointer to the Node and concludes the object is still reachable.
To make the leak visible, move the work into a function and overwrite the frame:
1
2
3
4
void run() { auto node = std::make_shared<Node>(); node->start(); }
void scrub() { volatile char buf[4096]; for (int i = 0; i < 4096; ++i) buf[i] = 0; }
int main() { run(); scrub(); } // now LSan reports the 80 bytes
Which is the real lesson: a clean sanitizer run does not prove you have no cycle. Reason about ownership direction instead of relying on the tool to find it.