Skip to content

Module 13 — Misconceptions and Mistakes

Two different problems, fixed two different ways.

A misconception is a wrong mental model, and only evidence removes it — so each one below is paired with a program that contradicts it. A mistake is a concrete error, and information fixes it — so each one below comes with the message this toolchain actually printed and the corrected code.

Every diagnostic quoted here was produced by compiling the snippets shown, with the course's flags.


Part 1 — Misconceptions

1. "Exceptions are for normal control flow."

They are convenient: a find() that throws is shorter to write than one that returns std::optional. That is exactly the trap.

An expected outcome — a search that finds nothing, a cache miss, a lock that is busy — is a normal result that the caller will handle immediately. A failure is a request the operation cannot satisfy. The library system contains both, and they get different mechanisms:

std::optional<Item> find(int id) const;   // expected: `if (!item)` at the call site
void require(int id) const;               // failure: throws ItemRequired but missing

The test: if throwing became free, would you still want the caller to handle this in the same breath as the call? If yes, it is an answer, not an exception.

2. "catch (...) makes my program safe."

It makes the program continue, which is not the same thing.

try {
    processLoan(request);
} catch (...) {
    // "handled"
}

Everything that made the failure diagnosable has been discarded: no type, no message, no clue. If processLoan half-updated the ledger before failing, the program now continues with a broken invariant and no record of it. Catching everything and continuing is worse than crashing, because crashing at least tells you.

Legitimate uses are narrow: a boundary that must release something by hand (which Module 11 removed the need for), and a handler that logs and immediately rethrows with throw;.

3. "I do not need RAII if I catch everything."

The catch block can release the resource — for the failures it sees. The three it misses:

void reload(const std::string& path) {
    HANDLE file = openCatalogue(path);
    try {
        load(file);                       // 1. a throw in here is caught, and released
        if (bad(file)) { close(file); return; }   // 2. an early return must remember too
        close(file);
    } catch (...) {
        close(file);                      // 3. and this releases TWICE if close() is not careful
        throw;
    }
}

And a resource acquired before the try — in a constructor body — is released by none of them. There is exactly one cleanup mechanism that cannot be skipped, and it is a destructor. catch is for deciding, not for releasing.

4. "Exceptions are slow, so I should avoid them."

The claim is usually stated the wrong way round. What is expensive is throwing; the cost of a call that could throw but does not is negligible — it is roughly the same as a branch. Which means:

  • throwing in a loop that runs a million times is expensive, and it is also control flow, so it is wrong for the second reason;
  • throwing on a failure that happens once, three frames below the caller who can handle it, costs nothing that matters and removes error-handling code from every frame in between.

The example program 01-error-codes-versus-exceptions puts it plainly: a call that succeeds is not slower for being able to throw.

5. "A destructor can throw like any other function."

Since C++11 a destructor is noexcept by default, and a throw inside one is not a control-flow decision — it is std::terminate:

struct Loader {
    ~Loader() noexcept(false) {                    // taking the promise back, explicitly
        throw std::runtime_error("cannot close the catalogue file");
    }
};
terminate called after throwing an instance of 'std::runtime_error'
  what():  cannot close the catalogue file

And when this happens during unwinding, the original failure is lost — the message names the destructor's exception, not the one that started the unwinding. Two failures cannot both be reported, so the standard ends the program instead of continuing with a state neither of them describes.

A destructor may log, swallow, or offer an explicit close() that callers invoke when they want to see the error. It may not throw.

6. "noexcept is a comment for readers."

It is checked, it is part of the interface, and it is read by the standard library:

void save() noexcept;            // promise
void save() noexcept(false);     // explicitly withdrawn (which is the default, stated out loud)

Break it and the program terminates without unwinding, because the caller has already been told the call cannot fail and may have arranged things on that basis. Inherit it wrongly and the compiler stops you:

error: looser exception specification on overriding virtual function
       'virtual void MemoryRepository::save() noexcept (false)'
note: overridden function is 'virtual void Repository::save() noexcept'

And the library uses the answer: std::vector moves elements during growth only if their move is noexcept (Module 10). A noexcept you cannot honour is worse than no noexcept at all.

7. "If it compiles, the failure path is handled."

Compilation proves nothing about a path that has never executed. A program can work on every good input and corrupt its state on one bad one, and no compiler will mention it.

The only evidence is a run: a deliberate throw, counters that prove acquisition and release balance, and an assertion that the invariants still hold afterwards. Lab 13 requires exactly that, as two runs of the same sequence.

8. "Exceptions are an alternative to validation."

Throwing on bad input feels like handling it. It postpones it.

Validation belongs at the boundary — the one place that receives untrusted data — and after it, everything downstream may assume the data is good. Throw, by all means, from the validator; but the work of validation is the check, not the throw. Module 4's rule (enforce the invariant where it can be enforced) applies unchanged to failure.


Part 2 — Common programming mistakes

Symptoms, and what actually causes them

