Daily bit(e) of C++ | Error handling
Daily bit(e) of C++ #6 | Error handling in C++ (with a dash of C++23)
C++ offers several tools for error handling. This article will cover each of them and discuss their benefits and downsides, as each error-handling approach is suited for different use cases.
Exceptions
Exceptions are the most fundamental approach to error handling in C++. It is what the vast majority of the standard library uses to report errors, and it is also the approach that is recommended for users. Yet, it is also one of the most divisive features of C++, with major codebases (notably Google) having disabled exceptions altogether.
If we want to handle exceptions, we surround the code that can potentially throw with a try-catch block. In this example, we throw a standard std::runtime_error
and catch it by its base class std::exception
.
This demonstrates one downside of exceptions: the cumbersome nature of the error-handling code.
The primary reason why exceptions are recommended is that, for many situations, there is no need for any error handling.
Strong and weak exception guarantee
Before we demonstrate that, we need to first talk about exception guarantees. When writing any C++ code, we have essentially four options regarding exceptions:
we can completely ignore exceptions (no exception guarantee)
we can guarantee that our code does not throw exceptions (
noexcept
)we can guarantee that if our code throws, it doesn’t violate any invariants (weak exception guarantee)
we can guarantee that if our code throws, the state doesn’t change (strong exception guarantee)
The strong exception guarantee is effectively transactional. Either the operation succeeds, or nothing changes. The weak exception guarantee permits partial changes (that do not violate invariants).
Let’s demonstrate using a toy type that throws in its constructor on the 5th copy construction.
The strongest guarantee is that a piece of code will not throw.
However, as is usual with C++, code that declares that it will not throw can still throw. Note, though, that it will result in program termination.
RAII + exceptions
As promised, it is time to discuss the exception’s main advantage. If we would write a properly error-handle transactional operation using C-style, it might look something like this:
This is pretty error-prone and hard to read. In C++, we wouldn’t, of course, write the code like this; instead, we would write something to this effect:
This is a lot better. We delegate the cleanup of resources to destructors and hand off ownership where appropriate. However, you might notice an emerging pattern here.
if (error) return;
And this is precisely what exceptions can do for us out of the box. So finally, here is a version using exceptions:
This is very clean. We have zero branching in our code, and all operations rely on RAII for cleanup. But, of course, our function, which previously was noexcept
, will now throw on error.
This leads us to the final point on exceptions, which is the main downside.
When exceptions become cumbersome
The ideal use case for exceptions is when we handle a sizeable transactional operation that succeeds or fails. Take an HTTP request as an example. This allows us to have only one big try{}catch(){}
block on the outermost interface that handles all the errors.
Of course, this brings a problem. What if we care about the different types of errors? In this approach, the exception could be coming from anywhere: it could be from our code, it could be from a 3rd party library, or it could be from the standard library.
This often means we need to have more try{}catch(){}
blocks, potentially translating errors. And sometimes, this can get pretty bad:
This is very cumbersome, and if we don’t have uniform types of exceptions in our code and dependencies, it can be very tricky to abstract this logic into a helper function, leaving this pattern sprinkled across our code base.
There is, unfortunately, a second problem. While the happy path for exceptions is generally faster than when using error codes (as we avoid the repeated if(err)
condition checks). The error path for exceptions is generally relatively slow, and treating it as a non-exceptional case can lead to performance problems.
So let’s look at alternatives.
Error codes
I will skip over the C-style approach here, as we have seen it, but let’s have a look at a translated version of the previous example using error codes:
std::error_code
The first tool in the standard library for handling error codes (without resorting to int
) is std::error_code
.
In straightforward terms, std::error_code
combines an opaque error code with an “explanation” std::error_category
. The standard already provides several categories of errors, notably the std::system_category
, which provides the mapping from errno
to std::strerror
.
To customize, users must provide an enum
with the list of errors and the corresponding error category and then let the compiler know how these map to each other.
The typical use of std::error_code
is either in place of a return error code or as an extra argument, potentially to distinguish between a throwing and noexcept version of an API (see <filesystem>
).
std::expected (C++23)
Using std::error_code
maybe be inconvenient when we need to return some other type, as then we would have to return it as an output argument (as seen in the previous example).
This is when the latest addition to the C++ error-handling arsenal comes in. std::expected
is either the expected return type or the unexpected type (error). The type provides an interface similar to std::optional
and is biased towards the expected path.
Together with the std::optional
the std::expected
also received the monadic interface.
Conclusion
While not worth its section, it should be noted that std::abort()
is a good way to handle terminal errors, particularly when continuing would present the danger of data corruption.
In summary:
Choose exceptions when you can keep the code free of error handling.
Choose error codes when:
errors are frequent
or there is a need for tight error handling
or noexcept interface is preferable/needed
Choose
std::expected
if you would choose error codes and can use C++23.
Just a little nitpick: never, and I mean absolutely never catch exceptions as anything but a `const &`.
Compilers can eliminate most of the boiler plate code for the exception itself if you keep the exceptions nice and const. Don't, and the exception handling turns actually quite costly.
Sure, error codes are still much better, but exceptions ain't that bad either when at least used correctly.
great overview!!