C++ RAII: Safe Lifetimes for Files, Mutexes and Transactions

Learn how construction, reverse destruction and move-only ownership make cleanup reliable. A single C++ example traces a file, mutex and transaction across success and failure.

KnowledgeGate Team

Exam prep & CS education

Updated 6 Nov 20265 min read
C++ RAII: Safe Lifetimes for Files, Mutexes and Transactions

A function opens a file, locks shared state and starts a temporary transaction. Then an early return or exception skips one manual close(), unlock() or rollback(), leaving a bug far from the line that acquired the resource. C++ offers one ownership pattern that makes the same scope control all three lifetimes, so cleanup stays correct when control flow changes. If classes, constructors or destructors are still unfamiliar, review the complete C++ learning path first.

RAII makes scope the owner of cleanup

Resource Acquisition Is Initialization, or RAII, means acquiring a resource while constructing an owning object, keeping ownership in that object, and releasing the resource in its destructor. When a scope ends normally or an exception unwinds it, local objects are destroyed in reverse construction order.

The resource and its owner differ. An operating-system file handle can live inside std::ofstream, a locked std::mutex inside std::lock_guard, and an active logical transaction inside a small Transaction guard. Constructors establish ownership; destructors end it.

RAII guarantees destructor invocation when C++ unwinds a scope. It does not make external systems atomic together, recover data after forced termination, or handle ignored I/O errors. Correctness still requires explicit checks and a suitable transaction design.

A function can own a file, mutex lock and transaction together.

This deterministic lifetime model remembers the old balance and restores it unless the caller commits. It is not a production database abstraction.

cpp
#include <fstream>
#include <mutex>
#include <stdexcept>

struct Ledger {
    int balance = 1000;
};

class Transaction {
    Ledger& ledger_;
    int before_;
    bool committed_ = false;

public:
    explicit Transaction(Ledger& ledger)
        : ledger_(ledger), before_(ledger.balance) {}

    Transaction(const Transaction&) = delete;
    Transaction& operator=(const Transaction&) = delete;

    void debit(int amount) {
        ledger_.balance -= amount;
    }

    void commit() noexcept {
        committed_ = true;
    }

    ~Transaction() noexcept {
        if (!committed_) {
            ledger_.balance = before_;
        }
    }
};

bool record_payment(Ledger& ledger, std::mutex& ledger_mu) {
    std::ofstream out("order-42.tmp");
    if (!out) {
        return false;
    }

    std::lock_guard<std::mutex> lock(ledger_mu);
    Transaction tx(ledger);

    tx.debit(240);
    out << "order=42 amount=240 balance=760\n";
    out.flush();
    if (!out) {
        throw std::runtime_error("write failed");
    }

    tx.commit();
    return true;
}

The constructor captures before_ = 1000, and debit(240) calculates 1000 - 240 = 760. The written and in-memory balances match.

Declaration order is part of correctness. Constructing lock before tx means reverse destruction handles tx first. Rollback therefore restores the balance while ledger_mu is locked, before lock releases it.

Timeline of record_payment: out, lock and tx construct in order, debit(240) leaves balance 760, and destruction runs tx, lock, out.

Each exit path follows the same destruction order.

Exit point

Transaction result

Mutex result

File result

Open fails before guards

Balance stays 1000

Never locked

Failed stream object destroyed

Exception after debit, before commit

760 -> 1000

Unlocks after rollback

Closes after unlock

Success after commit

Balance stays 760

Unlocks

Closes

Early return after guards

Rolls back unless already committed

Unlocks after transaction cleanup

Closes last

On the exception path, the write check throws. tx sees committed_ == false and restores before_, so 760 becomes 1000. Then lock unlocks ledger_mu, and out closes order-42.tmp.

On success, commit() changes the flag, so the destructor keeps 760. The mutex and file still release automatically.

