Module 13 — Concepts and Explanations¶
By Lab 12 the campus library system had a Catalogue, a MemberRegister and a LoanLedger,
all built on one Repository<T>, with ownership handled by unique_ptr and shared_ptr. Every
operation in it assumed that things go right.
They do not. An item may already be on loan. A member identifier may not exist. A catalogue file may have been edited by hand and no longer parse. A loan may exceed the borrowing limit. Twelve modules have quietly assumed success, and every one of those assumptions is a place where the program either ignores a failure or corrupts its own state.
This page answers the questions that turn failure into a design decision.
An exception is a failure that travels upward by itself. You throw it where the operation cannot complete; the runtime unwinds the stack to the nearest handler; and on the way out, every local object in every frame it leaves is destroyed. That last clause is the whole reason this module depends on Module 11.
Every snippet here compiles; the larger ones are in examples/module-13/, each with captured
output. The diagnostics quoted are the ones this toolchain actually produces.
1. What should borrow() do when the item is already out?¶
It cannot do what was asked. Three conventional answers:
// 1. an error code
int borrowByCode(Catalogue& catalogue, int itemId); // 0 = ok, -1 = no item, -2 = on loan
// 2. a status enum - the same thing with names a reader can trust
enum class BorrowStatus { Ok, NoSuchItem, AlreadyOnLoan };
BorrowStatus borrow(Catalogue& catalogue, int itemId);
// 3. an exception
void borrow(Catalogue& catalogue, int itemId); // throws ItemUnavailable
| Error code | Status enum | Exception | |
|---|---|---|---|
| Can the caller ignore it? | yes, silently | yes, silently | no — explicitly, or not at all |
| What must the caller do? | check every call | check every call | handle it, or pass it on |
| What must intermediate code do? | pass the code upward by hand | same | nothing |
| Where does the failure type live? | in a comment | in the type | in the type and the hierarchy |
| Cost when nothing fails | one comparison | one comparison | none measurable |
| Cost when it fails | one comparison | one comparison | stack unwinding |
The program examples/module-13/01-error-codes-versus-exceptions/main.cpp runs all three against the
same failure and prints what each forces on the caller. The middle column — every intermediate
function must remember to pass the code upward — is the reason the third row exists: in a call chain
five functions deep, five functions can forget.
2. Why can an error code be ignored?¶
Because a returned value is just a value, and C++ lets you discard it.
Nothing warns by default. [[nodiscard]] asks the compiler to warn — and asking is all it can do:
That is the strongest help a returned value can get, and it is still only a warning. An exception
cannot be discarded: it either reaches a catch or it ends the program. The standard library
publishes this distinction itself:
std::vector<std::string> shelf{"Compilers", "Algorithms"};
shelf[4]; // unchecked: undefined behaviour, and the library says nothing
shelf.at(4); // checked: throws std::out_of_range
On this implementation, at(4) throws with:
Read the two-way policy as a lesson, not as an inconsistency. A checked access is for an index you did not control; an unchecked one is for an index you have already proved. Every method you write should make the same choice visibly, in its name or its documentation.
3. How do throw, try and catch work?¶
void borrow(Catalogue& catalogue, int itemId) {
Item* item = catalogue.find(itemId);
if (item == nullptr) {
throw ItemUnavailable("no item with id " + std::to_string(itemId));
}
if (item->onLoan()) {
throw ItemUnavailable("item " + std::to_string(itemId) + " is already on loan");
}
item->lend();
}
int main() {
try {
borrow(catalogue, 7);
} catch (const ItemUnavailable& error) {
std::cout << "could not lend: " << error.what() << '\n';
}
}
throwconstructs an exception object and starts the search for a handler. Nothing after thethrowin that function runs.- Propagation: each frame is left in turn —
borrow's caller, its caller's caller — until a matchingcatchis found. If no frame has one,std::terminateruns and the program ends. catch (const ItemUnavailable& error)binds a reference to the thrown object;error.what()is the message.- A
throwexpression's type is the static type of the object thrown — which is why you throw a value and catch a reference.
Two habits to build immediately:
catch (const std::exception& error) // the general handler: standard and yours alike
catch (...) // anything at all: no type, no message, no information
catch (...) is legitimate for exactly two jobs: releasing something by hand (which you should not
need, see §7) and documenting a boundary before rethrowing with throw;. Catching everything and
continuing quietly is worse than crashing, because the invariant it protected is now broken and
nobody knows.
4. What happens to my local objects when it throws?¶
They are destroyed. That is stack unwinding, and it is the single most important mechanical fact in this module.
void borrowOrFail(Catalogue& catalogue) {
Cleaner guard("catalogue file"); // [cleanup] constructor prints
items = loadItems(); // this throws
saveItems(items); // never reached
} // guard's destructor RUNS on the way out
stack grows ↓ throw here
──────────────────────────────────────────────────────────────────
main │
└── borrowOrFail │ guard ──── destroyed ──┐
└── loadItems ────────────┘ │ unwinding
▼
handler in main: catch (const LibraryError& e)
Three things are worth being precise about, because students routinely get them wrong:
- Only fully constructed objects are destroyed. A
Cleanerwhose constructor threw has never existed; its destructor will not run. §7 shows the consequence for a constructor that acquires a resource in its body. - Destruction is in reverse order of construction, innermost frame first.
- Nothing else happens. No
catchblock in a skipped frame runs, nofinallyblock exists in C++, and no cleanup you wrote as a statement gets a second chance. Unwinding runs destructors and nothing more — which is exactly why ownership must be expressed in types.
examples/module-13/02-unwinding-and-cleanup/main.cpp prints every construction and destruction
with a counter, on both the normal and the throwing path, and reproduces the leak that a hand-written
release statement causes.
5. Which handler runs, and why must I catch by reference?¶
Handlers are tried in the order written, and the first whose type matches runs. A base class matches its derived types, so:
catch (const std::runtime_error& error) { /* runs for ItemUnavailable too */ }
catch (const ItemUnavailable& error) { /* NEVER runs */ }
This toolchain diagnoses the dead handler rather than letting it pass unnoticed:
warning: exception of type 'ItemUnavailable' will be caught by earlier handler [-Wexceptions]
note: for type 'std::runtime_error'
So: most-derived first, base classes after, catch (...) last.
Catching by value is the second mistake, and it is subtler because the correct and incorrect versions look nearly identical:
catch (LibraryError error) // WRONG: copies, and slices
catch (const LibraryError& error) // right: binds a reference
A derived exception caught by value is copied as the base type: the derived part — its extra data, its distinguishing type — is gone, and the handler cannot tell two different failures apart. The compiler warns when it is polymorphic:
Catch by const& always. There is no case where catching a copy of an exception is better.
6. How do I report something a human can act on?¶
A hierarchy rooted in std::exception does two jobs at once, and it is worth separating them:
- the type classifies the failure, so a caller can catch broadly (
LibraryError) or narrowly (ItemUnavailable); - the message —
what()— says what happened, for the person reading the log.
class LibraryError : public std::runtime_error {
public:
LibraryError(std::string message, std::string kind, std::string category)
: std::runtime_error(std::move(message)), kind_(std::move(kind)),
category_(std::move(category)) {}
const std::string& kind() const noexcept { return kind_; }
const std::string& category() const noexcept { return category_; }
private:
std::string kind_;
std::string category_;
};
class ItemUnavailable : public LibraryError {
public:
explicit ItemUnavailable(const std::string& title)
: LibraryError("item is already on loan: " + title, "ItemUnavailable", "availability") {}
};
Design rules that follow from the two jobs:
| Rule | Why |
|---|---|
Derive from std::runtime_error or std::logic_error, not from nothing |
A caller can then catch std::exception at the boundary and still print a message |
Give what() enough to identify the object (title, id) |
A message that says "invalid input" wastes the reader's time |
| One type per catchable category | A caller that can act differently needs a type it can name |
Do not put a using namespace std;-style shortcut in the type |
The hierarchy is an interface; keep it narrow |
Do not let what() throw |
It is a const noexcept virtual; returning std::string by value from it is a bug |
examples/module-13/03-custom-hierarchy-and-slicing/main.cpp builds this hierarchy for the library
system and then breaks it three ways on purpose — catching by value, ordering the base handler
first, and losing the original failure during a rethrow — so that each mistake is something you have
seen happen and can recognise.
The standard library's own messages are a useful reminder of why yours should be better. On this implementation:
The message is implementation-defined, and this one is useless. Your what() strings are the ones a
marker, or a future colleague, will actually read.
7. How does this not leak?¶
Unwinding destroys local objects. RAII (Module 11) makes destruction mean release. Therefore a
resource owned by an object is released on every exit path, without a catch block anywhere.
// BEFORE - the release is a statement, so it is skipped when load() throws
void reload(const std::string& path) {
HANDLE file = openCatalogue(path); // raw resource
load(file); // throws here -> file never closed
close(file);
}
// AFTER - the release is a destructor, so unwinding performs it
void reload(const std::string& path) {
CatalogueFile file(path); // owns the handle
load(file); // throws here -> ~CatalogueFile runs
} // and here
The fix is a destructor, not a catch. That is the whole argument, and it is why the raw version is
shown before the RAII version in the example program.
The constructor case is the one exception students miss. If a constructor throws, the destructor of the object under construction does not run — but the destructors of its already-constructed members do:
class Loan {
public:
Loan(Item& item, Member& member, const Date& due)
: item_(item), member_(member), // if the next line throws...
log_(openLog()) { // ...these members are still destroyed
if (member.overdueCount() >= limit) {
throw LoanLimitExceeded(member.id());
}
registerLoan(); // acquiring here would leak
}
};
So: acquire into members, never into the constructor's body. A resource acquired in the body and not yet owned by a member is the one thing unwinding cannot release.
8. What state is my object in after a failure?¶
This is the question the module has been leading to, and it has exactly three published answers.
| Guarantee | If the operation fails | What it costs |
|---|---|---|
| Nothrow | It cannot fail | Nothing — it is a design constraint, not a technique |
| Strong | Nothing changed: the object is exactly as it was | Usually one copy |
| Basic | No leaks, object still valid, contents may have changed | Nothing |
The basic guarantee is what you get by default, and it is easy to mistake for the strong one. The example program makes the difference visible rather than theoretical:
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
The basic version pushed the record first and validated afterwards. Nothing leaked and the object is perfectly valid — and every caller that cached the size or is part-way through iterating it is now wrong. A caller cannot recover correctly from a failure it did not know could change the object, so the guarantee belongs next to the declaration:
class Catalogue {
public:
// Strong guarantee: on failure, the catalogue is unchanged.
void add(const Item& item); // copy-and-swap
// Basic guarantee: the item may have been added even if this throws.
void addAndNotify(const Item& item);
};
The strong guarantee is Module 10's copy-and-swap wearing a different hat: do the work on a copy,
then swap, because swap is noexcept. The last step cannot fail, which is what turns "do the work
and hope" into commit-or-rollback.
9. What does noexcept promise?¶
That the function does not throw. It is part of the interface, checked by the compiler, and read by the standard library:
std::size_t size() const noexcept; // cannot fail
void swap(Catalogue& other) noexcept; // cannot fail - which the strong guarantee depends on
void add(const Item& item); // can fail, and says so by saying nothing
Break the promise and the program does not unwind — it terminates:
noexcept is also enforced through inheritance. If a base declares noexcept, an override cannot
loosen it:
error: looser exception specification on overriding virtual function
'virtual void MemoryRepository::save() noexcept (false)'
note: overridden function is 'virtual void Repository::save() noexcept'
And it is read by the library, not merely respected: std::vector moves elements during growth only
when the element's move is noexcept (Module 10). A type with a throwing move gets copied instead,
and a container cannot keep its own guarantees by moving it.
Destructors are noexcept by default since C++11, for the obvious reason: they run during
unwinding, and two failures in flight cannot both be reported. This toolchain says so at compile time:
warning: 'throw' will always call 'terminate' [-Wterminate]
note: in C++11 destructors default to 'noexcept'
Mark noexcept only what genuinely cannot throw, and treat a violation as a bug rather than a
condition to handle.
10. Should everything throw?¶
No — and deciding when not to is part of the design. Four categories cover almost every failure a program meets:
| Category | Where it belongs | Mechanism |
|---|---|---|
| Data from outside the program | at the boundary, once | validate, then trust it |
| An expected outcome (nothing matched) | at the call site | return a value — std::optional, a status, a bool |
| The operation cannot do what was asked | at the operation | throw, with the type documented |
| The caller is wrong | at the precondition | assert or std::logic_error |
examples/module-13/05-validation-and-design-rules/main.cpp applies all four to one feature set and
prints the rules back as a summary. The distinction that matters most is between the second row and
the third:
- A search that finds nothing is an answer. Returning it makes loops readable and keeps the exceptional path for the cases that are actually exceptional.
- A request for an item that does not exist is a failure: the operation could not do what was asked, and no local repair changes that. Returning a placeholder here is worse than throwing, because the caller continues with a fake item.
The cost argument for avoiding exceptions is usually stated backwards. The non-throwing path costs about what an error-code check costs; what is expensive is throwing, which is why throwing inside a tight loop that runs a million times is control flow wearing the costume of failure handling.
11. How do I know the failure path works?¶
By running it. A failure path that has never executed is a failure path that does not work — and the reason this matters here is that the leak, the broken invariant and the wrong state all live on the path nobody exercises.
The three techniques that make it testable:
// 1. a top-level handler: turns an abort into a readable message and a clean exit status
int main() {
try {
return run();
} catch (const std::exception& error) {
std::cerr << "failed: " << error.what() << '\n';
return 1;
}
}
// 2. counters that prove balance: acquisition and release must match on both paths
std::cout << "acquired " << acquired << ", released " << released << '\n';
// 3. a deliberate throw: the only way to test the strong guarantee
try { catalogue.addOversized(); } catch (const std::length_error&) { }
assert(catalogue.size() == sizeBefore);
That third pattern is the substance of Lab 13: two runs of the same sequence, one where everything succeeds and one where an operation throws, with counters proving that acquisition and release balance in both.
Checkpoint¶
Answer these before moving on. If any of them needs looking up, the section is given.
- Why can an exception not be ignored by accident, when a returned error code can? (section 1)
- What does unwinding destroy, and what does it not do? (section 4)
- Why must an exception be caught by reference? (section 5 — and name the warning this compiler emits when it is not)
- A constructor throws after acquiring a raw handle in its body. What is released, and what is not? (section 7)
- State the three guarantees, and say which one costs a copy. (section 8)
- What does
noexceptpromise, and what is the consequence of breaking it? (section 9) - Name the four categories a failure can fall into, and the mechanism that belongs to each. (section 10)
- You have written a failure path and it looks right. What single action makes that evidence rather than opinion? (section 11)
Next: Worked examples — five programs, each with a prediction to make before you read its output, plus one reproduction you run yourself.