Skip to content
VLSI Mentor

C++ Part I · Module 22 · Error Handling and Diagnostics

Exceptions and RAII

Chapter 22.3 measured two facts that look like trivia and are not. A destructor is guaranteed to run when an exception passes through its scope, in every frame, including frames that contain no exception-handling code at all. A statement after a call that throws is guaranteed not to run. This chapter is the consequence, and the frozen purpose states it as strongly as it deserves: RAII and exceptions are one design rather than two features that happen to coexist. The argument is usually made on aesthetic grounds — destructors are tidier than explicit close calls — and that is both true and much weaker than what is actually the case. The real position is that in a language where exceptions exist, cleanup written as a statement is cleanup that will sometimes be skipped, and that is not a style question but a correctness one. The chapter establishes it by measurement: the same function, written three ways, releasing the same resource. On the path where nothing fails, all three are correct and indistinguishable, which is precisely why the defect survives code review and testing. On the path where something throws, one of them leaks, and the chapter counts the leak. It then counts what the corrected manual version costs in code, and what that cost becomes when the function acquires a second resource.

Advanced19 min readC++Part IFundamentalsError Handling

Part I · Module 22 · Chapter 22.4 · Exceptions and RAII

1. The Same Function, Three Ways

One resource, acquired and released, with a configuration step in between that may fail.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static void manualCleanup(bool fail)                 // the "careful" version
{
    Device* d = openDevice(1);
    configure(*d, fail);        // if this throws, closeDevice is NEVER reached
    closeDevice(d);
}

static void manualWithTryCatch(bool fail)            // the "fixed" version
{
    Device* d = openDevice(2);
    try {
        configure(*d, fail);
    } catch (...) {
        closeDevice(d);
        throw;                  // rethrow after cleaning up
    }
    closeDevice(d);             // and again on the happy path
}

static void raii(bool fail)                          // the design
{
    auto d = std::make_unique<Device>(3);
    configure(*d, fail);        // nothing else to write
}

The first is what a careful programmer writes who is not thinking about exceptions. Acquire, use, release, in order, with no gaps. It is wrong, and §2 is by how much.

2. The Measurement

Counting Device constructions and destructions directly, with and without a failure.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A. the happy path: all three are correct
     manual cleanup           happy     opened=1 closed=1  LEAKED=0
     manual + try/catch       happy     opened=1 closed=1  LEAKED=0
     RAII (unique_ptr)        happy     opened=1 closed=1  LEAKED=0

B. the throwing path: one of them leaks
     manual cleanup           throwing  opened=1 closed=0  LEAKED=1
     manual + try/catch       throwing  opened=1 closed=1  LEAKED=0
     RAII (unique_ptr)        throwing  opened=1 closed=1  LEAKED=0

opened=1 closed=0. The device was opened and never closed, because closeDevice(d) is a statement after a call that threw — and Chapter 22.3 §2 measured that zero statements after a propagating call are reached.

The two halves of that measurement matter equally. Block A is why the defect ships: on the path where nothing fails, all three versions are identical. Every test that exercises the success path passes. Code review sees acquire-use-release in the right order. The function is correct, and is correct for as long as nothing throws.

A note on the instrument, because the first version was ambiguous. I initially counted global operator new and operator delete, which gave new=2 delete=1 for a single Device — because the thrown std::runtime_error is also a heap allocation, and it was freed. The leak was real and the numbers made it look like something else. Counting Device lifetimes specifically removed the ambiguity. This is the same trap as Chapter 20.9 §3 from the other direction: a global counter sees too much where a per-container one saw too little.

3. What the Fix Costs

The middle version is genuinely correct. Here is everything it needed:

  • a try block around the body;
  • a catch (...) that catches everything, including types it knows nothing about;
  • a closeDevice(d) inside the handler;
  • a bare throw; to rethrow — and if you forget it, the function silently swallows every failure;
  • and closeDevice(d) again on the normal path, because the catch only runs when something threw.

The cleanup call is written twice. That is the critical structural fact, because duplicated cleanup is cleanup that can diverge: someone adds a flush to one copy and not the other, and now the failure path and the success path release the resource differently.

Then the function acquires a second resource, and it stops scaling:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
D. two resources, which is where manual cleanup stops scaling
     RAII, two resources, second configure throws: opened=2 closed=2 LEAKED=0
     the manual equivalent needs the second resource freed in the first's
     catch block and both freed on the happy path: 4 cleanup call sites.

