C++ Exceptions Done Right: Catch by Reference, Safety Guarantees, RAII and noexcept
Key takeaways
Most exception bugs come from catching by value, throwing from destructors or leaking resources mid-operation. The post walks through the standard hierarchy, the three safety guarantees with code, and where to catch, translate or avoid exceptions in favor of error codes or std::expected.
Basic Exception Handling
An exception separates detecting an error from handling it. divide knows that b == 0 is invalid but has no idea whether the caller wants to log, retry or show a dialog, so it throws and lets control jump to the nearest enclosing catch whose type matches. Everything between the throw and that catch is skipped, and every local object in the abandoned stack frames is destroyed on the way out — that process, called stack unwinding, is what the rest of this post relies on. If no handler matches anywhere up the stack, the program calls std::terminate, which by default aborts without necessarily running any destructors.
#include <iostream>
#include <stdexcept>
using namespace std;
int divide(int a, int b) {
if (b == 0) {
throw runtime_error("Cannot divide by zero");
}
return a / b;
}
int main() {
try {
int result = divide(10, 0);
cout << result << endl;
} catch (runtime_error& e) {
cout << "Error: " << e.what() << endl;
}
return 0;
}
Output:
Error: Cannot divide by zero
Standard Exception Hierarchy
#include <exception>
#include <stdexcept>
// Base exception
exception
// Logic errors
logic_error
├─ invalid_argument
├─ domain_error
├─ length_error
└─ out_of_range
// Runtime errors
runtime_error
├─ range_error
├─ overflow_error
└─ underflow_error
Hierarchy visualization:
graph TD
A[std::exception] --> B[std::logic_error]
A --> C[std::runtime_error]
B --> D[std::invalid_argument]
B --> E[std::out_of_range]
B --> F[std::length_error]
C --> G[std::range_error]
C --> H[std::overflow_error]
style A fill:#FFB6C1
style B fill:#87CEEB
style C fill:#90EE90
The two branches encode whose fault the error is. logic_error and its children describe bugs the caller could have avoided by checking first — passing a negative size, indexing past the end (vector::at throws out_of_range). runtime_error describes conditions that only show up at run time no matter how careful the caller is: a missing file, a network failure, arithmetic overflow detected by a library. In practice many codebases treat logic errors as bugs to fix (assert, log, crash in debug builds) and only design recovery paths for runtime errors. Note that not everything the standard library throws lives under these two: std::bad_alloc (from failed new), std::bad_cast (a failed dynamic_cast to a reference) and std::bad_optional_access derive directly or indirectly from std::exception through other branches, so catch (const std::exception&) is the only standard handler that reliably catches all of them.
Multiple Exception Handling
try {
// Code that may throw
} catch (out_of_range& e) {
cout << "Out of range: " << e.what() << endl;
} catch (invalid_argument& e) {
cout << "Invalid argument: " << e.what() << endl;
} catch (exception& e) {
cout << "Other error: " << e.what() << endl;
} catch (...) {
cout << "Unknown error" << endl;
}
Catch order matters: Catch specific types first, then base types. Otherwise, base type catches everything and specific handlers never execute.
Handlers are tried top to bottom and the first match wins — C++ does not look for the best match the way overload resolution does. Put catch (exception&) first and the out_of_range handler below it becomes dead code; GCC and Clang warn about this (exception of type 'std::out_of_range' will be caught by earlier handler), which is a warning worth turning into an error. catch (...) must come last and cannot give you the exception object, so it is mainly for logging and for stopping foreign exceptions at a boundary such as a C callback or a thread entry point, where letting an exception escape would call std::terminate.
Exceptions in file processing, custom types, and RAII
Throwing when a file cannot be opened
#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace std;
string readFile(const string& filename) {
ifstream file(filename);
if (!file.is_open()) {
throw runtime_error("Cannot open file: " + filename);
}
string content, line;
while (getline(file, line)) {
content += line + "\n";
}
return content;
}
int main() {
try {
string content = readFile("config.txt");
cout << content << endl;
} catch (runtime_error& e) {
cerr << "Error: " << e.what() << endl;
return 1;
}
return 0;
}
Explanation: Throwing exceptions for file errors makes error handling explicit and propagates failures clearly.
Streams do not throw by default — a failed open just sets the stream’s failbit, which is why the code checks is_open() and throws itself. You can ask a stream to throw with file.exceptions(std::ifstream::failbit | std::ifstream::badbit), but that is usually a mistake for line-by-line reading: getline sets failbit at the normal end of file, so the loop ends by throwing std::ios_base::failure on every successful read. The other practical gap is the message. “Cannot open file: config.txt” does not say why; when I debug this kind of failure the useful information is almost always the OS reason (file missing, permission denied, or a relative path resolved against an unexpected working directory). Including std::strerror(errno) in the message, or checking std::filesystem::exists first, saves a round of guessing.
A domain-specific exception class
#include <iostream>
#include <exception>
#include <string>
using namespace std;
class InvalidAgeException : public exception {
private:
string message;
public:
InvalidAgeException(int age) {
message = "Invalid age: " + to_string(age);
}
const char* what() const noexcept override {
return message.c_str();
}
};
class Person {
private:
string name;
int age;
public:
Person(string n, int a) : name(n) {
if (a < 0 || a > 150) {
throw InvalidAgeException(a);
}
age = a;
}
void print() {
cout << name << ", " << age << " years old" << endl;
}
};
int main() {
try {
Person p1("Alice", 25);
p1.print();
Person p2("Bob", 200); // Throws exception
p2.print();
} catch (InvalidAgeException& e) {
cerr << "Error: " << e.what() << endl;
}
return 0;
}
Output:
Alice, 25 years old
Error: Invalid age: 200
Explanation: Domain-specific exceptions make errors more expressive and easier to handle.
Two refinements make this class sturdier. Deriving from std::invalid_argument (or std::runtime_error) instead of bare std::exception gives you a constructor that takes the message and a correct what() for free, and lets generic handlers classify the error. And exception objects are copied when thrown and may be copied again, so a std::string member means the copy constructor can throw bad_alloc while an exception is already in flight. The standard exception classes avoid this with an internally reference-counted string, which is another reason to derive from them rather than store your own std::string.
Throwing from the Person constructor also has a useful property: if a constructor throws, the object is considered never to have existed. Its destructor does not run, but members and bases that were fully constructed (here name) are destroyed. That is exactly why raw owning pointers as members are dangerous — if a constructor allocates with new and then throws, nothing deletes that memory, while a unique_ptr member would clean up automatically.
Smart pointers releasing memory during unwinding
#include <iostream>
#include <memory>
using namespace std;
class Resource {
public:
Resource() { cout << "Resource allocated" << endl; }
~Resource() { cout << "Resource released" << endl; }
void use() { cout << "Resource used" << endl; }
};
void dangerousFunction() {
throw runtime_error("Error occurred!");
}
int main() {
try {
// ❌ Manual management (leaks on exception)
// Resource* r = new Resource();
// dangerousFunction();
// delete r; // Never executed!
// ✅ RAII (automatic cleanup)
unique_ptr<Resource> r = make_unique<Resource>();
r->use();
dangerousFunction();
// Automatically released even on exception
} catch (exception& e) {
cout << "Error: " << e.what() << endl;
}
return 0;
}
Output:
Resource allocated
Resource used
Resource released
Error: Error occurred!
Explanation: Smart pointers ensure resources are safely released even when exceptions occur.
Look at the order of the output: “Resource released” is printed before “Error: Error occurred!”. The unique_ptr lives inside the try block, so unwinding destroys it while leaving that block, and only then does the catch body run. This is a useful mental model: by the time a handler executes, every object from the throw point up to the try is already gone, which means a handler must not rely on anything that lived in that scope. One exception to the guarantee: if no handler matches at all, whether the stack is unwound before std::terminate is implementation-defined, so RAII cleanup of files and locks is only guaranteed when something catches.
Slicing, throwing destructors, and deprecated throw()
Catching by value slices the exception
Symptom: Exception object slicing Cause: Catching by value loses derived class information Solution:
// ❌ Wrong: catch by value
try {
throw runtime_error("error");
} catch (exception e) { // Catch by value
// Derived class information lost
}
// ✅ Correct: catch by reference
try {
throw runtime_error("error");
} catch (exception& e) { // Catch by reference
cout << e.what() << endl;
}
// ✅ const reference (safer)
catch (const exception& e) {
cout << e.what() << endl;
}
Key: Always catch by reference (const exception&) to avoid object slicing and preserve polymorphic behavior.
With catch (exception e), the handler copy-constructs a plain std::exception from the thrown runtime_error. The derived part is cut off, so e.what() calls the base version and typically returns a generic string like "std::exception" instead of your message — the telltale symptom of slicing in logs. Slicing also breaks rethrowing: throw e; inside that handler throws the sliced base copy, while a bare throw; rethrows the original object with its full type. GCC warns about catching a polymorphic type by value (-Wcatch-value, enabled by -Wall).
A destructor that throws during unwinding
Symptom: Program abnormal termination Cause: Exception from destructor during stack unwinding calls terminate()
Since C++11 it is worse than that: destructors are implicitly noexcept (unless a member or base has a potentially-throwing destructor), so the “dangerous” version below calls std::terminate the moment the throw escapes the destructor, even when no other exception is active. GCC warns with 'throw' will always call 'terminate'. You can opt out with ~Resource() noexcept(false), but the unwinding case still terminates, and standard containers assume destructors do not throw. The practical pattern for operations that can fail during cleanup — flushing a file, committing a transaction — is to offer an explicit close() or commit() that throws, and let the destructor do a best-effort, non-throwing fallback only if the user never called it.
Solution:
// ❌ Dangerous
class Resource {
public:
~Resource() {
throw runtime_error("error"); // Never do this!
}
};
// ✅ Correct
class Resource {
public:
~Resource() noexcept {
try {
// Risky operation
} catch (...) {
// Swallow exception
}
}
};
throw() specifications after C++11
Symptom: Warning or error when using throw()
Cause: Dynamic exception specifications were deprecated in C++11. The list form throw(int, runtime_error) was removed in C++17 and is a compile error there; the empty throw() was kept as a synonym for noexcept until C++20 removed it too.
The old specifications failed because they were checked at run time, not compile time: if a function threw something not on its list, the program called std::unexpected and usually terminated, and compilers had to add code to perform the check. They gave no optimization benefit and no static guarantee. noexcept keeps only the useful part — a yes/no promise the compiler and library can rely on — and makes a violation go straight to std::terminate.
Solution:
// ❌ Old style (deprecated)
void func() throw(int, runtime_error) {
// ...
}
// ✅ Use noexcept
void func() noexcept { // Doesn't throw
// ...
}
void func2() noexcept(false) { // May throw
// ...
}
try-catch Basics Summary
- try: Code block that may throw exceptions.
- throw: Thrown type is matched to first matching
catchby type. Catch derived classes before base classes, or the base handler swallows them and the derived handler is never reached. - catch (…): Catches any type; usually placed last. Log before rethrowing to avoid silent swallow.
try {
mayThrow();
} catch (const std::invalid_argument& e) {
// Specific handling
} catch (const std::exception& e) {
// Common standard exception handling
} catch (...) {
// Unknown exceptions
}
Catch by reference: catch (const std::exception& e) is safer and more efficient than catching by value.
noexcept Deep Dive
- Meaning: “This function doesn’t throw exceptions out.” Violation typically leads to
std::terminate. - Move operations:
std::vectoruses move during reallocation if move constructor isnoexcept. Adding noexcept to move constructor/assignment directly impacts performance. - Destructor: Destructors are implicitly
noexcept. Never throw from destructors.
void swap(MyType& a, MyType& b) noexcept {
// Only calls member swap, guarantees no exceptions
}
Conditional noexcept: noexcept(expr) form allows conditional specification.
The vector case is the one that makes noexcept measurable. When a std::vector<T> grows, it must move or copy every element into the new buffer while keeping the strong guarantee of push_back. If T’s move constructor might throw, a failure halfway through would leave some elements moved-from in the old buffer with no way back, so the vector copies instead (via std::move_if_noexcept). A user-defined move constructor without noexcept therefore silently turns every reallocation into a full deep copy. This is easy to miss: the code works, it is just slower, and it only shows up in a profiler. Defaulted move constructors get noexcept deduced automatically, which is one more reason to prefer = default when the members allow it. In templates, noexcept(noexcept(std::swap(a, b))) — the operator inside the specifier — lets a wrapper be noexcept exactly when the wrapped operation is.
Where to throw, where to catch, and how to translate
Throwing from a constructor that cannot build a valid object
Constructors have no return value, so for invalid state, throw exception (or use factory + expected).
class Database {
public:
Database(const string& connStr) {
if (!connect(connStr)) {
throw runtime_error("Connection failed");
}
}
};
Catch only at boundaries
Library internals propagate exceptions; catch at UI/main for logging/recovery.
// Library code: propagate
void processData() {
if (error) throw DataException("...");
}
// Application boundary: catch
int main() {
try {
processData();
} catch (const exception& e) {
log(e.what());
return 1;
}
return 0;
}
At the top of the program, a final handler can also catch unknown exception types and return distinct exit codes:
#include <iostream>
#include <exception>
int main() {
try {
// Application logic
runApplication();
} catch (const std::exception& e) {
std::cerr << "Fatal error: " << e.what() << "\n";
logError(e);
return 1;
} catch (...) {
std::cerr << "Unknown fatal error\n";
return 2;
}
return 0;
}
Catching at the boundary rather than everywhere keeps the middle layers free of try blocks that only log and rethrow. A useful test for whether a catch belongs somewhere is whether that code can actually do something — retry, fall back to a default, translate the error, or report it to the user. If it can only log, it is usually better to let the exception reach a boundary that logs it once, with full context. Thread entry points are boundaries too: an exception escaping a std::thread function calls std::terminate, so each thread needs its own top-level handler, or should run under std::async, which stores the exception in the returned future and rethrows it from get().
Preserving the cause with std::nested_exception
Wrap low-level exceptions to preserve cause when propagating upward. std::throw_with_nested throws an object that derives from both your new exception type and std::nested_exception, which captures the currently handled exception. std::rethrow_if_nested checks for that base and rethrows the captured inner exception, so the chain can be unwrapped level by level (a recursive helper can print the whole chain). It only works when the outer exception was created by throw_with_nested; a hand-made wrapper type does nothing when passed to rethrow_if_nested.
#include <exception>
#include <stdexcept>
#include <iostream>
void lowLevel() {
throw std::runtime_error("Low-level error");
}
void midLevel() {
try {
lowLevel();
} catch (...) {
std::throw_with_nested(std::runtime_error("Mid-level error"));
}
}
int main() {
try {
midLevel();
} catch (const std::exception& e) {
std::cout << e.what() << std::endl;
try {
std::rethrow_if_nested(e);
} catch (const std::exception& nested) {
std::cout << " Caused by: " << nested.what() << std::endl;
}
}
}
Translating low-level errors at layer boundaries
When a lower layer throws a generic error, catch it at the layer boundary and rethrow a domain-specific exception so callers don’t depend on implementation details.
// Translate low-level exceptions to domain exceptions
void apiCall() {
try {
lowLevelOperation();
} catch (const std::system_error& e) {
throw DatabaseException("Database connection failed", e);
}
}
A scope guard for cleanup without an RAII type
For cleanup that has no dedicated RAII type (such as rolling back a transaction), a small scope guard runs the cleanup on every exit path unless it is dismissed after success.
#include <functional>
class ScopeGuard {
std::function<void()> cleanup_;
bool dismissed_ = false;
public:
ScopeGuard(std::function<void()> f) : cleanup_(std::move(f)) {}
~ScopeGuard() noexcept {
if (!dismissed_) {
try {
cleanup_();
} catch (...) {
// Log but don't rethrow
}
}
}
void dismiss() { dismissed_ = true; }
};
// Usage
void transactionalOperation() {
beginTransaction();
ScopeGuard guard([]() { rollback(); });
// Operations...
commit();
guard.dismiss(); // Success: don't rollback
}
Two subtleties. Constructing the std::function may allocate and therefore throw bad_alloc — after beginTransaction() has already run, with no guard yet in place. Creating the guard before starting the transaction, or using a template guard that stores the lambda directly (no allocation), closes that gap. And dismiss() must come after the last operation that can throw; if commit() itself throws, the guard still runs rollback(), which is usually what you want.
Testing that the right exception is thrown
Use test frameworks like Catch2 to verify exception types.
// Catch2 example
TEST_CASE("Division by zero throws") {
REQUIRE_THROWS_AS(divide(10, 0), std::runtime_error);
}
A team rule for exceptions vs error codes
Establish team convention: hot loops use error codes, I/O/parsing failures use exceptions.
Some codebases go further and compile with -fno-exceptions: many game engines, much embedded code, and projects following Google’s C++ style guide do not use exceptions at all, often for binary-size reasons or because older code is not exception-safe. In that mode a throw does not compile, and standard library functions that would throw typically abort instead. If you write a library, knowing whether your users build this way affects whether an exception-based API is even usable for them.
Habits for writing catch blocks
Catch by const reference
// ✅ Best
catch (const std::exception& e) {
// ...
}
// ❌ Avoid: catch by value (slicing)
catch (std::exception e) {
// ...
}
Order handlers from specific to general
try {
// ...
} catch (const std::out_of_range& e) {
// Specific
} catch (const std::logic_error& e) {
// More general
} catch (const std::exception& e) {
// Most general
} catch (...) {
// Unknown
}
Don’t swallow exceptions silently
// ❌ Silent swallow
catch (...) {
// Nothing—hides errors!
}
// ✅ Log and rethrow
catch (...) {
log("Unknown exception");
throw; // Rethrow
}
Let RAII own the resources
// ✅ Smart pointers, RAII wrappers
auto file = std::make_unique<FileHandle>("data.txt");
// Automatically closed even on exception
Mark moves and destructors noexcept
class MyClass {
public:
MyClass(MyClass&&) noexcept;
~MyClass() noexcept;
};
Exception Safety Guarantees
Basic Guarantee
No resource leaks; object remains in valid (but potentially unspecified) state.
void push_back(const T& value) {
// If exception during reallocation, no leaks
// but vector state may change
}
Strong Guarantee
Commit-or-rollback: operation succeeds completely or leaves state unchanged.
// Copy-and-swap idiom
T& operator=(const T& other) {
T temp(other); // Copy
swap(*this, temp); // Swap (noexcept)
return *this;
// If copy throws, *this unchanged
}
Nothrow Guarantee
Operation never throws exceptions.
void swap(T& a, T& b) noexcept {
// Guaranteed no exceptions
}
The three levels build on each other. The strong guarantee is usually built from a potentially-throwing step that works on a copy, followed by nothrow steps that publish the result — copy-and-swap is exactly that, and it only works because swap is nothrow. The trade-off is cost: copy-and-swap always makes a full copy, even when assigning into an object that already has enough capacity, so the standard library provides the strong guarantee only where it is cheap and settles for the basic guarantee elsewhere. When I review code for exception safety, the question I ask of each function is “if the line in the middle throws, what state is the object left in?” — a member updated before a throwing call and another updated after it is the typical way a class ends up inconsistent, even though nothing leaks.
Exceptions vs Error Codes vs expected
Comparison Table
| Feature | Exceptions | Error Codes | std::expected (C++23) |
|---|---|---|---|
| Control flow | For exceptional failures | For expected failures | Explicit value-or-error |
| Performance | Slow on exception path | Predictable branching | Similar to error codes |
| Visibility | Not obvious in signature | Explicit in return | Explicit in return |
| Propagation | Automatic | Manual checking | Manual checking |
| C interop | Difficult | Easy | Easy |
When to Use Each
Exceptions:
- Rare, unexpected failures
- Constructor failures (no return value)
- Deep call stacks needing error propagation
- I/O, parsing, validation errors Error Codes:
- Frequent, expected failures (e.g., “not found”)
- Performance-critical hot loops
- C API interop
- When failure is part of normal flow std::expected (C++23):
- Explicit value-or-error modeling
- When you want error code benefits with type safety
- API boundaries where failure is common
A team convention of “exceptions only for truly exceptional failures” reduces confusion about which style a given API uses.
// C++23 std::expected example
#include <expected>
std::expected<int, std::string> divide(int a, int b) {
if (b == 0) {
return std::unexpected("Division by zero");
}
return a / b;
}
int main() {
auto result = divide(10, 0);
if (result) {
std::cout << "Result: " << *result << "\n";
} else {
std::cout << "Error: " << result.error() << "\n";
}
}
std::expected<T, E> holds either a value or an error, and the caller cannot get the value without deciding what happens in the error case — *result on an error state is undefined behavior, while result.value() throws std::bad_expected_access. Unlike an error code returned alongside an out-parameter, the error cannot be silently ignored if you mark the function [[nodiscard]]. It requires C++23 and a recent standard library (GCC 12, Clang 16 with libc++, or Visual Studio 2022 17.3 or later); on older toolchains tl::expected provides the same interface. The cost is that propagation is manual: every layer has to check and forward the error, which C++23’s monadic operations (and_then, transform, or_else) make less verbose but not automatic.
RAII and Exception Safety
RAII (Resource Acquisition Is Initialization): Acquire resources in constructor, release in destructor. Stack unwinding during exception propagation calls destructors, preventing leaks.
RAII Example
#include <iostream>
#include <fstream>
class FileGuard {
std::FILE* file_;
public:
FileGuard(const char* path) : file_(std::fopen(path, "r")) {
if (!file_) throw std::runtime_error("Cannot open file");
}
~FileGuard() noexcept {
if (file_) std::fclose(file_);
}
std::FILE* get() { return file_; }
};
void processFile(const char* path) {
FileGuard file(path); // RAII: auto-closes on exception
// Use file.get()...
// Even if exception occurs, destructor closes file
}
Exception Safety in Practice
Knowing standard container guarantees helps design:
vector::push_back: Strong guarantee (if reallocation fails, vector unchanged) — provided the element type is copyable or has anoexceptmove constructor; with a throwing move and no copy, the guarantee is lostvector::reserve: Strong guaranteevector::insert: Basic guarantee (may partially modify) For custom classes, document exception guarantees in copy/move operations.
FAQ
Q1: Are exceptions slow?
A: If no exception is thrown, overhead is minimal. Exceptions are slow only when thrown and caught. For normal flow, performance impact is negligible. Mainstream compilers use table-based (“zero-cost”) exception handling: the non-throwing path runs no extra instructions, but the unwind tables increase binary size, and an actual throw involves allocating the exception object, looking up tables and running destructors — orders of magnitude slower than returning an error code. That is why exceptions suit rare failures and are a poor fit for “not found” results checked millions of times.
Q2: When to use exceptions?
A:
- Unrecoverable errors
- Constructor failures
- Deep call stack error propagation When NOT to use:
- Normal control flow
- Performance-critical loops
Q3: return vs throw?
A:
- return: Normal termination, expected result
- throw: Abnormal situation, error
Q4: Must I catch all exceptions?
A: Catch only exceptions you can handle. If you can’t handle, let it propagate upward. Catching everything and swallowing hides problems.
Q5: Why use noexcept?
A:
- Enables compiler optimizations
- Essential for move constructors (container optimization)
- Clarifies intent
Q6: Exceptions vs error codes?
A:
- Exceptions: Cleaner code, easier error propagation
- Error codes: Performance-critical, C compatibility needed