Skip to content

Module 13 — Worked Examples

Five programs in examples/module-13/, each folder with its own expected-output.txt. Build any of them with the course's command:

g++ -std=c++17 -Wall -Wextra -Werror main.cpp -o program
./program

Every program prints its own reasoning as it goes, so you can read the output as an argument rather than a dump of values. Each section below gives you something to predict before you scroll to the output — do that. The prediction is where the learning is, and several of these programs are designed so that the honest prediction is wrong.

One demonstration in this module is deliberately not a folder under examples/: the program that calls std::terminate cannot exit normally, so the automated check that compares captured output cannot verify it. It is quoted in §6, source and transcript together, and you are expected to run it yourself.


1. The same failure, reported three ways

examples/module-13/01-error-codes-versus-exceptions/main.cpp

Predict first: the program calls one operation whose second call must fail. Which version — the status one or the throwing one — leaves the caller able to ignore the failure, and what does each version force every intermediate function to do?

The core of the program is one operation written twice:

// 1. a status enum, returned
BorrowStatus borrowByCode(const std::string& title);

// 2. an exception, thrown
void borrowByException(const std::string& title);   // throws ItemUnavailable

Part A shows honest use of the first; Part B the second; Part C compares what each demands; Part D shows the standard library publishing both policies side by side; Part E measures the cost a status imposes on a three-frame call chain.

Part A - an error code, checked honestly
  borrowByCode("Compilers") -> success
  borrowByCode("Compilers") -> already on loan   <- the checkout worked
  borrowByCode("Unknown Title") -> no such item
  Three results, three branches. This code is correct - because every
  call site checks. `[[nodiscard]]` on the function asks the compiler to
  warn when a caller does not, which is the strongest help a return value
  can get, and it is only a warning.
Part C - what each forces on the caller
  status:    the caller MUST remember to read the result. Nothing stops
             the value being discarded, and the program then behaves as
             though the operation succeeded.
  exception: the caller must handle it or let it propagate. There is no
             third option - an unhandled exception ends the program.

What to notice. The status version is not a strawman: it is correct, it is fast, and it is the right design for some failures. What it cannot do is survive a careless caller. Part E names the real cost of the choosing wrongly in either direction:

  What that costs is on the THROWING path, not the normal one: a call that
  succeeds is not slower for being able to throw.

The trade is about expectation and frequency, never about speed alone.


2. What unwinding actually destroys

examples/module-13/02-unwinding-and-cleanup/main.cpp

Predict first: throwMidway() constructs three named locals and then throws. Predict the order of the destruction lines, and predict how many; then predict whether a resource released by a statement is released when the statement is skipped.

Part A - unwinding destroys locals, in reverse order
      [member] constructed a
      [member] constructed b
      [member] constructed c
      [member] destroyed c
      [member] destroyed b
      [member] destroyed a
      [handler] caught: failed after three locals existed

Then the leak, counted rather than argued:

Part B - a raw resource, released by a statement
  throwing path:
      [device] acquired raw sensor (handle 2)
      [handler] caught: raw sensor read failed
  two calls, and the number of devices still live went from 0 to 1

What to notice. The release statement in Part B is correct — it is simply not reached, and by the time the handler runs the local handle is gone, so nothing can release it later. Part C writes the same resource as an RAII member and gets three releases out of three paths, including the throwing one:

Part C - the same resource, owned by an object
  normal path:       released raii sensor (handle 3)
  early return:      released raii sensor (handle 4)
  a throw:           released raii sensor (handle 5)
  three paths, releases in this part: 3

Part D then delivers the case that catches almost everyone — a constructor that acquires a resource in its body and then throws:

Part D - a constructor that throws destroys only what was constructed
  the member was destroyed; devices live went from 1 to 2   <- the body-acquired handle leaked
  devices live went from 2 to 2   <- the guard_ member was destroyed, and released the device