The manual version now needs nested try blocks — because if the second acquisition throws, the first resource must be released, and if the second configuration throws, both must be — and the cleanup calls appear four times. With three resources it is six, with nested handlers three deep.

The RAII version did not change at all. Two unique_ptr locals, measured opened=2 closed=2, and Chapter 22.3's reverse-order guarantee releases them in the right order for free.

4. Why It Is One Design

The usual framing is that C++ has exceptions, and separately has a convention called RAII that is good practice. That framing makes RAII optional. It is not, and the reason is the asymmetry Chapter 22.3 measured:

A destructor is cleanup the language is guaranteed to run. A statement is cleanup the language is guaranteed to skip, if a call before it throws.

Given that, "use RAII" is not advice about tidiness. It is the only construction in which cleanup is reliable. Everything else is cleanup that happens on some paths.

This is also why the two features arrived together and why the language has no finally. Java and C# need finally because their cleanup is not attached to object lifetime; C++ attaches it to object lifetime, so the block has nothing to do. A catch (...) { cleanup; throw; } is a hand-written finally, and §3 counted what it costs.

The historical direction is worth knowing: exceptions were not added to a language that already had destructors and then found to compose well with them. Destructors were deliberately made to run during unwinding so that exceptions could be used safely at all. The guarantee in Chapter 22.3 §8 exists for this.

5. What Counts as a Resource

RAII is usually introduced with memory, which understates it. Anything acquired that must be released is a resource, and all of them have the same problem.

resourcereleased bystandard RAII type
heap memorydeletestd::unique_ptr, std::vector, any container
a file handlefclosestd::fstream, or your own wrapper
a mutexunlockstd::lock_guard, std::unique_lock, std::scoped_lock
a device / socket / fda close callyour own wrapper
a transactioncommit or roll backyour own wrapper
a logging indent leveldecrementyour own wrapper
a temporary state changerestore the old valueyour own wrapper

The last three are the ones people miss, because nothing was allocated. A function that increments a nesting depth, does work that can throw, and decrements at the end has exactly the §2 defect — the depth is now permanently wrong, with no leak detector to catch it.

The pattern for anything without a standard wrapper is six lines:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class IndentGuard {
public:
    explicit IndentGuard(int& depth) : d_(depth) { ++d_; }
    ~IndentGuard() { --d_; }
    IndentGuard(const IndentGuard&) = delete;
    IndentGuard& operator=(const IndentGuard&) = delete;
private:
    int& d_;
};

Copying is deleted because a copied guard would release twice — Chapter 14.2 §4's ownership argument, applied to something that is not memory.

6. Writing a Guard Correctly

Four rules, each of which has a failure mode.

Acquire in the constructor, release in the destructor. If acquisition fails, throw from the constructor — then the object never existed and there is nothing to release, which Chapter 22.3 §4 measured.

The destructor must not throw. It runs during unwinding, and Chapter 22.6 §5 measures what a throw from there costs. So a destructor that closes something which can fail must swallow the failure, which is why a resource with a meaningful close error needs an explicit close() as well — §7.

Delete or define copy and move. A guard that is copied releases twice. Deleting both is the common and correct choice; implementing move is right when the resource genuinely transfers, which is what std::unique_ptr does.

Hold exactly one resource per object. Two resources in one guard means a half-constructed state to handle; two guards means Chapter 22.3's reverse-order guarantee handles it for you.

For the ad-hoc cases there is also std::unique_ptr with a custom deleter, which Chapter 14.4 covers, and which avoids writing a class at all:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
auto d = std::unique_ptr<Device, void(*)(Device*)>(openDevice(1), closeDevice);

C++ has no standard scope_guard; it is in the Library Fundamentals TS as std::experimental::scope_exit and is widely implemented by hand.

7. The Honest Limits

Three places where RAII does not finish the job, and pretending otherwise is how people get surprised.

A close that can fail. fclose can fail. A flush to a device can fail. The destructor cannot report it, because it must not throw. So a type whose release can fail meaningfully needs an explicit close() that returns or throws, and a destructor that closes silently as a fallback. std::ofstream works exactly this way: the destructor closes and discards any error, and you call close() and check fail() if you care.

A resource with no scope. RAII binds a lifetime to a scope. A connection pool, a cache entry, or anything whose lifetime is determined by a request rather than a block, needs ownership modelled some other way — usually std::shared_ptr, which Chapter 14.5 covers.

