folly/result/docs/design_notes.md
result design notesThis is for folks who thoroughly read README.md and result.md, and want
to understand why the overall design is this way.
result instead of <alternative>?result addresses the high-level problem that some codebases want to avoid
exceptions... while existing error-handling approaches tend to be verbose,
complex, incompatible (all-or-nothing), and/or brittle. The preface to Barry
Revzin's P2561 speaks more to the issue.
result does not say you must avoid exceptions. It deliberately interoperates
with them, while giving you a viable alternative, where needed.
C++ is built around exceptions. Using them has important upsides:
std:: APIs throw. It is the most common error-handling paradigm.Simultaneously, exceptions have considerable downsides:
Programmers are liable to forget their subroutines can fail. This can have
catastrophic consequences when a program fails to use RAII for mandatory
cleanup. This problem is especially bad when you must co_await the
cleanup, which is incompatible with RAII.
Absent catastrophic bugs, forgetfulness can cause the errors to be caught at an excessively high-level error boundary, degrading reliability.
Writing exception-safe C++ is tricky, testing for exceptions is tedious, so the net result is that typical code has many exception-safety bugs. Patterns for the "basic" exception-safety guarantee (scope guards, RAII, composition of safe components) simply need to be learned & enforced in code review -- this alone creates ongoing reliability risks. Furthermore, the number of advanced topics that programmers need to concern themselves with is quite high. Some highlights:
noexcept.Throwing is slow, over 1usec per ExceptionWrapperBenchmark.cpp. This can
really ruin your day in a latency-sensitive application.
expected<T, std::exception_ptr> directly?The precursor to result started out as a template alias to folly::Expected
(the ancestor of std::expected). The folly version is already usable as a
short-circuiting coroutine (co_await propagates errors).
With tens of thousands of lines of code using this idiom, a few deficiencies
became clear. Many of these would be expensive or impossible to address with
folly::Expected coroutines, so we went for a dedicated type. While the overall
behavior is similar, there are many valuable improvements:
Much better usability:
res.value_or_throw() rethrows the actual exception instead of a
non-debuggable BadExpectedAccess.folly::get_exception<Ex>(result) makes error-checking easy.result supports epitaphs / provenance, which lets users capture
error-propagation stacks (details in epitaphs.md).folly::coro, so that result can supersede Try
in that usage.co_await expectedFn() is easily confused with async folly::coro code,
while co_await or_unwind(resultFn()) is not.Automatic exception boundary: Since an error-state result can store
std::exception_ptr, a result coroutine can implicitly wrap and return
unhandled exceptions.
Reference semantics, fewer copies: The folly::Expected<>
short-circuiting coroutine only implements await-as-value. As a
consequence, we saw many unwanted copies crop up, enough to impact perf in
some cases. result addresses this via (i) almost no implicit copying,
(ii) full support for reference types (result<T&>, result<T&&>).
[[nodiscard]] on result forces error checking. Theoretically, an
Expected template alias could perhaps achieve the same result by extending
compilers & the standard to allow marking template aliases
[[nodiscard]].
Other API polish:
result's API hides the empty-by-exception (mis)behavior of
folly::Expected well enough that it can be truly guaranteed nonempty as
soon as std::expected is available.expected<T, std::error_code> or boost::outcome?Of course, result's jack-of-all-trades error-handling paradigm may not be what
you want -- sometimes you must prioritize speed, maximize legacy-code
compatibility, or avoid heap allocations on the error path.
For such cases, consider:
expected<T, std::error_code>. Per above, folly::Expected may be
preferable to std::expected, since its co_await can propagate errors.boost::outcome::result<T> -- much like expected<T, std::error_code>.boost::outcome::outcome<T, std::error_code, std::exception_ptr> -- a rough
analog of folly::result, but with higher UX complexity, and higher
customizability. While folly::result tries to be good for everyone by
default (to be a useful standard for a large codebase), outcome lets you
tune behaviors extensively.folly::result to boost::outcome:
BOOST...TRY macros provide short-circuiting operations similar to
co_await folly::or_unwind(). The authors are against co-opting
co_await for short-circuiting
meaning that syntax sugar is blocked on the standardization of
P2561 or similar.boost::outcome synchronous coroutines (lazy<T>, eager<T>,
generator<T>, etc) have special behavior when T is an
outcome::result or outcome::outcome. In contrast, folly::coro
async coroutines like now_task<T> can always be awaited as
folly::result<T> -- so some_task<result<T>> is redundant.folly::Try instead?A few reasons, in order of importance:
Tri-state is a poor fit for short-circuiting coroutines: Unlike
expected, Try has three states: default-empty, value, error. Most
business logic should only handle "value or error". The need to handle empty
states adds cognitive burden, as well as boilerplate. The boilerplate
shrinks if co_await for Try short-circuits both empty and error states,
but that makes it easy to forget to handle empty states in code that
actually differentiates them. For example, there's no longer a meaningful
catch-all -- get_exception<std::exception>(emptyTry) would have to return
null. A robust API for a tri-state would also have to make it explicit
whether it short-circuits on empty:
co_await or_unwind_or_empty(...) // errors unwind, `std::optional<T>`
co_await or_unwind(...) // both errors and empty unwind, `T`
While this could be made to work, it is a less compelling offering.
Better API: As a clean-slate design for coroutine plumbing, result
gets API enhancements that are prohibitive to make in Try:
Implicitly-throwing value() was renamed to value_or_throw(), and
operators * / -> were omitted. This encourages explicit, non-throwing
error handling and/or or_unwind usage.
error_or_stopped() + has_stopped() instead of exception() encourages
C++26-aligned separation of "stopped" and
"error" error handling.
Typical code isn't made verbose -- test results for errors via
get_exception<Ex>(); in coroutines, obtain results via
co_await value_or_error() or value_or_error_or_stopped().
Try lacks useful implicit conversions from its value type -- and prior
debates settled on not adding them.
These could maybe be backported to Try, but result already has them:
for loops over result
generators.Two-state aligns with std::expected: result's two-state semantics
should, long-term, be less surprising since expected is standard C++23 and
is guaranteed nonempty.
folly::coro tasks provide the same functionality?Technically, folly async tasks have long supported return-oriented, short-circuiting error handling, in addition to their default throwing style:
// Get value, or propagate error / stopped
auto v = co_await co_nothrow(asyncMightFail());
Before result, correct non-throwing error handling looked like this:
// Test for value / error / stopped / empty (can happen in buggy code!)
auto t = co_await co_awaitTry(asyncMightFail());
if (t.hasValue()) {
// handle t.value()
} else if (!t.hasException()) {
// somehow handle empty `Try` -- DFATAL?
} else if (auto* ex = t.tryGetExceptionObject<MyError>()) {
// NEVER handle `std::exception` since that interrupts cancellation!
LOG(ERROR) << ex->what();
} else {
co_yield co_error(t.exception()); // propagate unhandled or stopped
}
This is both verbose and hard to get right, so most folly::coro users write
throwing code with try-catch exception boundaries as needed.
You can now use result to clean up non-throwing async error handling:
auto r = co_await value_or_error(asyncMightFail());
// Handling `std::exception` won't interrupt cancellation
if (auto ex = get_exception<MyError>(r)) { // a rich ptr, NOT `auto*`
LOG(ERROR) << ex; // don't write `->what()` here!
} else {
auto& v = co_await or_unwind(r);
// handle `v`
}
While this works fine, you should prefer result coroutines + or_unwind for
synchronous code with pervasive error handling -- they are simpler and cheaper.
folly::coro tasks will likely never optimize as
well as short-circuiting coroutines, which only suspend on destruction.
Also, folly::coro plumbing is inherently more complex in order to support
asynchrony (executors, cancellation tokens, etc).folly::coro has native support for adding
epitaphs to results in error-or-stopped states (epitaphs.md) --
currently, round-tripping through Try discards epitaphs.result APIIf you're wondering "why does result do <specific thing>?", and cannot
find it in a code comment, the rationale may be documented here.
result<V&> have deep-const copy semantics?result is movable iff T is, and copyable iff T is. The exception is
reference types: result<V&&> is move-only (following
rvalue_reference_wrapper), and result<V&> has a "deep const" restriction.
Since const result<V&> only exposes const V& (see next section), copying
result<V&> from const result<V&>& would silently grant mutable V& access
through what was behind a const barrier. To prevent this, result<V&> is only
copyable from a mutable source (result<V&>&), not from const result<V&>&.
result<const V&> is fully copyable, since the inner const cannot be lost.
See also DefineMovableDeepConstLrefCopyable.h for the general pattern.
const result<Ref> propagate const?When you access the contents of const result<V&>, you get const V&,
not V&. This differs from std::reference_wrapper, which does NOT
propagate const.
This choice was made because this sort of code is common:
const auto& r = obj.foo(); // returns `result<V&>`
auto& v = r.value_or_throw(); // Without const propagation: Bug! `v` is mutable
If foo() returns result<V&>, the user may expect r to be deeply const.
But, without const propagation, they'd get mutable access to the referenced V
through what looks like a const reference -- a const-correctness bug.
On the flip-side, propagating const is relatively straightforward, and has
few negative UX consequences. The main limitation is that you can only copy a
result<T&> from a mutable reference to result<T&> (because the copy
would otherwise grant mutable access that wasn't originally available).
If const-propagation isn't what you want, store result<V*> or
result<std::reference_wrapper<V>> / rvalue_reference_wrapper instead.
std::exception_ptr instead of error codes?Wouldn't modeling errors as integer codes, like std::error_code, be 🚀
obviously faster? Actually... result does not give up much speed, and gains
a lot of flexibility.
Compatible: Storing type-erased exceptions interoperates with
exception-based code (throwing & folly::Try, synchronous & folly::coro).
Supports codes: Per rich_error_code.md, result provides < 5ns
RTTI-free retrieval of coded errors, despite the type erasure. Use
errc_rich_error.h, coded_rich_error.h or nestable_coded_rich_error.h
to construct coded errors, and test them via get_rich_error_code<>().
Small: The model of rich_error_code is deliberately simpler than
std::error_code -- its category / domain for type erasure is the
exception type itself, meaning that we don't have to store an extra
category pointer. Today's 64-bit result<uintptr_t> is 16 bytes -- just
like std::error_code alone. If minimizing result size is of paramount
importance, see future_small_value.md for an optimization idea.
Fast enough: Using error_or_stopped{Error{}}, construct-destruct costs
~60ns. This is usually fast enough. If not, immortal_rich_error<Error> has
construct-destruct costs of under 5ns. Calling get_exception<Error>() on
both kinds of errors takes ~5ns thanks to a no-RTTI optimization -- we may
later extend this per future_fast_rtti.md.
Error provenance: Our type-erasure also powers epitaphs.md.
result distinguish "stopped" and "error"?result exposes error_or_stopped(). This distinguishes with "stopped" (for
cancellation) from "error" via has_stopped() and stopped_result. This
aligns with P1677 and
C++26/P2300 std::execution, which hold that
cancellation is not an error. Instead, we learned that "stopped" is early,
serendipitous success that typically only requires quick RAII cleanup --
not handling.
Today's folly::coro treats cancellation as an exception
(OperationCancelled), which has several problems:
catch (...) and catch (const std::exception&) accidentally swallow
cancellation.co_awaitTry returns cancellation as an error, requiring explicit checks.This causes many subtle bugs where user code inadvertently interrupts cancellation propagation.
By making "stopped" a distinct state, result encourages correct handling:
co_await value_or_error() automatically propagates
cancellation, producing a result that is never stopped.result from another source (value_or_error_or_stopped() or
try_to_result()):
get_exception<UserEx>() cannot match stopped.OperationCancelled is a compile error, users are told to
call has_stopped() explicitly.get_exception<std::exception>(). A migration is planned to fix this,
but will take time.See the folly/OperationCancelled.h docblock for example code.