Exceptions: throw, try, catch
Understand the C++ exception mechanism — stack unwinding, the standard exception hierarchy, custom exceptions, and when to prefer exceptions over error codes.
Why Exceptions Exist
Before exceptions, C code signaled errors by returning special values (like -1 or NULL). This had a critical flaw: callers could silently ignore the error. Exceptions solve this by forcing the program to either handle the error or terminate. They also separate the error detection site (deep in library code) from the error handling site (in application code), which can be many call frames apart.
C++ exceptions follow a "zero-cost on the success path" model on most modern compilers (using table-based unwinding). When no exception is thrown, there is no runtime overhead — no hidden if checks, no branch predictions missed. The cost is paid only when an exception is actually thrown, which makes exceptions ideal for exceptional (rare) error conditions.
throw, try, and catch
The core mechanism is straightforward: throw an object to signal an error, wrap risky code in a try block, and catch matching exception types.
#include <iostream>
#include <stdexcept>
#include <string>
double divide(double numerator, double denominator) {
if (denominator == 0.0) {
throw std::invalid_argument("denominator cannot be zero");
}
return numerator / denominator;
}
int main() {
try {
double result = divide(10.0, 0.0);
std::cout << "Result: " << result << '\n';
} catch (const std::invalid_argument& e) {
std::cerr << "Invalid argument: " << e.what() << '\n';
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << '\n';
} catch (...) {
std::cerr << "Unknown error occurred\n";
}
}Stack Unwinding in Detail
When an exception is thrown, the runtime performs stack unwinding: it walks back up the call stack, destroying all local variables in each frame by calling their destructors. This is why RAII (Resource Acquisition Is Initialization) is so crucial — if you hold resources in RAII wrappers, they are automatically released during unwinding.
The unwinding process works as follows:
1. The throw expression creates the exception object (usually on a special area of memory, not the stack).
2. The runtime searches the current function for a matching catch handler.
3. If none is found, it exits the current function, destroying all local objects in reverse order of construction.
4. It repeats steps 2-3 up the call stack.
5. If no handler is found in any frame, std::terminate() is called.
Key rule: throw by value, catch by const reference. Throwing by value avoids dangling pointers. Catching by const reference avoids slicing (where a derived exception is copied into a base class object, losing the derived data) and avoids unnecessary copies.
The Standard Exception Hierarchy
The standard library provides a hierarchy rooted at std::exception. Understanding it helps you throw the right type and catch at the right level.
// Standard exception hierarchy (simplified)
//
// std::exception
// ├── std::logic_error (programmer mistakes - precondition violations)
// │ ├── std::invalid_argument
// │ ├── std::domain_error
// │ ├── std::length_error
// │ └── std::out_of_range
// ├── std::runtime_error (errors detectable only at runtime)
// │ ├── std::range_error
// │ ├── std::overflow_error
// │ ├── std::underflow_error
// │ └── std::system_error (OS-level errors with error_code)
// └── std::bad_alloc (memory allocation failure)
// std::bad_cast (failed dynamic_cast)
// std::bad_typeid (typeid on null pointer)
#include <stdexcept>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {1, 2, 3};
try {
// .at() throws std::out_of_range, unlike operator[] which is UB
int val = v.at(10);
} catch (const std::out_of_range& e) {
std::cerr << "Out of range: " << e.what() << '\n';
}
}Custom Exception Classes
Derive from std::exception or one of its subclasses to create domain-specific exceptions. Always override what() to provide a meaningful message.
#include <stdexcept>
#include <string>
#include <iostream>
class FileParseError : public std::runtime_error {
public:
FileParseError(const std::string& filename, int line, const std::string& msg)
: std::runtime_error(
"Parse error in '" + filename + "' at line " +
std::to_string(line) + ": " + msg),
filename_(filename),
line_(line) {}
const std::string& filename() const noexcept { return filename_; }
int line() const noexcept { return line_; }
private:
std::string filename_;
int line_;
};
void parse_config(const std::string& filename) {
// Simulate a parse failure at line 42
throw FileParseError(filename, 42, "unexpected token '@@'");
}
int main() {
try {
parse_config("app.conf");
} catch (const FileParseError& e) {
std::cerr << e.what() << '\n';
std::cerr << " File: " << e.filename()
<< ", Line: " << e.line() << '\n';
}
}noexcept and Exception Specifications
The noexcept specifier tells the compiler (and human readers) that a function will never throw an exception. If a noexcept function does throw, std::terminate() is called immediately — there is no stack unwinding.
Why does noexcept matter?
- Performance: The compiler can omit the unwinding tables for noexcept functions, producing tighter code.
- STL optimization: Containers like std::vector will move elements during reallocation only if the move constructor is noexcept. Otherwise, they fall back to copying to maintain the strong exception guarantee. This can be a dramatic performance difference.
- Documentation: It is a contract with callers that the function cannot fail.
Use noexcept on: move constructors, move assignment operators, swap functions, destructors (implicitly noexcept), simple getters, and comparison operators.
The old throw() specification (and throw(type-list)) was deprecated in C++11 and removed in C++20. Always use noexcept instead.
Never throw exceptions in destructors. During stack unwinding from another exception, throwing a second exception calls std::terminate(). If a destructor must perform an operation that can fail, catch the exception inside the destructor and handle it there (e.g., log and suppress).
- Catching by value slices derived exceptions — always catch by
const& - Throwing pointers (e.g.,
throw new std::runtime_error(...)) leads to memory leaks — throw by value - Catching
...first hides all specific handlers below it — put it last - Exceptions in destructors during stack unwinding cause
std::terminate() - Using exceptions for control flow (non-error paths) defeats the zero-cost model and harms performance
- Throw by value, catch by
constreference to avoid slicing and leaks - Stack unwinding destroys local objects in reverse construction order — RAII ensures cleanup
- C++ exceptions are zero-cost on the success path (table-based unwinding), expensive only when thrown
noexceptenables compiler optimizations and is critical for move operations used by the STL- Derive custom exceptions from
std::runtime_errororstd::logic_errorand overridewhat()
Quiz — Test Your Knowledge
(15 XP)1. Why should you catch exceptions by `const&` rather than by value?
2. What happens if a function marked `noexcept` throws an exception?
3. During stack unwinding, what happens to local variables in each stack frame?