Error Handling part 3: Techniques
In previous post, we explored levels of abstractions and the flow of errors through interfaces. We did not, however, examine the actual tools used to implement them.
In this post, we will review the main error handling paradigms.
Error handling techniques
Error codes
The oldest way to report errors: return a value that tells whether an error occured.
Easy to implement, easy to understand, and very portable. This is common in C-style and cross-language APIs.
It is fully manual though, which implies significant drawbacks:
-
In most languages, it is buggy by default: the
consumer()
is expected to check for error codes, but if it forgets to do it, the error is silently discarded.This specific example code uses a C++17 feature, the
[[nodiscard]]
attribute, to ask the compiler to verify that the consumer uses the error code, alleviating that risk. This feature does not exist in most languages, though. -
Error forwarding adds a lot of boilerplate, as every single function along the call stack must implement it manually.
And again, if a single function misses it, it is silently discarded.
Sentinel values
Somewhat similar to error codes. The idea is to package in one type both information about success or failure and the actual result.
Many variants exist, such as:
NULL
pointers in C.- returning a negative value in many POSIX apis, such as
open()
. - similarly,
INVALID_HANDLE_VALUE
in Win32 APIs.
The main point is to allow using the same approach as error codes, without having to use a separate parameter. It only works if the type inherently has some unused values that can be used to signify the lack of a useful value. That special value is called a sentinel.
The method has the same pros and cons as error codes.
Exceptions
Error handling as part of language control flow.
The main advantages of exceptions are that:
- They make it easy to have structured error handling, typically by using some kind of exception class hierarchies.
- They skip forwarding boilerplate entirely, as exceptions automatically raise up to the point catching them.
- This makes them safe by default: errors will not be discarded.
This typically comes at the price of much more complexity:
- Reasoning about code paths becomes hard: what can throw? At which point? What are the exact guarantees if a statement throws half-way through?
- Exceptional codepaths tend to be slower on performance-oriented languages. Though irrelevant for most code as few programs really care for a few nanoseconds on an error path, it disqualifies them for time-critical applications.
Callbacks
An error handling method is defined upfront, and error conditions cause it to be called. This is used either:
- for asynchronous error handling, where the function call that triggered the failing operation has already returned by the time the error is detected.
- to provide additional context before returning an error code.
As it is somewhat unusual, we will not cover it.
Some design considerations
Ecosystem practices
Some languages and platforms favor one mechanism over others. For instance:
- C does not have support for exceptions.
- Python language and community favor exceptions.
- Some microcontrollers make exception support unpractical.
- Some framework you use might already have a strong opinion you want to be consisent with.
Error context
Giving context with an error tends to be difficult with error codes and sentinel values.
In the worst case, only one sentinel value exists (eg a NULL
pointer in C) so the only
useful information we can send is “this failed”.
Though there are techniques to provide more information, it is usually impractical, involving saving the error context in some global state area, possibly with thread-safety implications.
A word on monadic types
One way to overcome the limitations of error codes comes from functional programming.
The core idea is to create a type that is the sum of possible outputs and error conditions. That is, after constructing such a type, a variable of that type might take either of any possible output value or any possible error condition.
Here the Result
type is always either an error code or a string value.
This approach has many advantages:
- It solves the forwarding boilerplate problem, because simply forwarding the
Result
carries error information with it. - Attempting to access the value if
Result
holds an error will be detected. - It does not incur the complexities that come with exceptions.
Monadic types deserve a full article some day. They are not reserved to Haskell. For
instance, C++17 std::variant
is similar to Haskell's Either
that we used in the example above.
Conclusion
Writing this I realised that the topic is much deeper than one blog article can cover. Each paragraph would deserve its own post. But it was important to bring closure to that short series, and maybe we will explore those in more depth later.