@filedescriptor’s proposal already covers a lot of what my proposal was going to.
I am very much in support of an ErrorOr
-like approach to error handling in Blender, where errors are explicit return values. It’s the same approach as Rust, which has an equivalent Result
type.
I’ve had a lot of experience with this error handling approach over the last 8-9 years coding in Rust. In my opinion and experience, it’s far more robust and easy to reason about than something like exceptions. It does introduce some additional boiler plate, but a lot of that can be mitigated, and it ends up being worth it.
With that said, Result
is not the entirety of Rust’s error handling story. And I think there are some additional things that would be really good for us to steal/copy from that. Specifically, Rust has a double-pronged approach to error handling that I think is important, because ErrorOr
/Result
is not appropriate for all kinds of errors.
Two Different Kinds of Errors
Rust makes a distinction between two kinds of errors:
- Expected errors. These are errors that occur as a normal part of program execution. For example, the user may try to open a file of the wrong file type, or that is corrupted. Or the user might try to insert a key on something that’s not animatable. These are expected cases that need to be handled.
- Unexpected errors. These are violations of assumptions/invariants in the code, and thus don’t have a reasonable path for recovery or handling. For example, a function that assumes that the list given to it is never empty, and thus has no code path to handle that case, but which is then given an empty list.
Importantly, unexpected errors almost always indicate a bug somewhere in the code. In the empty list example, it might be that the function shouldn’t assume non-empty lists, or it might be that the calling code misunderstood the semantics of the function and is using it incorrectly. Or it might be something else. But reaching that state means there’s a bug somewhere, and normal operation of the software can’t be guaranteed to continue correctly.
Handling Unexpected Errors
Rust takes the stance that unexpected errors should simply abort the program.
This might sound extreme, but the rationale is that if you reach a truly unexpected state, it’s hard to know what will happen. You might corrupt user data, for example, which could actually be worse than losing the user’s work since the last save.
So for these kinds of errors, programmers are expected to use asserts. To use the non-empty-list example again: if a function really believes it will never be passed an empty list, and is therefore ignoring that case (and hopefully documents that fact in its doc comment), it should assert that the list is non-empty.
Returning something like a Result
for these cases is not only overkill, and not only can it make code unnecessarily verbose, but it is also misleading because it gives the impression that such an error could/should be a normal part of correct program execution.
Blender already has BLI_assert
for this kind of error, and I think it makes sense to continue using it for that. Rust also makes a distinction between normal asserts (which trigger in all builds, and represent critical invariants) and debug asserts (which only trigger in debug builds) that might be useful to adopt.
Handling Expected Errors
@filedescriptor already covered this well with his description of ErrorOr
. Rust has an equivalent type Result<T, E>
.
Just to resummarize: Result
essentially amounts to a tagged union, and can be either T
(the normal return type) or E
(the error type), but never both. (Rust calls these “ok” and “error”, respectively.) This forces calling code to handle the possibility of an error if it wants access to the return value. Which is a really good thing.
Rust has a lot of nice features that make working with Result
ergonomic. I strongly suspect not all of these can be reproduced in C++. But I think we can get at least some of them. The TRY
macro that @filedescriptor highlighted is a good example (in Rust it’s the ?
operator).
Turning Expected Errors Into Unexpected Errors
Higher-level code very often benefits from making assumptions that lower-level code doesn’t or can’t.
For example, say we have the following “lower level” function:
Result<int, ParseError> parse_unsigned_int(char *text) {
// Try to parse the string into an integer.
}
The higher-level code that calls this may already know that the string being passed is a valid unsigned integer string, due to the additional context and knowledge it has at the higher level.
In these cases, the higher-level code essentially wants to “convert” the expected error of the lower level function into an unexpected error, because it knows that in this context it is indeed unexpected.
This can be done fairly straightforwardly with an assert:
auto int_result = parse_unsigned_int("123456");
BLI_assert(int_result.is_ok());
int my_int = int_result.get_ok();
However, this is pretty verbose if you’re doing it often. It also can’t be inlined into expressions, which forces the creation of temporary variables that clutter things up.
Rust provides various convenience methods on Result
to handle cases like this. Here are some examples:
// Extracts the inner `T` value if ok, otherwise aborts.
// This essentially turns an otherwise expected error into an
// unexpected error. It makes a lot of sense in cases where
// the context should ensure that the error case never happens.
int my_int = int_result.unwrap();
// Same as `unwrap()` above, except that no check is done for
// the error case at all, and thus is undefined behavior if
// it's an error. This can make sense in performance-sensitive
// code.
int my_int = int_result.unwrap_unchecked();
// Extracts the inner `T` value if ok, otherwise returns a default
// of twelve.
int my_int = int_result.unwrap_or(12);
// The converse of `unwrap()`: extracts the inner error `E` if
// it's an error, otherwise aborts.
int my_int = int_result.unwrap_err();
In general, I recommend stealing a lot of things from Result
’s API.
What If You Don’t Need a Value, Only An Error?
There are situations where you may want to indicate a possible error, but there is no “ok” value to return because conceptually the function has no return value.
In Rust this is addressed by the unit type ()
, which is simply a memberless tuple. Functions that might return an error but have no ok value to return can return Result<(), ErrorType>
.
It might seem odd to use Result
when there is no success value. Why not just return ErrorType
directly, then?
The main reason is to allow uniform handling of errors. For example, Result
has a lot of useful helper methods like unwrap()
and is_ok()
that can conveniently assert invariants, check success, etc. And things like a TRY
macro will still work to propagate errors up the chain.
C++ can accomplish something like Rust’s unit type with a memberless struct. We could either provide one specifically for this purpose, or use std::monostate
from C++17.