SafeConfig Parser
You are building a SafeConfig parser — a thin wrapper around a string→string
map that reads configuration entries and validates them with exceptions. The
parser exposes two typed exception classes, a port-parsing function that throws
the right type for each failure mode, a noexcept boundary wrapper that
contains exceptions completely, and an Unwinder probe that makes stack
unwinding physically observable.
Why config parsing? Because it hits every exception scenario at once: a missing
key is a structurally different failure from a bad-format value, which is
different from an out-of-range integer. Those differences belong in exception
types, not in error-code integers — and std::exception's what() carries
the human-readable context that an error code can never carry. The project also
demonstrates the two main exception policies — propagate freely through the call
graph, or absorb at a boundary and return a plain bool — so you can recognize
both in real code.
The Unwinder probe (Task 4) is the chapter's central "make it physical"
moment: you will see a counter increment during stack unwinding even though
the function that owned the object never reached a return statement.
Your tasks
getOrThrow— throw the specific derived type (Task 1). Look upkeyinm_entries. If absent,throw MissingKeyError { key }. If present, return the value. Onefind()+if (== end())+throwis enough.parsePort— three distinct failure modes, three distinct types (Task 2). CallgetOrThrow(key)first (letMissingKeyErrorpropagate naturally — no catch here). Next, reject an empty value withthrow ConfigError{...}— a per-character loop passes vacuously for"", andstd::stoi("")would leakstd::invalid_argument, a type that's not in the contract. Then loop over the value's characters: if any is not a digit,throw ConfigError{...}. Convert withstd::stoi, then range-check[1, 65535]: if out of range,throw std::out_of_range{...}. On success, return the int.tryParsePort— thenoexceptboundary wrapper (Task 3). Wrap a call tocfg.parsePort(key)in atryblock. On success, store the result inoutPortand returntrue. In acatch (...)block, returnfalseand leaveoutPortunchanged. Thenoexceptin the signature is already there; your body makes the promise real.Unwinderconstructor, destructor, andthrowingWork(Task 4).- Constructor: increment
*enteredto record that the probe was created. - Destructor: increment
*destroyed. This must not throw (notes 27.8). throwingWork: create a localUnwinder uw{pEntered, pDestroyed}, thenthrow ConfigError{"work failed"}. The destructor runs during unwinding, before thecatchblock in the test — that's the observable behaviour.
- Constructor: increment
rethrowOuter— catch, augment, re-raise (Task 5). CallthrowingWorkinside atryblock. Catchconst ConfigError& e. Build a new message:"outer: " + e.what().throw ConfigError{newMessage}— this raises a new exception object with the layered context. Do not use barethrow;here (that would re-raise the original; we want a different object).
Success criteria
getOrThrow("timeout")throwsMissingKeyErrorwhosewhat()names"timeout"- Catching
MissingKeyErrorfires beforeConfigErrorfor the same exception parsePortwith"abc"throwsConfigError, notMissingKeyErrorparsePortwith an empty value throwsConfigError— not a leakedstd::invalid_argumentfromstd::stoi("")(the vacuous-loop trap)parsePortwith"0"or"65536"throwsstd::out_of_range, notConfigErrortryParsePortreturnsfalsefor every failure mode and never propagatesthrowingWorkincrements both counters; destroyed increments before the catch runsrethrowOuterthrows aConfigErrorwhosewhat()starts with"outer: "
Concepts practiced
New in Chapter 27:
throwwith a typed exception object — throwing the right type (notes 27.2)try/catchby const reference — avoids slicing, avoids copies (notes 27.2)- Stack unwinding — destructors run for all locals, even when an exception propagates past them (notes 27.3)
std::exceptionhierarchy —what(), deriving fromstd::runtime_error, catching derived before base (notes 27.5)- Custom exception classes —
ConfigError : std::runtime_error,MissingKeyError : ConfigError(notes 27.5) - Catch ordering — most-derived handler first, base handler later (notes 27.5)
- Rethrow with a new exception —
throw NewType{...}to layer context (notes 27.6) noexceptfunction declaration — a real, enforced contract;catch (...)inside makes the promise true (notes 27.9)
Reused from earlier chapters:
- Functions, headers, header guard (Ch 2)
std::string,std::string_view(Ch 5)const-correctness (Ch 5)- Member functions, access specifiers, constructors (Ch 14)
= deleteto prevent copying (Ch 14)std::optional(available from Ch 12; not used here but noted in the header)std::unordered_map(a preview — formally containers Chapter 17+; provided as scaffolding so you don't need to implement it)std::movein constructor for efficient map transfer (Ch 22; provided scaffolding)
Constraints
Allowed: everything from Chapters 1–27. Key constructs:
throw, try, catch (const T&), catch (...), bare throw; to rethrow,
std::runtime_error, std::out_of_range, std::exception,
custom exception classes deriving from the standard hierarchy,
noexcept on a function that genuinely cannot propagate.
Required idioms (per notes 27.2–27.9):
- Catch class-type exceptions by
constreference (avoids slicing and copy). - Place most-derived catch handlers before base class handlers.
- Never throw from a destructor (notes 27.8 — the runtime calls
std::terminateif a destructor throws while another exception is being handled). - The
noexceptspecifier must be a real promise — back it withcatch (...).
Forbidden (out of scope):
- Function try blocks (mentioned in notes 27.7 but not a learner task here).
noexcepton a function that can actually propagate — the lie causesstd::terminate.- Deprecated dynamic exception specifications (
throw(...)— pre-C++11 syntax). std::nested_exception/std::throw_with_nested— beyond Ch 27 scope.
Build & run locally
make # compile-check starter/config_parser.cpp (warning-clean)
make test # grade your code → RED until TASK blocks are filled in
make solution # build + run the reference solution if you get stuck
make test-solution # verify the reference is green (our proof it's solvable)
make clean # remove build artifactsmake test is the grader — it supplies its own main() in tests/tests.cpp.
Hints
Task 1 — finding a key in an unordered_map
std::unordered_map::find returns an iterator. If the key is absent, it equals
m_entries.end(). A std::string_view cannot directly key the map (C++17
doesn't have heterogeneous lookup for unordered_map by default), so convert
first: auto it { m_entries.find(std::string{key}) };.
auto it { m_entries.find(std::string{key}) };
if (it == m_entries.end())
throw MissingKeyError { std::string{key} };
return it->second;Task 2 — validating digits and converting to int
Guard the empty case first, then walk the value string checking each character:
if (value.empty())
throw ConfigError { "port value is empty" };
for (char ch : value)
if (!std::isdigit(static_cast<unsigned char>(ch)))
throw ConfigError { "port value is not numeric: '" + value + "'" };
int port { std::stoi(value) };
if (port < 1 || port > 65535)
throw std::out_of_range { "port " + std::to_string(port) + " is outside [1, 65535]" };
return port;Why the empty guard must come first: "" sails through the loop untouched
(zero characters → zero rejections), and std::stoi("") throws
std::invalid_argument — which is not a ConfigError (it lives on the
std::logic_error branch of the hierarchy), so a caller following this
header's contract would never catch it. The static_cast<unsigned char>
before std::isdigit is correct practice to avoid UB on char values above
127 (char can be signed on some platforms).
Task 3 — the noexcept boundary
try {
outPort = cfg.parsePort(key);
return true;
}
catch (...) {
return false;
}The catch (...) "catch-all" (notes 27.4) absorbs every possible exception so
none can escape to the noexcept boundary. If the boundary were violated,
std::terminate() would be called — a hard crash with no recovery.
Task 4 — making unwinding physical
// Constructor
Unwinder::Unwinder(int* pEntered, int* pDestroyed)
: entered{pEntered}, destroyed{pDestroyed}
{ ++(*entered); }
// Destructor — no throw allowed
Unwinder::~Unwinder() { ++(*destroyed); }
// throwingWork
void throwingWork(int* pEntered, int* pDestroyed) {
Unwinder uw{pEntered, pDestroyed}; // enters: *pEntered becomes 1
throw ConfigError{"work failed"}; // uw's dtor runs HERE, during unwind
}The destructor runs before the caller's catch block — that is the observable proof of stack unwinding.
Task 5 — layering context on an exception
try {
throwingWork(pEntered, pDestroyed);
}
catch (const ConfigError& e) {
throw ConfigError { std::string{"outer: "} + e.what() };
}This is throw NewObject{...}, not bare throw;. Bare throw; would re-raise
the original ConfigError{"work failed"} unchanged. We want a new object
with the composed message, so we construct and throw explicitly. The original
Unwinder destructor already ran before this catch block, so *destroyed is
already correct when we re-raise.
Stretch goals
- Add a
parsePositiveIntfunction that throwsConfigErrorfor non-numeric values andstd::out_of_rangefor values ≤ 0. Reuse your digit-check loop. - In
rethrowOuter, switch from "throw new" to barethrow;and observe that thewhat()message no longer starts with"outer: "— that's the slicing vs. rethrow difference made physical. - Add a
getOptionalmethod that returnsstd::optional<std::string>instead of throwing — then compare: which callers are cleaner withoptional, which with exceptions? (Motivates notes 27.1's "not every error should be an exception" discussion.) - Wrap
mainof a small driver intry { ... } catch (const std::exception& e) { std::cerr << "fatal: " << e.what() << '\n'; return 1; }— the CS6340 top-level tool pattern from the notes' CS6340 patterns section.
// Chapter 27 — Exceptions · Project: SafeConfig Parser (STARTER)
// ─────────────────────────────────────────────────────────────────────────────
// Fill in the five TASK blocks below. Each maps 1:1 to a task in the README
// and to a declaration in ../config_parser.h. The bodies currently return
// PLACEHOLDERS so the file compiles immediately — that's why `make test` is
// RED right now. Your job is to turn it GREEN by wiring up the real exception
// handling.
//
// make build compile your code (should work right now)
// make test grade it (RED until you fill these in)
// make solution run the reference if you get stuck
//
// Key terms you are making physical (see notes 27.2–27.9):
// throw — raise an exception object
// try/catch — mark a region + handle a matching type
// stack unwind — destructors of locals run as frames are popped
// noexcept — a promise that no exception escapes to the caller
// rethrow — throw; (bare) to re-raise the current exception unchanged,
// or throw NewException{...} to raise a different object
#include "../config_parser.h"
#include <string> // std::string, std::stoi
#include <string_view> // std::string_view
#include <cctype> // std::isdigit
// ─── SafeConfig::getOrThrow (TASK 1) ─────────────────────────────────────────
// Return the value stored under `key` in m_entries. If the key is not present,
// throw a MissingKeyError (notes 27.5: throw the SPECIFIC derived type so the
// caller can catch it by type — not just a generic ConfigError).
//
// Hint: std::unordered_map has a .find(key) that returns an iterator; compare
// to .end() to test for absence. Or use .count(key) == 0.
// std::string_view cannot be used directly as an unordered_map key with the
// default hasher in C++17; convert with std::string{key} first.
//
// ─── TASK 1: getOrThrow ──────────────────────────────────────────────────────
// Throw MissingKeyError when the key is absent; otherwise return the value.
//
// >>> YOUR CODE HERE <<<
//
// ─────────────────────────────────────────────────────────────────────────────
std::string SafeConfig::getOrThrow(std::string_view key) const
{
(void)key;
return ""; // placeholder — always returns empty string (never throws)
}
// ─── SafeConfig::parsePort (TASK 2) ──────────────────────────────────────────
// Parse a port number from the config key `key`.
//
// Step 1: call getOrThrow(key) — let MissingKeyError propagate naturally
// (you do NOT need to catch it here; it is already the right type).
//
// Step 2: reject an EMPTY value: throw ConfigError if value.empty().
// THE TRAP: a per-character digit loop passes VACUOUSLY for "" (there
// are no characters to reject) — and std::stoi("") would then throw
// std::invalid_argument, the WRONG type (not in the contract; a caller
// catching ConfigError would miss it). Check .empty() FIRST.
//
// Step 3: validate that every character of the value is a digit. If any
// non-digit is found, throw ConfigError with a meaningful message.
// (notes 27.2: throw the right type.)
//
// Step 4: convert to int (std::stoi does this), then check the range [1, 65535].
// If out of range, throw std::out_of_range (it is part of the declared
// throws contract — tests verify the EXACT type, not just "threw").
//
// Hint: std::isdigit(ch) checks a single character.
// std::stoi(str) converts a std::string to int (may throw std::invalid_argument
// or std::out_of_range, but your empty + digit checks prevent invalid_argument).
//
// ─── TASK 2: parsePort ───────────────────────────────────────────────────────
// Throw MissingKeyError / ConfigError / std::out_of_range per the contract above.
//
// >>> YOUR CODE HERE <<<
//
// ─────────────────────────────────────────────────────────────────────────────
int SafeConfig::parsePort(std::string_view key) const
{
(void)key;
return 0; // placeholder — always returns 0 (never throws)
}
// ─── tryParsePort (TASK 3) ────────────────────────────────────────────────────
// A NOEXCEPT BOUNDARY wrapper around SafeConfig::parsePort (notes 27.9).
//
// Contract:
// • Call cfg.parsePort(key) inside a try block.
// • If it succeeds, store the result in outPort and return true.
// • If it throws ANYTHING, leave outPort unchanged and return false.
// • No exception must escape — the function is marked noexcept.
//
// Why is this useful? The caller can check a bool without dealing with
// exceptions. This is the "exceptions contained at a policy boundary" pattern
// from the notes' CS6340 section. A top-level tool driver uses exactly this
// shape to translate library exceptions into exit codes.
//
// ─── TASK 3: tryParsePort ────────────────────────────────────────────────────
// Catch ALL exceptions and return false; return true on success.
// (Hint: catch (...) catches every exception type — notes 27.4.)
//
// >>> YOUR CODE HERE <<<
//
// ─────────────────────────────────────────────────────────────────────────────
bool tryParsePort(const SafeConfig& cfg, std::string_view key,
int& outPort) noexcept
{
(void)cfg;
(void)key;
(void)outPort;
return false; // placeholder — always reports failure
}
// ─── Unwinder constructor / destructor (TASK 4) ──────────────────────────────
// The Unwinder struct is a RAII PROBE: it makes stack unwinding OBSERVABLE.
//
// Constructor: save the two pointers, then increment *pEntered so the test
// can confirm the object was created.
// Destructor: increment *destroyed. This runs whether the function returns
// normally OR throws past this Unwinder (notes 27.3).
//
// IMPORTANT — never throw from a destructor (notes 27.8):
// If an exception is already being handled when a second exception tries to
// escape a destructor, std::terminate() is called immediately. Keep the
// destructor body to simple, non-throwing operations only.
//
// ─── TASK 4a: Unwinder constructor ──────────────────────────────────────────
//
// >>> YOUR CODE HERE <<<
//
// ─────────────────────────────────────────────────────────────────────────────
Unwinder::Unwinder(int* pEntered, int* pDestroyed)
: entered { pEntered }
, destroyed { pDestroyed }
{
// placeholder — should increment *entered
}
// ─── TASK 4b: Unwinder destructor ────────────────────────────────────────────
//
// >>> YOUR CODE HERE <<<
//
// ─────────────────────────────────────────────────────────────────────────────
Unwinder::~Unwinder()
{
// placeholder — should increment *destroyed
}
// ─── throwingWork (TASK 4c) ───────────────────────────────────────────────────
// Create a local Unwinder, then throw a ConfigError. The exception unwinds the
// Unwinder (calling its destructor) before the throw reaches the caller's
// catch block. The test checks that *pDestroyed incremented even though the
// function never reached a normal return.
//
// >>> YOUR CODE HERE <<<
//
// ─────────────────────────────────────────────────────────────────────────────
void throwingWork(int* pEntered, int* pDestroyed)
{
(void)pEntered;
(void)pDestroyed;
// placeholder — should create an Unwinder and then throw ConfigError
}
// ─── rethrowOuter (TASK 5) ────────────────────────────────────────────────────
// Demonstrates the "catch, augment, re-raise" idiom (notes 27.6).
//
// Call throwingWork in a try block. When it throws, catch the exception by
// CONST REFERENCE (notes 27.2 — avoids slicing and unnecessary copies).
// Build a new message: "outer: " + e.what(). Then throw a NEW ConfigError
// with that composed message (this is different from bare `throw;` — we
// deliberately raise a different exception object here to layer context).
//
// The test verifies:
// • The exception that escapes rethrowOuter IS a ConfigError.
// • Its what() starts with "outer: ".
// • *pDestroyed incremented (the Unwinder inside throwingWork still ran).
//
// ─── TASK 5: rethrowOuter ────────────────────────────────────────────────────
// Catch (const ConfigError& e), then throw ConfigError{"outer: " + e.what()}.
//
// >>> YOUR CODE HERE <<<
//
// ─────────────────────────────────────────────────────────────────────────────
void rethrowOuter(int* pEntered, int* pDestroyed)
{
(void)pEntered;
(void)pDestroyed;
// placeholder — should call throwingWork and re-throw with layered message
}
Try the lab first — the learning is in the attempt.
// Chapter 27 — Exceptions · Project: SafeConfig Parser (REFERENCE SOLUTION)
// ─────────────────────────────────────────────────────────────────────────────
// Complete, correct, warning-clean reference implementation of ../config_parser.h.
// Peek only after you've taken a real swing at starter/config_parser.cpp.
// The learning is in wiring the throw/catch logic yourself, then comparing.
#include "../config_parser.h"
#include <string>
#include <string_view>
#include <cctype> // std::isdigit
// ─── SafeConfig::getOrThrow ───────────────────────────────────────────────────
// TASK 1: return the value or throw MissingKeyError (the specific derived type).
//
// Key technique (notes 27.2):
// We throw MissingKeyError, not just ConfigError. The catch block in a test
// that uses `catch (const MissingKeyError&)` will match; one that uses
// `catch (const ConfigError&)` ALSO matches because MissingKeyError IS-A
// ConfigError — the standard exception hierarchy at work. The type carries
// information that a bare int code never could.
std::string SafeConfig::getOrThrow(std::string_view key) const
{
// Convert string_view to std::string for use as unordered_map key.
auto it { m_entries.find(std::string { key }) };
if (it == m_entries.end())
throw MissingKeyError { std::string { key } }; // THROW: key absent
return it->second;
}
// ─── SafeConfig::parsePort ────────────────────────────────────────────────────
// TASK 2: parse a port number, throwing the RIGHT type for each failure mode.
//
// Three distinct failure modes → three distinct exception types (notes 27.2):
// 1. Key absent → MissingKeyError (propagates from getOrThrow)
// 2. Empty / non-digit value → ConfigError (bad format)
// 3. Out-of-range int → std::out_of_range (value not in [1, 65535])
//
// CS6340 lens: this mirrors an LLVM pass checking IR semantics — different
// "bad" conditions get different error types so the recovery point can be
// specific. A generic "something failed" error loses information.
int SafeConfig::parsePort(std::string_view key) const
{
// Step 1: look up the key — let MissingKeyError propagate naturally
// (no try/catch here: we don't handle it, so don't intercept it).
std::string value { getOrThrow(key) };
// Step 2: reject an EMPTY value before the digit loop. The loop below
// passes VACUOUSLY for "" (no characters to reject), and std::stoi("")
// would then throw std::invalid_argument — a type that is NOT in our
// contract, and one a caller catching ConfigError would miss entirely
// (invalid_argument derives from std::logic_error, not runtime_error).
// Guarding here keeps every malformed-value path on the documented type
// (notes 27.2: throw the RIGHT type — never leak an implementation
// detail's exception through your interface).
if (value.empty())
throw ConfigError { "port value is empty" };
// Step 3: every character must be a digit — no leading '-', no spaces.
for (char ch : value)
{
if (!std::isdigit(static_cast<unsigned char>(ch)))
{
// THROW ConfigError: bad format. The message names the offender.
throw ConfigError {
"port value is not numeric: '" + value + "'"
};
}
}
// Step 4: convert and range-check. stoi handles the integer conversion
// (safe now: the value is non-empty and all digits, so invalid_argument
// cannot occur).
int port { std::stoi(value) };
if (port < 1 || port > 65535)
{
// THROW std::out_of_range: the notes mention this type for
// "index/key outside valid range" (notes 27.5, table).
throw std::out_of_range {
"port " + std::to_string(port) + " is outside [1, 65535]"
};
}
return port;
}
// ─── tryParsePort ─────────────────────────────────────────────────────────────
// TASK 3: noexcept boundary — absorb all exceptions, return bool+out-ref.
//
// Why noexcept here? (notes 27.9)
// The caller is a top-level driver that cannot handle exceptions itself.
// Marking the boundary noexcept makes the promise explicit and prevents
// accidents where a future code change adds a throw path that slips through.
// The noexcept promise is REAL because we catch (...) inside — every possible
// exception is handled before reaching the function boundary.
//
// catch (...) — the "catch all" handler (notes 27.4). Use it sparingly; here
// it is exactly right because the boundary must contain ANY failure.
bool tryParsePort(const SafeConfig& cfg, std::string_view key,
int& outPort) noexcept
{
try
{
outPort = cfg.parsePort(key); // may throw MissingKeyError, ConfigError,
// std::out_of_range, or anything else
return true; // only reached on success
}
catch (...)
{
// All exceptions are swallowed here. outPort is unchanged.
return false;
}
}
// ─── Unwinder constructor ─────────────────────────────────────────────────────
// TASK 4a: save pointers and record construction.
//
// The increment of *entered proves the object was alive when throwingWork ran.
Unwinder::Unwinder(int* pEntered, int* pDestroyed)
: entered { pEntered }
, destroyed { pDestroyed }
{
++(*entered); // record: this Unwinder was created
}
// ─── Unwinder destructor ──────────────────────────────────────────────────────
// TASK 4b: record destruction — normal path OR unwinding path.
//
// Destructor rule (notes 27.8): do NOT throw here. If this destructor ran
// because an exception was already being handled and we threw again, the
// runtime would call std::terminate(). A counter increment is always safe.
Unwinder::~Unwinder()
{
++(*destroyed); // record: destructor ran (may be unwinding)
}
// ─── throwingWork ─────────────────────────────────────────────────────────────
// TASK 4c: create Unwinder local, then throw PAST it.
//
// Stack unwinding path (notes 27.3):
// 1. Unwinder uw is constructed — *pEntered increments.
// 2. throw ConfigError{"work failed"} executes.
// 3. Stack unwinds: uw goes out of scope, Unwinder::~Unwinder runs.
// 4. *pDestroyed increments DURING UNWINDING — before the catch block runs.
// 5. ConfigError propagates to the caller's catch block.
//
// This makes unwinding PHYSICAL: the test will verify *pDestroyed changed
// even though throwingWork never reached a normal return statement.
void throwingWork(int* pEntered, int* pDestroyed)
{
Unwinder uw { pEntered, pDestroyed }; // RAII probe: dtor runs on throw
// Throw ConfigError — the Unwinder dtor runs BEFORE this reaches the caller.
throw ConfigError { "work failed" };
} // <- uw's destructor would also run here on normal exit (never reached)
// ─── rethrowOuter ─────────────────────────────────────────────────────────────
// TASK 5: catch an exception, compose a richer message, throw a new one.
//
// Two rethrowing patterns (notes 27.6):
// bare `throw;` — re-raises the original exception UNCHANGED (no slicing)
// throw New{...} — raises a DIFFERENT exception object (this function)
//
// We use the second form to layer diagnostic context. This is the LLVM
// "wrapping errors" pattern: inner functions report raw facts; outer functions
// add callsite context before propagating.
//
// Catch by CONST REFERENCE (notes 27.2 — avoids slicing and unnecessary copy).
void rethrowOuter(int* pEntered, int* pDestroyed)
{
try
{
throwingWork(pEntered, pDestroyed); // throws ConfigError{"work failed"}
}
catch (const ConfigError& e)
{
// Compose a new message and throw a different exception object.
// "throw e;" here would SLICE if e were a derived type (notes 27.6).
// "throw;" (bare) would re-raise the exact original object.
// We intentionally want the NEW, composed message, so we throw a new
// ConfigError with a layered what() string.
throw ConfigError { std::string { "outer: " } + e.what() };
}
}
// Chapter 27 — Exceptions · Project: SafeConfig Parser (GRADER)
// ─────────────────────────────────────────────────────────────────────────────
// Tiny no-framework unit-test harness — same style as CLAUDE.md.
// Includes ../config_parser.h and exercises every public function across
// many inputs, including the exception-specific edge cases that separate
// correct exception handling from plausible-looking near-misses.
//
// The Makefile links this against starter/config_parser.cpp (make test) or
// solution/config_parser.cpp (make test-solution). EXIT CODE 1 = failures.
//
// ── ABOUT THE EXCEPTION-CHECK PATTERN ────────────────────────────────────────
// A function that is SUPPOSED to throw cannot be checked with a single macro
// expression. The canonical shape used throughout this file is:
//
// try {
// call_that_should_throw();
// CHECK(false && "should have thrown"); // ← if we reach this, it didn't
// } catch (const ExactType& e) {
// CHECK(std::string{e.what()}.find("keyword") != std::string::npos);
// }
//
// The `CHECK(false && ...)` trick makes the grader fail if the throw was
// missing. The catch block verifies the TYPE is right (the handler is only
// reached for ExactType) and checks the message content.
// ─────────────────────────────────────────────────────────────────────────────
#include <iostream>
#include <string>
#include "../config_parser.h"
static int fails = 0;
#define CHECK(cond) \
do { if(!(cond)){ \
std::cerr << "FAIL: " #cond " @line " << __LINE__ << "\n"; \
++fails; \
} } while(0)
// A helper that runs the exception-check pattern and returns true if the
// right exception was thrown. Reduces boilerplate in the tests below.
// (Inline template — a preview of Chapter 26; provided as scaffolding so
// the learner only fills in the TASK bodies, not the test harness.)
template<typename ExType, typename Fn>
bool throws_as(Fn fn)
{
try { fn(); return false; }
catch (const ExType&) { return true; }
catch (...) { return false; } // threw, but wrong type
}
int main()
{
// ── Task 1: getOrThrow ─────────────────────────────────────────────────
{
SafeConfig cfg { {{"host", "localhost"}, {"port", "8080"}} };
// Happy path: key exists → correct value
CHECK(cfg.getOrThrow("host") == "localhost");
CHECK(cfg.getOrThrow("port") == "8080");
// Missing key → MissingKeyError (the SPECIFIC derived type, not base)
try
{
cfg.getOrThrow("timeout");
CHECK(false && "getOrThrow should have thrown for missing key");
}
catch (const MissingKeyError& e)
{
// Correct type caught. Verify the what() mentions the key name.
CHECK(std::string { e.what() }.find("timeout") != std::string::npos);
// Also verify the .key() accessor (it is tested separately).
CHECK(e.key() == "timeout");
}
// A MissingKeyError is also a ConfigError (IS-A).
// Catch ordering: derived BEFORE base (notes 27.5).
bool caught_as_config { false };
try
{
cfg.getOrThrow("missing");
}
catch (const MissingKeyError&)
{
caught_as_config = true; // preferred handler fires first
}
catch (const ConfigError&)
{
// If the ordering were wrong this would fire — then caught_as_config
// stays false and the CHECK below would flag it.
}
CHECK(caught_as_config);
// Edge: empty key string treated as absent
CHECK(throws_as<MissingKeyError>([&]{ cfg.getOrThrow(""); }));
}
// ── Task 2: parsePort — success ───────────────────────────────────────
{
SafeConfig cfg { {{"p", "80"}, {"max", "65535"}, {"min", "1"}} };
CHECK(cfg.parsePort("p") == 80);
CHECK(cfg.parsePort("max") == 65535); // boundary: top of range
CHECK(cfg.parsePort("min") == 1); // boundary: bottom of range
}
// ── Task 2: parsePort — MissingKeyError ───────────────────────────────
{
SafeConfig cfg { {{"x", "9000"}} };
// Missing key → MissingKeyError must propagate through parsePort
try
{
cfg.parsePort("absent");
CHECK(false && "parsePort should throw MissingKeyError for absent key");
}
catch (const MissingKeyError& e)
{
CHECK(std::string { e.what() }.find("absent") != std::string::npos);
}
}
// ── Task 2: parsePort — ConfigError for non-numeric value ─────────────
{
SafeConfig cfg { {{"p", "abc"}, {"q", "80x"}, {"r", "12.3"}} };
// Alphabetic value → ConfigError
CHECK(throws_as<ConfigError>([&]{ cfg.parsePort("p"); }));
// Trailing non-digit → ConfigError
CHECK(throws_as<ConfigError>([&]{ cfg.parsePort("q"); }));
// Decimal point → ConfigError (not a valid integer port string)
CHECK(throws_as<ConfigError>([&]{ cfg.parsePort("r"); }));
// A ConfigError for bad format is NOT a MissingKeyError.
CHECK(!throws_as<MissingKeyError>([&]{ cfg.parsePort("p"); }));
// EDGE: an EMPTY value is malformed too. The digit loop passes
// VACUOUSLY for "" (no characters to reject) — without an explicit
// empty check, std::stoi("") would throw std::invalid_argument: the
// WRONG type, absent from the contract, and invisible to a caller
// catching ConfigError. The contract demands ConfigError here.
SafeConfig cfg_empty { {{"p", ""}} };
CHECK(throws_as<ConfigError>([&]{ cfg_empty.parsePort("p"); }));
// ...and specifically NOT a leaked std::invalid_argument from stoi.
CHECK(!throws_as<std::invalid_argument>([&]{ cfg_empty.parsePort("p"); }));
// Verify the what() message mentions the bad value.
try
{
cfg.parsePort("p");
CHECK(false && "should have thrown");
}
catch (const ConfigError& e)
{
CHECK(std::string { e.what() }.find("abc") != std::string::npos);
}
}
// ── Task 2: parsePort — std::out_of_range ─────────────────────────────
{
// Port 0 is below the minimum (1).
SafeConfig cfg_zero { {{"p", "0"}} };
// Port 65536 is above the maximum (65535).
SafeConfig cfg_high { {{"p", "65536"}} };
// Port 99999 is well above the maximum.
SafeConfig cfg_huge { {{"p", "99999"}} };
// Must throw std::out_of_range — the EXACT type, not just ConfigError.
CHECK(throws_as<std::out_of_range>([&]{ cfg_zero.parsePort("p"); }));
CHECK(throws_as<std::out_of_range>([&]{ cfg_high.parsePort("p"); }));
CHECK(throws_as<std::out_of_range>([&]{ cfg_huge.parsePort("p"); }));
// out_of_range is NOT a ConfigError (it derives from std::logic_error,
// not from std::runtime_error in the standard hierarchy). Verify the
// catch ordering matters: a ConfigError catch should NOT swallow it.
CHECK(!throws_as<ConfigError>([&]{ cfg_zero.parsePort("p"); }));
}
// ── Task 3: tryParsePort ──────────────────────────────────────────────
{
SafeConfig good { {{"port", "8080"}, {"tls", "443"}} };
SafeConfig bad { {{"port", "abc"}, {"over", "99999"}} };
int out { -1 };
// Success case → returns true, fills outPort
CHECK(tryParsePort(good, "port", out) == true);
CHECK(out == 8080);
CHECK(tryParsePort(good, "tls", out) == true);
CHECK(out == 443);
// MissingKeyError → returns false, outPort unchanged
out = -1;
CHECK(tryParsePort(good, "missing", out) == false);
CHECK(out == -1); // outPort must not be modified on failure
// ConfigError (bad format) → returns false
out = -1;
CHECK(tryParsePort(bad, "port", out) == false);
CHECK(out == -1);
// out_of_range → returns false
out = -1;
CHECK(tryParsePort(bad, "over", out) == false);
CHECK(out == -1);
}
// ── Task 4: Unwinder — stack unwinding is observable ─────────────────
{
int entered { 0 };
int destroyed { 0 };
// throwingWork creates an Unwinder and then throws.
// The Unwinder destructor MUST run during unwinding before the catch.
try
{
throwingWork(&entered, &destroyed);
CHECK(false && "throwingWork should have thrown");
}
catch (const ConfigError&)
{
// Exception caught. Now verify the Unwinder lifecycle.
CHECK(entered == 1); // constructor ran: Unwinder was created
CHECK(destroyed == 1); // destructor ran: unwinding cleaned it up
}
// Normal return path (no throw): destructor still runs.
// We test this by catching the exception, but the dtor is what matters.
// The counts above already prove it for the unwinding path.
// Edge: a second call accumulates independently.
int e2 { 0 }, d2 { 0 };
try { throwingWork(&e2, &d2); } catch (...) {}
CHECK(e2 == 1);
CHECK(d2 == 1);
}
// ── Task 5: rethrowOuter — catch, augment, re-raise ──────────────────
{
int entered { 0 };
int destroyed { 0 };
// rethrowOuter must:
// 1. Propagate a ConfigError (the re-raised one).
// 2. Its what() starts with "outer: ".
// 3. The Unwinder inside throwingWork still cleaned up.
try
{
rethrowOuter(&entered, &destroyed);
CHECK(false && "rethrowOuter should have thrown");
}
catch (const ConfigError& e)
{
// Correct type
std::string msg { e.what() };
CHECK(msg.find("outer: ") == 0); // starts with "outer: "
CHECK(msg.find("work failed") != std::string::npos); // original text
}
// Unwinder inside throwingWork must have been destroyed.
CHECK(entered == 1);
CHECK(destroyed == 1);
// The exception that escapes is a ConfigError, NOT a MissingKeyError.
bool caught_mis { false };
try
{
int e3 { 0 }, d3 { 0 };
rethrowOuter(&e3, &d3);
}
catch (const MissingKeyError&) { caught_mis = true; }
catch (const ConfigError&) { /* expected */ }
CHECK(!caught_mis); // rethrowOuter throws ConfigError, not derived
}
// ── Summary ────────────────────────────────────────────────────────────
if (!fails)
std::cout << "PASS ✅ all SafeConfig checks passed.\n";
else
std::cerr << "\nFAIL ❌ " << fails << " check(s) failed"
" — fix the TASK blocks in starter/config_parser.cpp.\n";
return fails ? 1 : 0;
}
// ============================================================================
// config_parser.h — the PUBLIC CONTRACT for the SafeConfig parser (Ch 27)
// ----------------------------------------------------------------------------
// This header is COMPLETE and PROVIDED. Do not edit it. Your job is to
// implement the bodies in starter/config_parser.cpp (or solution/).
//
// THE BIG IDEA: structured error propagation.
//
// return-code style forces the caller to check a status after every call, and
// there is no clean channel for a constructor to report failure. Exceptions
// solve both problems by separating "something went wrong" (the throw site)
// from "what do we do about it" (the matching catch block), potentially
// several frames up the call stack.
//
// This lab makes that physical:
// • Two custom exception CLASSES (notes 27.5) so catch blocks can discriminate
// on TYPE — not just on an int error code.
// • A port-parsing function that throws the RIGHT type depending on what
// failed (bad characters vs. out-of-range value).
// • A key-lookup that throws a narrower derived class.
// • A noexcept BOUNDARY wrapper that contains exceptions completely — the
// caller sees only bool+value, never a propagating exception (notes 27.9).
// • An Unwinder struct whose destructor increments a counter so a test can
// VERIFY that stack unwinding ran the destructor (notes 27.3).
//
// Header guard (Chapter 2): prevents double-inclusion.
#ifndef CONFIG_PARSER_H
#define CONFIG_PARSER_H
#include <stdexcept> // std::runtime_error, std::out_of_range
#include <string> // std::string
#include <string_view> // std::string_view — cheap view, no copy (Chapter 5)
#include <unordered_map> // std::unordered_map — key-value store (a preview;
// formally Chapter 17+ / standard containers)
#include <optional> // std::optional — "a value or nothing" (Chapter 12)
// ─── Exception Hierarchy ─────────────────────────────────────────────────────
//
// Why derive from std::runtime_error instead of std::exception directly?
// (notes 27.5)
// • std::runtime_error already stores a message string and returns it from
// what(). Deriving from it gives us that for free.
// • Code that catches (const std::exception&) — e.g., a top-level handler —
// still handles our errors: IS-A inheritance works here.
//
// CS6340 lens: the LLVM Instruction class hierarchy is structured the same way —
// derived types for specificity, base type for generic traversal.
// ConfigError — the base class for all config-parsing failures.
// Catches anything that goes wrong while parsing a configuration.
class ConfigError : public std::runtime_error
{
public:
// Forwarding constructor: pass the message straight to std::runtime_error
// so what() returns it unchanged (notes 27.5).
explicit ConfigError(const std::string& message)
: std::runtime_error { message }
{
}
};
// MissingKeyError : ConfigError — thrown when a key that must exist is absent.
// A caller can catch (const ConfigError&) for all config errors, or catch
// (const MissingKeyError&) if it needs to handle "not found" specifically.
// Derived BEFORE base in any catch sequence — see notes 27.5 "Catch derived
// exceptions before base exceptions."
class MissingKeyError : public ConfigError
{
public:
// Stores the key that was missing so the caller can name it in a message.
explicit MissingKeyError(const std::string& key)
: ConfigError { "missing required key: " + key }
, m_key { key }
{
}
// The absent key, separate from the full what() message.
const std::string& key() const noexcept { return m_key; }
private:
std::string m_key;
};
// ─── SafeConfig ───────────────────────────────────────────────────────────────
//
// A thin wrapper around a string→string map that provides throwing accessors.
// Think of it as a minimal stand-in for a real config-file parser.
class SafeConfig
{
public:
// Construct from any existing key-value map.
explicit SafeConfig(std::unordered_map<std::string, std::string> entries)
: m_entries { std::move(entries) } // move: no copy (Chapter 22)
{
}
// ── TASK 1 ───────────────────────────────────────────────────────────────
// getOrThrow — return the value for `key`, or throw MissingKeyError if the
// key is not present.
//
// IMPLEMENTATION FILE: starter/config_parser.cpp (in the SafeConfig section)
std::string getOrThrow(std::string_view key) const;
// ── TASK 2 ───────────────────────────────────────────────────────────────
// parsePort — find `key`, then parse its value as a port number.
//
// Throws:
// MissingKeyError — key is not in the map
// ConfigError — value is EMPTY, or contains non-digit characters
// std::out_of_range — value is numeric but outside [1, 65535]
//
// (The empty case must be checked explicitly: a per-character digit loop
// passes vacuously for "" — and std::stoi("") would then leak
// std::invalid_argument, a type this contract does not allow.)
//
// (notes 27.2: throw the RIGHT type — callers can then catch only the
// exceptions they know how to handle.)
//
// IMPLEMENTATION FILE: starter/config_parser.cpp
int parsePort(std::string_view key) const;
private:
std::unordered_map<std::string, std::string> m_entries;
};
// ─── tryParsePort — the noexcept boundary wrapper ────────────────────────────
//
// A "boundary" is a point in the call graph where exceptions are ABSORBED and
// translated into a non-exception result (notes 27.9, CS6340 patterns).
//
// tryParsePort wraps SafeConfig::parsePort with noexcept so that no exception
// can escape to its caller. If parsePort throws for any reason, outPort is left
// unchanged and the function returns false. On success it stores the port and
// returns true.
//
// DESIGN NOTE: the noexcept promise is real here because we catch everything
// inside the body. Do NOT add noexcept to a function unless you truly guarantee
// no exception escapes — the compiler lets you lie, but std::terminate() runs
// if the lie is exposed (notes 27.9).
//
// ── TASK 3 ───────────────────────────────────────────────────────────────────
// IMPLEMENTATION FILE: starter/config_parser.cpp
bool tryParsePort(const SafeConfig& cfg, std::string_view key,
int& outPort) noexcept;
// ─── Unwinder — the RAII stack-unwinding probe ───────────────────────────────
//
// A tiny struct whose constructor increments *entered and whose destructor
// increments *destroyed. When a function throws PAST an Unwinder local, the
// destructor MUST run during stack unwinding (notes 27.3). The test confirms
// this by observing the counter: if stack unwinding skipped destructors, the
// destroyed count would be wrong.
//
// This is the "make it physical" principle from CLAUDE.md: you don't just read
// that destructors run during unwinding — you OBSERVE the counter change.
//
// ── TASK 4 ───────────────────────────────────────────────────────────────────
// Implement the constructor and destructor in starter/config_parser.cpp.
// Constructor: increment *entered.
// Destructor: increment *destroyed. (Do NOT throw from a destructor —
// notes 27.8 "Destructors should not throw".)
struct Unwinder
{
int* entered {}; // pointer to a caller-owned counter
int* destroyed {}; // pointer to a caller-owned counter
// Increment *entered to record that this Unwinder was created.
explicit Unwinder(int* pEntered, int* pDestroyed);
// Increment *destroyed to record that cleanup ran — even during unwinding.
~Unwinder();
// Non-copyable: a probe object should not be accidentally duplicated.
// (= delete syntax — Chapter 14; provided here as scaffolding.)
Unwinder(const Unwinder&) = delete;
Unwinder& operator=(const Unwinder&) = delete;
};
// ─── throwingWork — helper that exercises unwinding ──────────────────────────
//
// Creates an Unwinder local, then throws ConfigError so the Unwinder is
// destroyed by stack unwinding rather than by a normal return.
//
// ── TASK 4 (continued) ───────────────────────────────────────────────────────
// The test drives this by calling throwingWork inside a try block, catching
// the exception, and checking that *pDestroyed incremented. DO NOT change
// the signature.
// IMPLEMENTATION FILE: starter/config_parser.cpp
void throwingWork(int* pEntered, int* pDestroyed);
// ─── rethrowOuter — demonstrates the catch / rethrow idiom ──────────────────
//
// Calls throwingWork inside a try block. Catches the exception, prepends
// "outer: " to the what() string, and re-throws a NEW ConfigError with that
// composed message. (notes 27.6 — this is "throw a new exception", not bare
// throw; — we intentionally raise a different object here.)
//
// ── TASK 5 ───────────────────────────────────────────────────────────────────
// IMPLEMENTATION FILE: starter/config_parser.cpp
void rethrowOuter(int* pEntered, int* pDestroyed);
#endif // CONFIG_PARSER_H
# Chapter 27 — Exceptions · SafeConfig Parser · unit-test grader (Style B).
# Targets follow the drills/CLAUDE.md Makefile contract. TABS, not spaces.
#
# Layout:
# config_parser.h — declarations (the CONTRACT; do not edit)
# starter/config_parser.cpp — LEARNER's bodies + TASK blocks
# solution/config_parser.cpp — reference bodies
# tests/tests.cpp — grader: includes ../config_parser.h, CHECKs results
#
# The grader is linked against starter/ or solution/ via the Makefile target.
CXX := clang++
CXXFLAGS := -std=c++17 -Wall -Wextra
.PHONY: all build run test solution test-solution clean
all: build
# ── build: compile-check the starter (warning-clean object file, then link) ──
build:
$(CXX) $(CXXFLAGS) -c starter/config_parser.cpp -o starter/config_parser.o
@echo "OK ✅ starter/config_parser.cpp compiles. Now run: make test"
# ── run: no interactive runner for this lab; redirect to test ─────────────────
run: build
@echo "(No interactive driver for this lab — use 'make test' to grade your code.)"
# ── test: grade the LEARNER's code ───────────────────────────────────────────
# RED until all TASK blocks are filled in; GREEN once correct.
test:
$(CXX) $(CXXFLAGS) tests/tests.cpp starter/config_parser.cpp -o tests/run
@./tests/run || echo "FAIL ❌ fill in the TASK blocks in starter/config_parser.cpp."
# ── solution: build + run the reference solution ─────────────────────────────
solution: solution/app
./solution/app
solution/app: tests/tests.cpp solution/config_parser.cpp
$(CXX) $(CXXFLAGS) tests/tests.cpp solution/config_parser.cpp -o solution/app
# ── test-solution: proof the lab is solvable (MUST be green) ─────────────────
test-solution:
$(CXX) $(CXXFLAGS) tests/tests.cpp solution/config_parser.cpp -o tests/run
@./tests/run
# ── clean ─────────────────────────────────────────────────────────────────────
clean:
rm -f starter/config_parser.o tests/run solution/app
rm -rf tests/run.dSYM solution/app.dSYM
make test locally
(see “Build & run locally” above).