Consider using an Either type to handle errors — they lift errors into the type-system and have the same performance characteristics as error-codes.
TL;DR
Consider using an Either type to handle errors as they lift the error into the type-system and have the same performance characteristics as error-codes.
Either Implementations
Introduction
Programming language design is always a matter of trade-offs. In the case of C++, the designers optimized for two things: runtime efficiency and high-level abstraction. This gives the C++ programmer huge flexibility in many areas, one of which is error handling.
Exceptions & Try-Catch
Try-catch is traditionally seen as the most idiomatic error-handling method in C++.
#include <iostream>
#include <string>
int divide(int x, int y) {
if (y == 0) {
throw std::string("Divide by zero");
}
return x / y;
}
int main() {
try {
std::cout << "4/2 " << divide(4, 2) << std::endl;
std::cout << "3/0 " << divide(3, 0) << std::endl;
} catch(std::string e) {
std::cout << e << std::endl;
}
return 0;
}Exception Overhead
The try-catch language feature is not zero-cost and the exact price is determined by the compiler implementation. Implementers can choose between increased code-size and increased run-time overhead, both in the success branch and the failure branch.
In most C++ implementations, code in the try block runs as fast as any other code. However, dispatching to the catch block is orders of magnitude slower. This penalty grows linearly with the depth of the call-stack.
If exceptions make sense for your project will depend on the frequency at which exceptions will be thrown. If the error rate is above 1%, then the overhead will likely be greater than that of alternative approaches.
Exceptions are not supported by all platforms, and methods that throw cannot be easily understood by C.
Ergonomics
Exceptions are very easy to use and fairly easy to reason about. The biggest drawback is that handling exceptions is not enforced by the type-system. Unlike Java, where exceptions must be caught by the caller, catching a C++ exception is optional.
But what about noexcept and throw?
Unfortunately, noexcept and throw simply dictate that a call to std::terminate is made in the case where an unmentioned exception is thrown. This does not enforce any exception-handling at compile-time.
Error-codes
Error-codes are ancient and used everywhere. There are three common forms.
1. Error-codes as Return Values
This pattern is found in many C APIs as it is easy to implement and has no performance overhead, besides the error-handling itself.
const int ERROR = 1;
const int SUCCESS = 0;
int compute(int input, int* output) {
if (cond(input)) {
return ERROR;
} else {
*output = computeOutput(input);
return SUCCESS;
}
}
int output;
int input;
if (int error = compute(input, &output)) {
error_handler(error);
}This pattern can be followed very dogmatically and it is easy to verify that all cases have been taken care of. Unfortunately it has some drawbacks:
- Functional composition is hard — the return value is occupied by the error-code, so the result must be an out-variable.
- Out-parameters enforce a memory layout which is not optimizer friendly.
- Postponing error-handling requires threading the error-code through the call-graph.
2. Error-code as out-parameter
Swapping the semantics of the out-parameter and return value has no significant advantages, except perhaps a slightly cleaner API. This approach can be found in boost::asio.
3. Error Singletons
Error singletons have completely different ergonomics and are mostly found in low-level libraries implementing a system-global state-machine, such as a driver. One prominent example is OpenGL.
Benefits: no out-parameters, error-handling can be deferred. Caveats: shared state makes thread-safety hard, and no shortcutting of computation pipelines.
So What About Eithers?
An Either type is a container which takes a single value of one of two different types:
template<class Left, class Right>
struct Either {
union {
Left leftValue;
Right rightValue;
};
bool isLeft;
};To run computations on the wrapped value, an Either provides leftMap, rightMap, and join:
- leftMap transforms the
leftValueif present, leaving arightValueunchanged. - rightMap transforms the
rightValueif present, leaving aleftValueunchanged. - join unifies both sides into the same type, allowing the Either to be unwrapped.
Either<string, int> myEither = left("hello");
int count = myEither
.rightMap([](auto num) { return num + 1; })
.leftMap([](auto str) { return str + "world"; })
.leftMap([](auto str) { return str.size(); })
.join();Now we can lift exceptions into the type-system:
// Before: exceptions
float sqrt(float x) {
if (x < 0) throw std::string("x should be >= 0");
return computeSqrt(x);
}
// After: Either
Either<std::string, float> sqrt(float x) {
if (x < 0) return left("x should be >= 0");
return right(computeSqrt(x));
}// Before: try-catch
try {
float x = sqrt(-1);
std::cout << "sqrt(x) = " << x << std::endl;
} catch(std::string x) {
std::cout << "error occurred: " << x << std::endl;
}
// After: Either
std::string msg = sqrt(-1)
.leftMap([](auto msg) { return "error occurred: " + msg; })
.rightMap([](auto result) { return "sqrt(x) = " + to_string(result); })
.join();
std::cout << msg << std::endl;What Have We Gained?
We no longer pay for exception overhead and we have encoded the error type into the function signature. The compiler now enforces that we handle the error properly.
Performance
At first glance it seems that every leftMap and rightMap call adds a branch. In practice, the compiler optimizes these away. The following identity is recognized and inlined:
e.leftMap(f).leftMap(g) == e.leftMap([](auto x){ return g(f(x)); })After the optimization step, all abstractions are collapsed. There is no significant difference between the error-code implementations and the Either-based implementations.
Conclusion
Consider using an Either type to handle errors. They lift the error into the type-system, making them safer than exceptions whilst yielding the same performance characteristics as error-codes.
Resources
- loopperfect/neither — Either implementation for C++
- std::expected
- Gist with code samples: https://gist.github.com/nikhedonia/db401285d9f3816e2a74d78c68dd4c6c
- Assembly output on Compiler Explorer: https://godbolt.org/g/5f6mT9
Related
- neither — Either for C++ — The open-source Either implementation referenced throughout this article.
- value_ptr — The Missing C++ Smart-Pointer — Another missing C++ abstraction: a smart-pointer with value semantics.
- C++ Coroutine TS — It's About Inversion of Control — Another exploration of high-level C++ abstractions with zero overhead.
Related Articles
value_ptr — The Missing C++ Smart-Pointer
Use value_ptr to get value semantics on a heap resource. At the cost of some extra copying, your code will be simpler and easier to reason about.
Experimenting with Small-Buffer Optimization
We implemented SmallFun, an alternative to std::function using fixed-size capture optimization (a form of small-buffer optimization) that is 3–5x faster in benchmarks.
C++ Coroutine TS — It's About Inversion of Control
One motivating example where lazy sequences enable you to separate your concerns and maximize code reuse — without increasing complexity.