C++ has no garbage collector and no finally. What it has instead is better:
every object's destructor runs at a precisely defined moment, including
when an exception is flying. Resource Acquisition Is Initialization (RAII)
means: acquire a resource in a constructor, release it in the destructor, and
let scope do the bookkeeping. Memory, file handles, locks, sockets, temporary
state — all of it gets released exactly once, on every path out of a scope,
without you writing cleanup code on each path.
1. Storage duration: when objects live and die
Every object has a storage duration that decides when its lifetime ends:
| Duration | How you get it | Destroyed |
|---|---|---|
| automatic | local variables, parameters | at the end of the enclosing scope, in reverse order |
| static | globals, static locals, static members |
after main returns (reverse order of construction) |
| thread | thread_local |
when the thread exits |
| dynamic | new (or an allocator) |
when you call delete — the one that's easy to get wrong |
#include <memory>
#include <print>
int counter = 0; // static: lives for the whole program
int next_id() {
static int calls = 0; // static local: initialised on first call, kept between calls
return ++calls;
}
int main() {
int local = 42; // automatic: dies at the closing brace of main
auto owned = std::make_unique<int>(7); // the unique_ptr is automatic; the int is dynamic,
// but the unique_ptr's destructor deletes it
std::println("{} {} {} {}", local, *owned, next_id(), next_id());
++counter;
}RAII's trick is to make dynamic (or external) resources follow an automatic
object: the unique_ptr above is automatic, so the heap int is freed when it
goes out of scope — no delete anywhere.
2. Construction and destruction order
The order is fully specified, and RAII relies on it:
- Locals are destroyed in reverse order of construction at the end of their scope.
- Members are constructed in declaration order (not the order of the member-initialiser list!) and destroyed in reverse.
- Bases are constructed before members, and destroyed after them.
#include <print>
#include <string>
struct Noisy {
std::string name;
explicit Noisy(std::string n) : name(std::move(n)) { std::println("+{}", name); }
~Noisy() { std::println("-{}", name); }
};
struct Pair {
Noisy first{"first"};
Noisy second{"second"};
};
int main() {
Noisy a{"a"};
{
Noisy b{"b"};
Pair p;
} // p.second, p.first, then b are destroyed here
Noisy c{"c"};
} // c, then a+a +b +first +second -second -first -b +c -c -a. Members are built in declaration order and torn down in reverse; so are locals.Reverse order matters: later objects may depend on earlier ones (a lock taken after the mutex it locks; a buffer used by a stream built after it), so they must be torn down first.
3. Destructors run during stack unwinding
When an exception propagates out of a scope, every fully-constructed automatic object in that scope is destroyed on the way out. That's what makes RAII exception-safe:
#include <print>
#include <stdexcept>
struct Step {
int n;
explicit Step(int n) : n(n) { std::println("acquire {}", n); }
~Step() { std::println("release {}", n); }
};
void work() {
Step one{1};
Step two{2};
throw std::runtime_error("disk full");
Step three{3}; // never constructed, so never destroyed
}
int main() {
try {
work();
} catch (const std::exception& e) {
std::println("caught: {}", e.what());
}
}work's frame; on the way out, every fully constructed local is destroyed, newest first. three was never built, so nothing runs for it.Two corollaries:
- An object whose constructor throws was never fully constructed, so its destructor does not run — but the members and bases it had already built are destroyed. Build classes from RAII members and partial construction cleans itself up.
- Destructors are implicitly
noexcept. A destructor that throws during unwinding callsstd::terminate. Release functions must not throw.
4. Why manual cleanup leaks
Here's the code RAII replaces. It looks fine and leaks whenever parse
throws:
#include <string>
int parse(const std::string& s);
int load(const std::string& s) {
int* scratch = new int[1024];
int result = parse(s); // if this throws, the delete below never runs
delete[] scratch;
return result;
}Every early return, every break, every function that might throw is a path
that skips the delete. Patching each path doesn't scale; making the resource
owned by an object does — std::vector<int> scratch(1024); or
auto scratch = std::make_unique<int[]>(1024); and the problem is gone.
5. RAII wrappers you already have
Memory: std::unique_ptr, std::vector, std::string, …
Locks: std::lock_guard locks in its constructor and unlocks in its
destructor; std::scoped_lock does the same for several mutexes at once,
without deadlock.
#include <mutex>
#include <print>
std::mutex m;
int shared = 0;
void bump() {
std::scoped_lock lock{m}; // locked here…
++shared;
if (shared > 100) return; // …and unlocked on this path…
++shared;
} // …and this one, and if anything throws
int main() {
bump();
std::println("{}", shared);
}bump(). The lock is taken in the constructor and released in the destructor — on every path out — so the other thread waits only for the critical section.C handles: unique_ptr takes a custom deleter, which turns any
create/destroy C API into an RAII type:
#include <cstdio>
#include <memory>
#include <print>
struct FileCloser {
void operator()(std::FILE* f) const noexcept { std::fclose(f); }
};
using File = std::unique_ptr<std::FILE, FileCloser>;
int main() {
File f{std::tmpfile()};
if (!f) return 1;
std::fputs("hello", f.get());
std::rewind(f.get());
char buf[16]{};
std::fgets(buf, sizeof buf, f.get());
std::println("{}", buf);
} // fclose runs here, exactly onceRAII types are usually not copyable — two owners would both release. A
lock_guard can't even be moved:
#include <mutex>
std::mutex m;
int main() {
std::lock_guard<std::mutex> a{m};
std::lock_guard<std::mutex> b = a; // error: copy constructor is deleted
}6. Worked example: a scope guard
Sometimes the "resource" is just something to undo: roll back a half-finished change, restore a flag. A scope guard runs a callable when it's destroyed, unless told otherwise:
#include <print>
#include <utility>
#include <vector>
template <class F>
class ScopeGuard {
public:
explicit ScopeGuard(F f) : f_(std::move(f)) {}
ScopeGuard(const ScopeGuard&) = delete; // one guard, one cleanup
ScopeGuard& operator=(const ScopeGuard&) = delete;
~ScopeGuard() { if (active_) f_(); }
void dismiss() noexcept { active_ = false; } // "commit": skip the undo
private:
F f_;
bool active_ = true;
};
bool add_all(std::vector<int>& v, const std::vector<int>& extra) {
auto old_size = v.size();
ScopeGuard rollback{[&] { v.resize(old_size); }}; // undo unless we reach the end
for (int x : extra) {
if (x < 0) return false; // rollback runs
v.push_back(x);
}
rollback.dismiss(); // success: keep the changes
return true;
}
int main() {
std::vector<int> v{1, 2};
add_all(v, {3, 4});
add_all(v, {5, -1, 6});
std::println("{}", v); // [1, 2, 3, 4]: the failed batch was rolled back
}Walk through it: the guard is constructed with a lambda that captures what it
needs. Every way out of add_all destroys rollback — the return false, an
exception from push_back, or the normal end. Only the success path calls
dismiss() first. Notice what's absent: no cleanup code duplicated per path.
Faded example — your turn
A unique handle owns an integer handle (a file descriptor, say) and closes
it exactly once. The constructor and destructor are easy. Fill in move
assignment: what must happen to the handle *this currently owns, and what
must other hold afterwards?
UniqueHandle& operator=(UniqueHandle&& other) noexcept {
if (this != &other) {
/* ??? */
}
return *this;
}Answer
Close the handle we currently own (if any), take other's, and leave other
empty so its destructor does nothing: reset(other.release()); where
release() returns the handle and stores "invalid", and reset(h) closes the
old handle and adopts h. Forgetting the close leaks; forgetting to empty
other closes the same handle twice.
7. Temporaries live until the end of the full-expression
A temporary is destroyed at the end of the full-expression that created it —
roughly, at the ;. Using it within that expression is fine; keeping a
pointer or view to it is not:
#include <print>
#include <string>
#include <string_view>
std::string greeting() { return "hello, a string long enough to live on the heap"; }
int main() {
std::println("{}", greeting().size()); // fine: the temporary lives until the ';'
const std::string& kept = greeting(); // fine: binding to a const& extends
std::println("{}", kept); // the temporary's life to the reference's
std::string_view view = greeting(); // compiles, but the string dies at the ';'
(void)view; // reading view now would be use-after-free
}Lifetime extension applies only when a temporary binds directly to a
reference — not through a string_view, a function returning a reference, or
a member of a temporary you call a method on.
8. Static locals: lazy and thread-safe
A static local is initialised the first time control passes through its
declaration. Since C++11 that initialisation is thread-safe: if several
threads arrive at once, one runs the initialiser and the others wait. This
"magic static" is the simplest correct lazy singleton:
#include <print>
#include <string>
const std::string& config_path() {
static const std::string path = [] {
std::println("computing once");
return std::string{"/etc/app.conf"};
}();
return path;
}
int main() {
std::println("{}", config_path());
std::println("{}", config_path());
}Statics are destroyed after main returns, in reverse order of construction.
Don't rely on one static's destructor using another from a different
translation unit — the order across files isn't specified.
Summary
- Every resource gets an owner object; acquire in the constructor, release in the destructor. Then scope handles normal exit, early return and exceptions.
- Locals die in reverse order; members are built in declaration order and destroyed in reverse; bases first in, last out.
- Destructors run during unwinding; a throwing constructor doesn't get its destructor, but its finished members do. Destructors must not throw.
- RAII owners are non-copyable; movable ones leave the source empty.
- Temporaries die at the end of the full-expression; only direct reference binding extends their life.
- Prefer existing wrappers:
unique_ptr(with a deleter for C handles), containers,scoped_lock.