A useful test forces an output failure immediately after tx.debit(240). Catch it, assert ledger.balance == 1000, then acquire ledger_mu with a second std::lock_guard. This tests restoration and lock release, not merely line coverage.

Two lanes from balance 1000: success keeps 760 after commit; failure restores 1000 when the write throws. Both then unlock and close.

Wrap a raw file handle when no library owner exists

C APIs often return raw handles:

cpp
FILE* raw = std::fopen("scores.csv", "w");
if (!raw) return false;
// A later early return can forget std::fclose(raw).

A custom deleter turns the handle into a unique owner:

cpp
#include <cstdio>
#include <memory>

struct FileCloser {
    void operator()(FILE* f) const noexcept {
        if (f) std::fclose(f);
    }
};

using FilePtr = std::unique_ptr<FILE, FileCloser>;

FilePtr file(std::fopen("scores.csv", "w"));
if (!file) return false;

int written = std::fprintf(file.get(), "roll=17,score=86\n");
if (written <= 0) return false;

file.get() borrows the handle for std::fprintf; it does not transfer ownership, so the borrower must not call fclose. FilePtr closes the file on every exit. The same distinction runs through Pointers in C: GATE Memory Diagrams, where a pointer names a resource without owning its release.

Compose owners carefully with scope, order and moves

Construction is out, lock, tx; destruction is tx, lock, out. If Transaction tx(ledger) appears before std::lock_guard lock(ledger_mu), the lock is destroyed first. Rollback could mutate the ledger after release. That is a race risk, not a style preference.

Unique owners normally cannot be copied because two owners could release the same resource. Moving FilePtr a into FilePtr b transfers the single FILE* owner. Here a.get() becomes null, and only b may close it. std::lock_guard is neither copyable nor movable.

Use std::unique_ptr or a purpose-built movable wrapper where transfer is meaningful. Do not choose std::shared_ptr merely to postpone deciding who owns the lifetime.

Common RAII traps and their repairs

  • Scattered cleanup branches: if (bad) { fclose(f); return; } can miss the next exit path. Create the owner immediately after acquisition and remove manual cleanup branches.

  • Throwing cleanup: a destructor that throws during exception unwinding can terminate the program. Keep the transaction destructor noexcept, keep commit() explicit, and report fallible cleanup through an explicit operation when needed.

  • Unchecked output: std::ofstream destruction closes its file but does not replace checking flush() and stream state.

  • Temporary and reference lifetimes: an unnamed temporary lock can unlock at the semicolon. A reference stored by an owner must not outlive its target.

  • Moved-from use: after ownership moves, do not use the source as though it still owns the resource.

RAII releases each resource reliably, but it still does not coordinate the file contents and ledger as one atomic transaction.

How interviews and exam questions probe RAII

  1. If objects are constructed as A, B, C, what is their destruction order? C, B, A.

  2. If constructing B throws, what is destroyed? Only the already-constructed A; B never finished construction.

  3. If tx is constructed after lock, which cleanup happens first? Transaction rollback happens before mutex unlock.

  4. After FilePtr b = std::move(a), who owns the file? b; for this unique_ptr, a remains valid but empty.

  5. Which lock wrapper should you choose? Use std::lock_guard when the lock lasts to scope end. Use std::unique_lock when you genuinely need deferred or timed locking, a condition variable, or an earlier explicit unlock.

For interview practice in C++ and competitive coding, continue with the Coding for Placements course.

The short version and the next safe step

  • Acquire a resource directly into an owner.

  • Give that owner the narrowest correct scope.

  • Make destruction safe and non-throwing.

  • Test success, early return and exception paths.

The invariant is simple: failure restores 1000, success retains 760, and both paths unlock the mutex and close the file. The C++ Programming course provides a structured route through programming concepts, MCQs and coding questions, while the Coding and DSA learning path places this ownership pattern in a broader problem-solving path.

Before moving on, refactor one manual open/close or lock/unlock pair into an owner and write one forced-failure test.