Same class, same failure, one line moved from the body into a member. Part E is a destructor that has nothing in flight — so it may report, and must not throw — and Part F shows that a failed acquisition cannot leak at all, because the object never existed.

The final book-keeping is the lesson:

  acquisitions       7
  refused            1   (the acquisition threw before a resource existed)
  releases           5
  devices still live 2

Two devices are still live, on purpose, and each is one of the two mistakes above.


3. A hierarchy, and the three ways to make one useless

examples/module-13/03-custom-hierarchy-and-slicing/main.cpp

Predict first: a CorruptCatalogue exception is caught by catch (LibraryError error) — by value. What exactly does the handler lose: the message, the type, the extra accessors, or all three?

Part A catches narrowly; Part B catches the base with a virtual classifier and keeps the detail; Part C answers the prediction:

Part C - catching by value loses the derived part
  by value:  kind LibraryError, category data
             this handler cannot ask for file() or line(): the accessors
             lived in the derived type, and the copy is a LibraryError.
             Note what survived and what did not. The TEXT still names the
             line, because the base built the message before the copy.
             The CLASSIFICATION did not: kind() reports LibraryError ...
  by const&: kind CorruptCatalogue, category data
             file catalogue.txt, line 12   <- the context is intact

What to notice. This is why the mistake survives code review: nothing crashes, a message is printed, and the information the handler needed has quietly gone. The text survived because the base class built it before the copy — the type did not, because the type is not data.

Part D is the ordering mistake; Part E shows the difference between throw; (re-raises the same object, type intact) and throw error; (copies it — and from a base reference, slices it again):

  `throw;` has no operand and re-raises the current exception with its
  type and data intact. `throw error;` would copy it - and, from a base
  reference, slice it, which is Part C again.

4. The three guarantees, and noexcept

examples/module-13/04-guarantees-and-noexcept/main.cpp

Predict first: an operation validates its input after inserting. The validation throws. Is the container changed? Is anything leaked? Is the object still valid? (Three questions, and the surprising answer is yes, no, yes.)

Part B - BASIC: the failure happened, and so did the change
  caught: copies cannot be negative: Bad Record
  size before 2, after 3: Compilers, Algorithms, Bad Record

Part C - STRONG: work on the side, then commit
  caught: copies cannot be negative: Bad Record
  size before 2, after 2   <- unchanged

What to notice. The difference between the two functions is five lines, and it is not about catch: it is about when the object is modified. The strong version does the work on a copy and swaps, which is Module 10's idiom doing duty as a guarantee:

  build a correct copy, then swap it in. `swap` is noexcept, so the last step
  cannot fail - which is what turns 'do the work and hope' into
  commit-or-rollback.

Part D checks four noexcept claims with static_assert, so the guarantee is written where the compiler verifies it, and Part E explains the consequence of breaking the promise: termination without unwinding, because the caller has already been told the call cannot fail.


5. When to throw, and when not to

examples/module-13/05-validation-and-design-rules/main.cpp

Predict first: four failures are coming — a number the user typed, a search that finds nothing, a request for a title the catalogue does not have, and a negative count. Predict which of the four mechanisms each deserves: validate, return, throw, or assert.

Part A - validate at the boundary, then stop worrying
  '3' -> accepted, 3
  '' -> refused: no value given
  'seven' -> refused: not a number: 'seven'

Part B - return a value: the expected outcomes
  find("Compilers") -> found, 2 copy/copies
  find("Astrophysics") -> nothing matched

Part C - throw: what nothing local can repair
  require() threw: no item titled 'Astrophysics'

What to notice. find() and require() are the same lookup with two different answers to the question "who decides what happens when there is no match?" — the caller, or the operation. Naming them differently is what makes the choice visible at the call site.

Part E quotes the standard library's own messages, and they are instructive precisely because they are poor:

  shelf.at(4) threw std::out_of_range: vector::_M_range_check: __n (which is 4) >= this->size() (which is 2)
  std::stoi("seven") threw std::invalid_argument: stoi
  std::stoi("99999999999999999999") threw std::out_of_range: stoi