Cleanup that must not happen on the failure path. A transaction should commit on success and roll back on failure, and a destructor cannot tell which happened. The idiom is to default to rollback and have an explicit commit() that disarms the guard:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class Transaction {
public:
    ~Transaction() { if (!committed_) rollback(); }
    void commit() { doCommit(); committed_ = true; }
private:
    bool committed_ = false;
};

std::uncaught_exceptions() exists for asking whether unwinding is in progress, and is almost always the wrong tool — the disarm flag is clearer.

8. Why Intermediate Functions Need Nothing

The payoff, and it is worth stating explicitly because it changes how much code you write.

Chapter 22.3 §3 measured two frames with no try, no catch, and no mention of exceptions, whose automatic objects were destroyed correctly as a failure passed through. Combine that with §4's rule and the result is:

A function whose resources are all held in objects is exception-safe without containing any exception-handling code.

So in a chain of ten functions where one can throw and one wants to handle it, the other eight need nothing. They are correct by construction. That is the actual economy of the design: not that destructors are shorter than close() calls, but that eight functions contain no error-handling code at all and are nonetheless correct on the failure path.

The inverse is the §2 defect generalised. In a manual-cleanup codebase, every function holding a resource needs its own try/catch/rethrow, so error-handling code is proportional to the number of functions rather than to the number of places that genuinely handle something.

9. The Comparison

manual cleanupmanual + try/catchRAII
happy pathcorrect (measured)correct (measured)correct (measured)
throwing pathLEAKED=1 (measured)correct (measured)correct (measured)
cleanup call sites, 1 resource120
cleanup call sites, 2 resources24, nested handlers0 (measured)
forgetting the rethrown/aswallows every failureimpossible
release order with 2+ resourcesmanualmanualreverse construction, automatic
needs try/catch in intermediate functionsyes, every oneyes, every oneno (22.3 §3 measured)
early return also safenonoyes
break / continue also safenonoyes
can report a failing closeyesyesonly via an explicit close()

The early-return row is worth noticing: the manual version is wrong for the same reason on a path that has nothing to do with exceptions. A return in the middle of the function skips the cleanup exactly as a throw does.

10. What to Actually Do

  • Hold every resource in an object whose destructor releases it. Measured: it is the only cleanup that always runs.
  • Use the standard types first — unique_ptr, vector, string, lock_guard, fstream.
  • Write a six-line guard for anything else, including things that are not memory: an indent level, a saved flag, a nesting depth.
  • Delete copy and move on a guard, or implement move if the resource genuinely transfers.
  • One resource per object.
  • Throw from a constructor if acquisition fails — there is then no object and nothing to clean up.
  • Never throw from a destructor.
  • Give a type whose close can fail an explicit close(), with the destructor as a silent fallback.
  • Use a disarm flag for commit-or-rollback, not std::uncaught_exceptions().
  • Do not add try/catch to intermediate functions. They are already correct.
  • Treat a catch (...) { cleanup; throw; } as a code smell — it is a hand-written finally and there is a better construction.
  • Suspect any cleanup statement at the end of a function that also contains a call which can throw.

11. Mistakes Worth Making Once

Acquire, use, release, with a throwing call in between. Measured opened=1 closed=0, and the happy path is perfect.

catch (...) { cleanup; } without the rethrow. Now the function swallows every failure and reports success.

Cleanup duplicated in the handler and on the normal path, then changed in only one of them.

A guard with a compiler-generated copy constructor. It releases twice.

Two resources in one guard, giving a half-constructed state nobody handles.

A destructor that throws. Chapter 22.6 §5 measures the terminate.

Relying on the destructor to report a failing flush. It cannot; add close().

Forgetting that an early return skips manual cleanup too — it is not only exceptions.

Counting global new/delete to find a leak in a throwing function. My own first instrument: the exception object is also an allocation, and the numbers were ambiguous.

12. Interview Relevance

The frozen curriculum marks this chapter Critical, and it is one of the questions most likely to be asked in some form.

"Why is RAII important for exception safety?" Because a destructor is guaranteed to run during unwinding and a statement after a throwing call is guaranteed not to. The strong answer gives the measurement — the same function leaking on the throwing path and correct on the happy path — and notes that the happy-path equality is why the defect ships.

"What is wrong with catch (...) { cleanup; throw; }?" Nothing, functionally. It is a hand-written finally: the cleanup is written twice, it scales to four call sites with two resources, and forgetting the rethrow swallows the failure.

