Hello! In our previous lesson, we introduced C++ exceptions and learned the mechanics of try, catch, and throw. We saw how they work with RAII to provide a robust way of handling runtime errors.
This lesson builds directly on that foundation. Our goal is to compare exception handling with traditional C-style error codes. This is a classic and ongoing debate in the C++ community. Understanding the trade-offs is crucial for making informed design decisions, especially given your interests in both high-level desktop applications and low-level embedded systems, where the "right" choice can differ dramatically.
We'll explore the pros and cons of each approach by looking at:
- Code readability and maintainability.
- The costs in performance and binary size.
- When one approach is clearly superior to the other.
1. The Classic Trade-Off: Clarity vs. Clutter
As we touched on before, the primary method for error handling in C is to use function return values. A function returns a special value (like 0, -1, or a specific enum) to indicate success or failure. The caller is then responsible for checking this value.
The main argument for exceptions is that this style of error checking clutters the primary logic of your program.
Exceptions and Error Handling, C++ FAQ - Standard C++
The C++ FAQ you read in the last lesson has excellent sections that directly contrast the two styles. Let's revisit it to see a clear illustration of this problem.
Please read the following two sections: 'What does it mean that exceptions separate the “good path” from the “bad path”?': Focus on the two code snippets. Notice how the exception-based version has a clean, linear flow for the main logic (the 'happy path'), while the return-code version interleaves logic with if-else checks. 'I’m still not convinced... why should I therefore use exceptions on an application that is orders of magnitude larger?': This demonstrates the most powerful argument for exceptions—scalability. Observe how in a deep call stack (f1 to f10), the return-code approach forces every intermediate function to contain boilerplate error-propagation code, whereas exceptions handle this automatically.
As the examples show, exceptions excel at separating the main algorithm from the error-handling logic. This separation leads to two key benefits:
- Readability: The "happy path" is clean and easy to follow. Error handling is centralized in
catchblocks, which are physically separate from the main flow. - Maintainability: When propagating an error up a deep call stack, you don't need to modify any of the intermediate functions. With return codes, every function in the chain must be modified to check and pass the error code along. This is tedious and error-prone.
Here's a summary of the classic arguments:
| Aspect | C-Style Return Codes | C++ Exceptions |
|---|---|---|
| Main Logic | Intermixed with error checks (if (rc != 0) ...). |
Clean and separate (the try block). |
| Error Handling | Scattered throughout the code after each function call. | Centralized in catch blocks. |
| Propagation | Manual. Every function in the call chain must check and return the error. | Automatic. Stack unwinding propagates the error to the nearest handler. |
| Ignorability | Easy to accidentally forget to check a return code, leading to silent failures. | Cannot be ignored. An unhandled exception terminates the program. |
| Constructors | Cannot report errors effectively, as they don't have a return value. | The only clean way for a constructor to signal a fatal error. |
2. The Performance and Overhead Debate
The most common argument against exceptions is their perceived cost. This is where the comparison becomes much more nuanced.
The Cost of Throwing
When a throw occurs, the system does a lot of work:
- Stack Unwinding: The program must walk back up the call stack, calling destructors for all local objects in each frame until a
catchblock is found. - Handler Matching: It often uses Run-Time Type Information (RTTI) to find a
catchblock that matches the type of the thrown exception. - Memory Allocation: The exception object itself is often allocated in a special, dynamically-managed memory area.
This process is significantly slower than a simple function return.
Exceptionally Bad: The Misuse of Exceptions in C++ & How to Do Better - Peter Muldoon - CppCon 2023
This clip from Peter Muldoon's CppCon talk, "Exceptionally Bad," provides a concise summary of the work done behind the scenes during a throw and catch.
Watch from 00:11:02 to 00:13:40. The speaker explains the costs associated with memory allocation for the exception object, the separate stack unwinding mechanism, and the polymorphic catching that uses RTTI. The key takeaway is that the mechanism is designed to be powerful, not necessarily fast.
The "Zero-Overhead" Principle
C++ was designed with the zero-overhead principle: "what you don't use, you don't pay for." For exceptions, this means that if no exception is thrown, the performance cost should be minimal. Modern compilers are very good at this. In a try block, the code on the "happy path" runs at or very near native speed. The cost is only paid when an error actually occurs.
In contrast, a return-code approach pays a small cost—an if statement (a conditional branch)—on every single function call, regardless of whether an error occurred.
A Counter-intuitive View: Exceptions in Firmware
Given your interest in embedded systems, you might assume that exceptions are completely off-limits due to their overhead and non-determinism. While this is often the case, the full story is surprising.
The following talk investigates what happens when you challenge this dogma. It provides a data-driven comparison of binary size and performance.
C++ Exceptions for Smaller Firmware - Khalil Estell - CppCon 2024
Khalil Estell's talk, "C++ Exceptions for Smaller Firmware," is a fascinating deep-dive that turns the conventional wisdom about exception overhead on its head. It is highly relevant to your interest in automation and Arduino.
This is a rich presentation, but let's focus on the core argument: The "Virality" of Return Codes (10:48 - 14:18): See the real-world problem of using return-code systems (std::expected). Notice how the error-checking code pollutes everything, a phenomenon his students hated. The Binary Size Experiment (23:34 - 28:00): This is the key part. The speaker presents his hypothesis and experimental results showing that as a project grows, the accumulated size of all the return-code checks can become larger than the one-time cost of the exception-handling machinery. There's a crossover point where exceptions lead to a smaller binary. Centralized vs. Distributed Handling (01:03:25 - 01:05:30): This provides the mental model for why this happens. Return codes are a distributed error system (cost is per-call). Exceptions are a centralized system (cost is mostly fixed).
The key insight is that while the upfront cost of enabling exceptions (in binary size) is higher, the marginal cost of adding another potential error site is near zero. For return codes, the upfront cost is zero, but every check adds a few bytes to the binary. In a large system with thousands of checks, this adds up.
3. Practical Guidance: Which to Choose?
There is no single answer. The choice depends on the nature of the error. A good heuristic is to distinguish between errors and failures.
-
Use Exceptions for Exceptional Errors:
- An error is a condition that prevents your function from fulfilling its fundamental contract (e.g., memory allocation failure, a file that must exist is corrupted, a network connection is unexpectedly lost).
- These are situations where the caller likely cannot recover locally and the problem must be propagated up to a higher level of the application.
- Constructor failures are the canonical example where exceptions are the only viable solution.
-
Use Return Codes (or Modern Equivalents) for Expected Failures:
- A failure is a predictable, alternative outcome of an operation (e.g., a user enters an invalid date, a search function doesn't find the item, a file open function fails because the file doesn't exist).
- These are situations you expect to happen in the normal course of operation and can often be handled locally with a simple
ifstatement.
For these expected failures, Modern C++ provides safer and more expressive alternatives to raw integer codes.
Modern Alternatives
-
std::optional(C++17)
Use this when a function might or might not return a value. It elegantly handles the "found" vs. "not found" case without magic values likenullptror-1.#include <optional> #include <iostream> std::optional<int> find_user_id(const std::string& username) { if (username == "admin") { return 1; // Found, returns an optional containing 1 } return std::nullopt; // Not found, returns an empty optional } // Usage: if (auto id = find_user_id("guest"); id.has_value()) { std::cout << "User ID: " << id.value() << std::endl; } else { std::cout << "User not found." << std::endl; } -
std::expected(C++23)
This is the ideal modern replacement for return codes. It's a type that holds either an expected value (on success) or an unexpected error value (on failure).#include <expected> #include <string> #include <iostream> // A simple enum for our error types enum class ParseError { EmptyString, NotANumber }; std::expected<int, ParseError> parse_integer(const std::string& s) { if (s.empty()) { return std::unexpected(ParseError::EmptyString); } try { return std::stoi(s); } catch (const std::invalid_argument&) { return std::unexpected(ParseError::NotANumber); } } // Usage: auto result = parse_integer("123"); if (result) { // A bool-like check for success std::cout << "Parsed value: " << *result << std::endl; } else { switch (result.error()) { case ParseError::EmptyString: std::cerr << "Error: Input string was empty." << std::endl; break; case ParseError::NotANumber: std::cerr << "Error: Input is not a number." << std::endl; break; } }This gives you the locality and predictability of error codes but with type safety and clear intent.
Conclusion
You've now explored one of the most fundamental design trade-offs in C++. There's no universal "best" way to handle errors; context is everything.
Key Takeaways:
- Exceptions excel at separating the main logic from error handling. They provide robust, automatic propagation of serious, exceptional errors that cannot be handled locally. They are essential for reporting failures from constructors.
- Return Codes are simple and deterministic. They are best suited for handling predictable, local failures. However, they can clutter code and are easily forgotten.
- Performance is complex. Exceptions are expensive when thrown, but cheap when not. In large systems, the code-size overhead from thousands of return-code checks can exceed the overhead of the exception-handling tables, making exceptions a surprisingly efficient choice in some scenarios, even in resource-constrained environments.
- Modern C++ offers
std::optionalandstd::expectedas type-safe, expressive alternatives that capture the benefits of return codes without their main drawbacks.
As a developer, your job is to choose the right tool for the job. Is the condition you're handling a rare, system-level catastrophe or a predictable alternative path? Your answer will guide you to the correct error-handling strategy.
Next Up
In our next lesson, we will look at a feature that helps manage the complexity of types we've been dealing with. We'll learn to use the auto keyword for automatic type deduction, which can significantly improve code readability and make refactoring easier.