The standard fixes the type of a failure but leaves the message implementation-defined, and this implementation's stoi message is the literally useless word stoi. Your what() strings are read by people; write them for people.


6. The reproduction you must run yourself: std::terminate

This is the module's deliberate failure. It is not in examples/ because the program does not exit normally, so it cannot be verified by the automated comparison. Save it as terminate.cpp and run it.

#include <iostream>
#include <stdexcept>
#include <string>

struct Cleaner {
    std::string name;
    explicit Cleaner(std::string n) : name(std::move(n)) {}
    ~Cleaner() { std::cout << "  [cleanup] " << name << "\n"; }   // buffered!
};

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

int main() {
    try {
        Cleaner guard("catalogue file");
        Loader loader;
        throw std::runtime_error("the original failure");
    } catch (const std::exception& error) {
        std::cout << "caught: " << error.what() << "\n";
    }
    return 0;
}
$ g++ -std=c++17 -Wall -Wextra terminate.cpp -o terminate && ./terminate
terminate called after throwing an instance of 'std::runtime_error'
  what():  cannot close the catalogue file

That is the entire output, and the compiler said nothing at all while building it — which is worth noticing in itself, because a destructor declared noexcept(false) is a deliberate, visible statement rather than an accident, and the compiler takes you at your word. Read what that transcript says, because it is a debugging trap:

  • The message names the wrong failure. The destructor's exception is reported; the original failure is never mentioned, and the catch block never runs.
  • No cleanup line appears. Cleaner's destructor did not get to print: std::terminate does not unwind, and [cleanup] was still sitting in a buffer that the abort discards. Add std::endl and it appears — which is the general rule: on an abnormal exit, anything still buffered is lost. std::cerr is unbuffered and survives; that is why a diagnostic goes there.
  • The exit status is not 0. This shell reports 127 for the abort; on many systems you will see 134, which is SIGABRT. Either way the process did not return from main.

The same three symptoms appear if you mark a function noexcept and let it throw. This one the compiler does catch, in advance:

#include <iostream>
#include <stdexcept>

void promisedNotToThrow() noexcept {
    throw std::runtime_error("broke the promise");
}

int main() {
    promisedNotToThrow();
    std::cout << "never reached\n";
    return 0;
}
$ g++ -std=c++17 -Wall -Wextra promise.cpp -o promise
promise.cpp: In function 'void promisedNotToThrow()':
promise.cpp:5:5: warning: 'throw' will always call 'terminate' [-Wterminate]
    5 |     throw std::runtime_error("broke the promise");
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

$ ./promise
terminate called after throwing an instance of 'std::runtime_error'
  what():  broke the promise

What to notice. Same warning text, two very different situations. Here the compiler saw the throw inside a function that had promised not to throw and told you so at build time. In the destructor case there was no warning, because the destructor said noexcept(false) out loud: the promise was withdrawn, and the termination moved from a compile-time certainty to a runtime accident. Under this course's -Werror build the warning is an error, which is the compiler refusing to let you write the function that way at all — a far better outcome than a mystery abort three months later.


7. Where each example is used

Program Used by
01-error-codes-versus-exceptions §1–2 of the concepts page, exercises W1 and C1
02-unwinding-and-cleanup §4 and §7 of the concepts page, debugging, Lab 13 requirement 5
03-custom-hierarchy-and-slicing §5–6 of the concepts page, misconceptions 3 and 6
04-guarantees-and-noexcept §8–9 of the concepts page, Lab 13 requirement 3, challenge X3
05-validation-and-design-rules §10 of the concepts page, exercises X1, challenge X1
§6 (run it yourself) debugging, self-assessment 6

Continue

Previous: Concepts and explanations · Next: Misconceptions and mistakes · Module home: Module 13