C++ uses three keywords for error handling: throw signals that something went wrong, try marks a block where errors might happen, and catch handles them. This lesson covers the syntax, the basic flow of an exception from throw to a matching catch, and the conventions experienced C++ developers follow when writing handlers.
A try block is a regular block of code marked with the try keyword. It tells the compiler that handlers are ready right after, in case code inside the block throws.
Nothing was thrown, so the catch handler is skipped. Execution falls off the end of the try block and continues normally. A try block by itself does nothing special; it only matters when something inside it throws.
The body of a try can contain anything: variable declarations, function calls, loops, nested control flow. Variables declared inside the try are local to it, so they aren't visible in the matching catch. A try block must be immediately followed by at least one catch handler, with no unrelated code between the two. The compiler reads the try/catch pair as a single syntactic unit.
throw hands an exception to the runtime. The syntax is throw expr;, where expr can be a built-in type, a string literal, a class object, or a temporary. Once throw runs, the rest of the function is skipped and the runtime looks for a matching catch.
The line right after throw is unreachable. The runtime exits the try block and jumps to the matching catch, copying the value 42 into the catch parameter code.
Any expression can be thrown. The same idea with a string literal, which is a const char*:
Throwing raw int, const char*, or double values is rare. Real C++ code throws objects from the standard exception hierarchy or custom classes, because an object can carry context like an error message, an error code, or the offending value.
A more realistic version using std::runtime_error from <stdexcept>. For now, treat it as a class that holds an error message set by its constructor:
e.what() returns the message passed to the constructor.
A catch clause looks like a function parameter list with a body: catch (Type name) { ... }. When the runtime finds a throw, it walks the active catch handlers in source order, looking for one whose parameter type matches the thrown value's type.
The type in the catch must match (more or less, with some allowances for inheritance and const qualifiers) the type that was thrown. A throw 100; produces an int, which matches catch (int code). A throw 3.14; produces a double and would not match an int handler.
The name of the catch parameter is optional. Writing catch (int) without a name still matches an int exception; it just doesn't bind a local variable for the handler. That's useful when the type itself is enough information to act on.
A single try can have more than one catch handler chained after it, each handling a different type. The runtime picks the first one whose type matches. For the rest of this lesson, every example uses a single catch handler per try.
The high-level path a thrown exception takes inside a single function.
The diagram traces what happens for one try/catch pair. If nothing throws, the catch is skipped and execution falls through. If something does throw, the runtime builds an exception object, looks for a matching catch in the same function, and either runs the catch body or propagates the exception up to the caller.
The "propagate up" arrow is the case this lesson only previews. If no catch in the current function matches, the exception keeps moving outward through the call stack until either a matching catch is found in some caller or the exception escapes main and the program terminates. That whole process is stack unwinding.
A small program shows propagation across one function boundary.
The throw happens inside chargeCustomer, which has no try of its own. The exception propagates out of it and into main, where the matching catch handles it.
The catch parameter can be declared in three ways, and the choice matters:
| Form | Behavior | When to use |
|---|---|---|
catch (Type e) | Copies the exception into e | Almost never |
catch (Type& e) | Binds e to the thrown object | Rarely (when modification is needed) |
catch (const Type& e) | Binds e to the thrown object, read-only | The standard recommendation |
The first form, catch by value, makes a copy of the thrown object. That copy costs CPU cycles and memory, and for exceptions derived from a base class, the copy can lose information. The second form binds a reference but allows modification. The third form binds a const reference, which avoids the copy and prevents accidental changes to the exception inside the handler.
A small program shows the copy cost when catching by value.
The value-catch produces an extra "Copied OrderError" line. The reference-catch skips that copy entirely. For a small object the copy is cheap, but real exception classes can carry strings, vectors, or backtraces. Avoiding the copy is the cheaper default.
Catching by value copies the exception object every time it's caught. For a class holding a std::string (like most real exceptions), that's an allocation. const T& skips the copy with no downside.
There's a more serious problem with catch-by-value than performance. With a class hierarchy of exceptions, catching the base type by value can chop off the derived part of the object. This is called object slicing, covered in detail in the Inheritance section, but the symptom inside a catch handler is easy to demonstrate.
What's wrong with this code?
The thrown object was a StockError, but the catch parameter was declared as a StoreError by value. The runtime copied just the base part of the object into e, and the StockError-specific information was sliced off. e.describe() runs the base version, not the derived one.
Fix: change the catch parameter from StoreError e to const StoreError& e. The reference binds to the actual StockError object without copying it, and the virtual call to describe() resolves to the derived version:
The rule has stuck around for decades: catch exceptions by `const` reference unless there's a specific reason to do otherwise.
How class hierarchies like this one work, including virtual and override, belongs to the inheritance and polymorphism material. For now, the takeaway is the catching convention: catch (const T&).
When a class object is thrown, the runtime copies (or moves) it into special exception storage, from which the catch handler can access it. The lifetime is automatic: the exception object lives until the handler finishes, then it's destroyed.
A temporary can be thrown directly.
throw CartError("Wireless Mouse", "Quantity cannot be negative"); constructs a CartError temporary and throws it. The catch binds a const reference to that temporary, calls what() on it, and prints the result. When the catch finishes, the exception object is destroyed.
A named object can also be thrown (CartError err(...); throw err;). That form is occasionally useful for setting up the object across several statements, but the throw-a-temporary form is more common because it's terser.
Throwing copies (or moves) the thrown object into exception storage. For large objects, this is a real cost. Most exception classes are kept small for this reason; they carry a message string and maybe an error code, not big payloads.
Sometimes a catch handler can do part of the work, then needs to pass the exception up to the caller. A bare throw; statement, with no operand, does exactly that.
The exception is caught inside processCheckout, logged, and rethrown with throw;. That rethrow doesn't create a new exception; it sends the exact same object back into propagation, so the outer catch in main handles it.
There's an important difference between throw; and throw e; inside a catch block. The two look similar but behave differently.
| Form | Behavior |
|---|---|
throw; | Rethrows the original exception object, unchanged |
throw e; | Throws a new exception, copy-constructed from e |
The second form is a fresh throw expression. The runtime creates a new exception object from e's static type, which for a base-class reference can slice off any derived-class data.
The slicing in action:
The throw; form preserves the original StockError, and the derived describe() runs. The throw e; form constructs a new StoreError from the base reference, losing the StockError part of the object. The rule: to rethrow inside a catch block, use bare throw;. Avoid writing throw e; in that position.
A try block can contain another try block. Each pair works independently. The inner catch gets first chance at any exception thrown inside the inner try. If the inner catch doesn't match, or if it rethrows, the exception propagates to the outer try's handlers.
The inner catch handles the exception, so the outer catch never runs. Execution returns to the outer try right after the inner try/catch block. If the inner catch had rethrown with bare throw; (as in the rethrow example earlier), the outer catch would handle the same exception afterward. That pattern is common when an inner layer needs to add context or perform cleanup before letting the exception continue upward.
Nested try blocks are useful but easy to overuse. Most real code doesn't need them; one try per logical operation is usually cleaner. Two or three layers deep, consider whether each layer is doing different cleanup or whether the structure is hiding the actual flow.
When a function throws and there's no matching try/catch around the throw, the exception leaves the function. The runtime stops executing the function, destroys local variables along the way, and hands the exception to the caller's try/catch. If no caller matches either, the exception keeps propagating up until some catch handles it or it escapes main and the program terminates.
The earlier chargeCustomer example showed this in miniature: the throw happened in one function, but the matching catch was in main, one frame up. The chain can be much longer in real code.
10 quizzes