CodeRigor

FREE LESSON

RAII, scope and object lifetime

Tie every resource to an object's lifetime so cleanup happens exactly once — on normal exit, early return and exceptions.

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
C++
#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.
C++
#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
Lifetimes nest: last constructed, first destroyed
Output: +a +b +first +second -second -first -b +c -c -a. Members are built in declaration order and torn down in reverse; so are locals.
Lifetimes nest: last constructed, first destroyed
Output: +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:

C++
#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());
    }
}
Unwinding: throw in work()
start / 3
The exception leaves work's frame; on the way out, every fully constructed local is destroyed, newest first. three was never built, so nothing runs for it.
Unwinding: throw in work()
The exception leaves 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 calls std::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:

C++
#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 path out must pass the delete
Manual cleanup covers only the paths you remember. A destructor runs on all of them.
Every path out must pass the delete
Manual cleanup covers only the paths you remember. A destructor runs on all of them.

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.

C++
#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);
}
scoped_lock: held exactly while in scope
Two threads calling 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.
scoped_lock: held exactly while in scope
Two threads calling 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:

C++
#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 once

RAII types are usually not copyable — two owners would both release. A lock_guard can't even be moved:

C++
#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:

C++
#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?

C++
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:

C++
#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:

C++
#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.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

RAII from first principles, the rule of three/five, and why business classes should follow the rule of zero.

Compact RAII refresher: tying acquire/release to constructor/destructor, including on early return and exceptions.

How stack unwinding runs destructors and what the basic/strong guarantees mean — the reason RAII cleanup is exception-safe.

Surprising lifetime cases (temporaries, range-for, returned references) that bite scope-based reasoning.

Lifetime management end to end: scopes, ownership and RAII wrappers.

When locals are constructed and destroyed, scope by scope: the lifetime rules RAII builds on.

Automatic (stack) vs dynamic (heap) storage and what each costs — context for why RAII owns heap resources on the stack.