Symptom Cause
The message printed by a catch has lost its detail, and the handler cannot tell two failures apart Caught by value: the exception was sliced. Catch by const&
A specific handler never runs The base-class handler is written first; the first matching handler wins
A resource leaks only when an exception occurs The resource was not owned by an object, so unwinding could not release it
A resource is released twice A catch block released something RAII already released
The program ends with terminate called after throwing ... An exception escaped main, or a destructor threw while another exception was in flight
The terminate message names the wrong failure A destructor threw during unwinding: the original exception is gone by definition
Nothing at all was printed before the program ended std::cout was still buffered when std::terminate aborted the process. Use std::cerr, or flush
error: looser exception specification on overriding virtual function An override throws where the base declared noexcept. A virtual function's exception specification is part of its contract
warning: 'throw' will always call 'terminate' [-Wterminate] A throw inside a function (or destructor) that promised not to throw. The compiler can see the termination in advance
warning: catching polymorphic type ... by value [-Wcatch-value=] catch (LibraryError error) — a copy, and the derived part is lost
warning: exception of type 'ItemUnavailable' will be caught by earlier handler [-Wexceptions] Handler order: most-derived must come first
After a catch, the program behaves oddly The operation provided only the basic guarantee: nothing leaked and the object is valid, but its contents changed
State is corrupted after a failed operation Same cause; the fix is to validate first, or to work on a copy and swap
An exception thrown in a constructor leaves no object, but something leaked The resource was acquired in the constructor body rather than owned by a member
error: loop variable ... binds to a temporary constructed from type 'const char* const' [-Werror=range-loop-construct] A const std::string& loop variable over a braced list of string literals. Take the element by value, or hold real std::strings

Worked mistakes

Each of the following was compiled on this toolchain; the messages are quoted as they appeared, and the line numbers belong to the small file each extract came from.

M1. Catching by value

struct LibraryError : std::runtime_error { /* ... */ };

try {
    throw LibraryError("item already on loan");
} catch (LibraryError error) {          // by value
    std::cout << error.what() << "\n";
}
warning: catching polymorphic type 'struct LibraryError' by value [-Wcatch-value=]
   12 |     } catch (LibraryError error) {
      |                           ^~~~~

It still compiles, and at this level of the hierarchy it still prints the right message — which is what makes the mistake survive. What is lost is the derived type: catch a CorruptCatalogue this way and the handler holds a LibraryError, with no file() and no line(). Fix: catch (const LibraryError& error).

M2. Handlers in the wrong order

} catch (const std::runtime_error& error) {          // base: written first
    std::cout << "base handler: " << error.what() << "\n";
} catch (const ItemUnavailable& error) {             // dead code
    std::cout << "derived handler: " << error.what() << "\n";
}
warning: exception of type 'ItemUnavailable' will be caught by earlier handler [-Wexceptions]
   14 |     } catch (const ItemUnavailable& error) {
      |       ^~~~~
note: for type 'std::runtime_error'
   12 |     } catch (const std::runtime_error& error) {
      |       ^~~~~

The note names the culprit, which is the part worth reading: the compiler is telling you which handler shadows which. Order handlers most-derived first, catch (...) last.

M3. An override that throws where the base said noexcept

class Repository {
public:
    virtual void save() noexcept = 0;
};

class MemoryRepository : public Repository {
public:
    void save() noexcept(false) override { /* ... */ }     // looser
};
error: looser exception specification on overriding virtual function
       'virtual void MemoryRepository::save() noexcept (false)'
   12 |     void save() noexcept(false) override {
      |          ^~~~
note: overridden function is 'virtual void Repository::save() noexcept'
    6 |     virtual void save() noexcept = 0;
      |                  ^~~~

This one is a compile error, and it should be: a caller holding a Repository& was promised that save() cannot fail, and the derived class would break that promise on its behalf. The fix is to change the base interface deliberately (and then every caller must be revisited) or to make the override genuinely not throw.

M4. A destructor that throws during unwinding

struct Loader {
    ~Loader() noexcept(false) {
        throw std::runtime_error("cannot close the catalogue file");
    }
};

Compiles cleanly — noexcept(false) is an explicit statement, so the compiler does not warn — and at run time:

terminate called after throwing an instance of 'std::runtime_error'
  what():  cannot close the catalogue file

The message is the wrong one. The exception that was actually propagating was the original failure; that is unrecoverable information now, and the catch block that would have logged it never ran. This is the mistake that makes the standard's severity about throwing destructors sensible.

M5. A throw inside a function that promised not to throw

void promisedNotToThrow() noexcept {
    throw std::runtime_error("broke the promise");
}
warning: 'throw' will always call 'terminate' [-Wterminate]
    5 |     throw std::runtime_error("broke the promise");
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Under this course's -Werror build the warning is an error, so the mistake never reaches a run: the compiler refuses. That is the difference between a promise a reader is asked to trust and a promise the compiler checks.

M6. A reference element over a list of literals

Small, common, and not really about exceptions — it is here because it appears the moment you write a loop over test inputs in a failure-handling exercise:

for (const std::string& typed : {"3", "", "seven", "-2"}) { /* ... */ }
error: loop variable 'typed' of type 'const std::string&' {aka
       'const std::__cxx11::basic_string<char>&'} binds to a temporary constructed from type
       'const char* const' [-Werror=range-loop-construct]
  125 |         for (const std::string& typed : {"3", "", "seven", "-2"}) {
      |                                ^~~~~
note: use non-reference type 'const std::string' to make the copy explicit

The braced list holds const char*; each element needs a std::string constructed from it, so a reference would bind to a temporary whose lifetime is the loop body. The compiler refuses rather than quietly allowing it. Take the element by value — the copy is the point.



The three rules worth memorising

  1. Catch by const&, most-derived handler first, and never swallow silently.
  2. Express ownership in types, not in catch blocks — unwinding is the only cleanup that cannot be skipped.
  3. Write down each operation's guarantee (nothrow, strong, basic) next to its declaration, and test the failure path, not just the success path.

Continue

Previous: Worked examples · Next: Debugging · Module home: Module 13