"Why does C++ have no finally?" Because cleanup is attached to object lifetime instead of to a block, so there is nothing for finally to do.

"Which functions need try/catch?" Only the ones that handle something. Measured: intermediate frames with none cleaned up correctly.

"What if the resource's close can fail?" An explicit close() that reports, plus a destructor that closes silently. std::ofstream is the model.

"How do you do commit-or-rollback?" A guard that rolls back by default with an explicit commit() that disarms it.

"Is RAII only about memory?" No — mutexes, files, transactions, and non-allocated state like a nesting depth, which has the same defect with no leak detector to find it.

13. What You Should Be Able to Say Now

  • That on the happy path all three versions measured opened=1 closed=1 and are indistinguishable — which is why the defect survives review and testing.
  • That on the throwing path the manual version measured opened=1 closed=0, because a cleanup statement after a throwing call does not run.
  • That the corrected manual version needs a try, a catch (...), a rethrow, and the cleanup written twice — four sites with two resources.
  • That the RAII version was unchanged at two resources, measured opened=2 closed=2.
  • That the asymmetry from Chapter 22.3 — destructors run, statements are skipped — makes RAII the only reliable construction, not the tidier one.
  • That C++ has no finally because cleanup is bound to object lifetime.
  • That a resource is anything acquired that must be released, including non-memory state.
  • The four rules for a guard, and the three honest limits: a failing close, a resource with no scope, and commit-or-rollback.
  • That intermediate functions need no exception-handling code at all.

14. Exercises

Exercise 1 — reproduce the leak. Build §1's three versions with a per-class counter and confirm the happy and throwing rows. Then run only the happy-path test and observe that it passes.

Exercise 2 — the instrument. Count the same leak with a global operator new/delete counter and explain why the numbers are ambiguous.

Exercise 3 — two resources. Write the manual two-resource version correctly. Count the cleanup call sites and the nesting depth. Then do three resources.

Exercise 4 — the missing rethrow. Remove the throw; from the handler and show that the caller now sees success. Decide whether any warning catches it.

Exercise 5 — not memory. Write the IndentGuard from §5, use it in a function that throws, and show the depth is correct afterwards. Then do it without the guard and show that it is not.

Exercise 6 — early return. Add an early return to the manual version and show it leaks for a reason unrelated to exceptions.

Exercise 7 — a failing close. Write a file wrapper whose destructor closes silently and whose close() reports. Demonstrate a caller who cares and one who does not.

Exercise 8 — commit or rollback. Implement §7's Transaction and show that it rolls back on a throw and commits on success, with no uncaught_exceptions().

15. Summary

  • Measured, happy path: all three versions opened=1 closed=1 LEAKED=0 — identical, which is why the defect ships.
  • Measured, throwing path: the manual version opened=1 closed=0 LEAKED=1, because a cleanup statement after a propagating call does not run — Chapter 22.3 §2.
  • The corrected manual version needs a try, a catch (...), a rethrow, and the cleanup call written TWICE — and four sites with two resources, nested.
  • Measured: RAII with two resources, opened=2 closed=2 LEAKED=0, with release order handled by Chapter 22.3's reverse-order guarantee.
  • A destructor is cleanup the language runs; a statement is cleanup it skips. That is why RAII is the only reliable construction rather than the tidier one.
  • C++ has no finally because cleanup is bound to object lifetime; catch (...) { cleanup; throw; } is a hand-written one.
  • A resource is anything acquired that must be released — memory, files, mutexes, transactions, and non-allocated state like a nesting depth.
  • Four guard rules: acquire in the constructor, never throw from the destructor, delete or define copy and move, one resource per object.
  • Three honest limits: a close that can fail needs an explicit close(); a resource with no scope needs shared_ptr; commit-or-rollback needs a disarm flag.
  • Intermediate functions need no exception-handling code and are correct anyway — eight of ten in a chain.
  • An early return skips manual cleanup too. It is not only exceptions.

Cleanup is now reliable, which answers the question does my function leak? It does not answer the harder one: what state is the object in after a failure? A function can release every resource correctly and still leave the data it was modifying half-updated — an invariant broken, a count disagreeing with a container. Chapter 22.5 is the vocabulary for that question, four named levels of promise, and it measures the same insertion written three ways where only one leaves the object's invariant intact — and, as in this chapter, all three are identical when nothing fails.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.