Introduction
The most important functional programming feature that was missing from Rust in C++, for me, is the ? operator, which is critical if you handle errors via values instead of exceptions, like many modern languages do. This article will show you how I implemented it in C++.
This article may not be particularly suitable for C++ beginners, but I have tried to make it approachable with as much love as I’m legally allowed to give.
The feature discussed was proposed in P2561 all the way back in 2022.
Why does it exist in Rust?
Instead of writing code like so:
const auto maybe_value{some_optional_or_expected(/* ... */)};
if (!maybe_value.has_value()) {
return /* .error() or nullopt or some other error */;
}
auto value{maybe_value.value()};
If we had the same operator from Rust, we would be able to write it like this instead:
const auto value{some_optional_or_expected(/* ... */)?};
(This is completely unrelated to the ternary operator, and ? is used for illustration here.)
If the optional is missing, then the std::nullopt is returned, and if std::expected has error that error is returned; otherwise, control flow continues.
Now, the lack of this feature would be somewhat tolerable if C++ supported variable shadowing within the same scope:
const auto value{some_optional_or_expected(/* ... */)};
if (!value.has_value()) {
/* ... */
}
auto value{maybe_value.value()}; // type changes!
Of course, we can at least write it like this C++17’s if statement with initializer:
if (const auto value = some_optional_or_expected(); !value.has_value()) {
return value.error();
} else {
/* ... */
}
What’s wrong with the current approaches of C++?
The problem this operator solves is not being able to do this common procedure inline; this becomes very important when your entire application is based on optionals and expecteds, and it’s why Rust made the operator.
In addition, something that Rust doesn’t have with ? is being able to return a modified error type within the same operator. So if for example it is an error, you can change the return type to something else, possibility looking at the error itself. This is basically syntactic sugar for doing a chain of transformations, but covering simple cases.
Unfortunately, the lack of this feature is one of the many annoyances of C++ we have to deal with to the heat death of the universe, just like we cannot extend STL types to have basic string methods we have been asking for the last decade because UFCS won’t be supported until C++53, for various reasons.
Fortunately, however, one tool comes to the rescue at the end of this insidiously obscene tunnel, as the C preprocessor is more powerful than some people think; it allows us to fully emulate this feature in C++, and on top of that, get a text dump jumpscare when you make a mistake for free. (Due to the ongoing tarriffs, error production has been reduced by 50% since last quarter.)
The implementation
Since it’s a convention in C++ to prefix the discussed practice with maybe_, I thought it would be fitting to name my macro maybe:
const auto value{maybe(/* ... */)};
(IIRC, maybe was a keyword in C++20 contracts that were subsequently removed, so it should be safe to use, but if you want to be safe you can add your own prefix to it; for example, in C++ the co_yield keyword has that name instead of yield for routines.)
The reason I don’t use uppercase is because in my codebase I treat this like a primitive, just like <cassert>’s assertion macro is not uppercase. (Did you know that it’s a macro?)
The base case is when the user simply wants to return the bad value, which is just one argument:
const auto value{maybe(expected_or_optional)};
Now, in my case, one thing I wanted is a second argument that lets me modify the return value within the error basis:
const auto value{maybe(expected_or_optional, /* ??? */)};
This could likely be avoided by using and_then, transform, but the point of this optimization is to make our syntax as compact as possible.
For example, in the tokenization phase of a parser tool I’m developing, I needed this to return a missing component if a regular expression did not find a match (for those interested, I used a great library called ctre):
const auto binary{maybe(search<binary_pattern>(line), std::unexpected(missing_component("binary")))};
I will be able to make errors that repeated in my code even smaller when C++26 compile-time reflection is implemented in compilers, but until then we have another layer to deal with.
In other cases, I want to take the actual wrapped return value and run some expression on it that modifies it in some way. To do this, I made my macro define the intermediate __maybe_macro variable:
const auto binary{maybe(expected_or_optional, expression(__maybe_error))};
Note that the resolution of shadowing concerns with this will come packaged with the language construct that we shall use.
So our first question is how do we even have macro overloading? The preprocessor does not let us simply define a macro with the same name twice. There is a pattern to implement it and I personally call it macro routing. It looks like so:
#define GET_maybe_MACRO(_1, _2, NAME, ...) NAME
#define maybe(...) GET_maybe_MACRO(__VA_ARGS__, maybe_2, maybe_1)(__VA_ARGS__)
maybe_1 and maybe_2 are other macros that will be explained in a moment. The ... is the variadic parameter itself; it is what allows us to collect all the extra parameters we need. Since we cannot simply refer to ... in the body, another preprocessor token was defined by the standard, __VA_ARGS__.
The routing you see works by pushing the macro names to the end of the argument list, so that the NAME picked is the one that is last available. So suppose you call:
maybe(x)
In that situation, the preprocessor would evaluate it (well not literally evaluate since it’s just substituting literal text) to:
GET_maybe_MACRO(x, maybe_2, maybe_1)
So the NAME will be maybe_1. If we added another argument:
maybe(x, y)
This is also fine because even though we exceed three arguments we support accepting more because variadic arguments are accepted and subsequently ignored, so we get:
GET_maybe_MACRO(x, y, maybe_2, maybe_1)
So our final evaluation of NAME is the third argument, maybe_2. So that’s how the macro routing aspect works.
Now, you may have many ideas for the implementation of maybe_1 and maybe_2.
Can we use IIFE?
You may think that an IIFE is the appropriate approach to this, something along the lines of:
const auto iife{[](auto&& v){
if (!v.has_value()) {
if constexpr (requires { v.error() }) {
return std::unexpected(v.error());
} else {
return std::nullopt;
}
}
return v.value();
}()};
That would simply not work because you cannot return from the outer function from inside the body of a lambda called in that function; this would only return from the lambda itself.
Can we use goto?
Alternatively, you may be naturally conditioned to think that we could do this by using a goto:
auto parent_function() {
// this is more for demonstration not how you would actually write it
T __value;
__failure:
return std::unexpected(v.error());
const auto iife{[](auto&& v){
if (!v.has_value()) {
if constexpr (requires { v.error() }) {
__value = v.error();
goto __failure;
} else {
__value = std::nullopt;
goto __failure;
}
}
return v.value();
}()};
}
This reminds me of my patching days since it is a similar concept to trampolines.
(If you are not familiar with some of these language constructs, they will be explained in a moment.)
This is extremely dangerous, and would require us setting up a hook at the top of the function (for the __failure label) with a separate macro — a side effect that would immediately negate any ergonomics we otherwise want to gain. And we definitely don’t want to introduce impure ABI by modifying our compiler’s code to hard code it in the prologue of our functions, either.
So what even remains for us to consider?
Sometimes, to find the solution you seek, you must venture out into the wilderness. C and C++ are well known for their unique property of having ISO standards backing their specification, but just like any other language, extensions exist to them. GNU maintains a set of extension for it, and the one we are interested in for this discussion is statement expressions.
The word extension might sound scary, but it is implemented by all new versions of GCC and Clang; if you have concerns about MSVC and Visual Studio compatibility, you can use a compatibility layer or the clang-cl toolset.
Statement expressions are written like this:
({
statement1;
statement2;
// ...
final_value;
})
The value of final statement is the value returned, which treats our statement like a return one. But the benefit from this is that actual return statements (such as replacing statement1 with a conditional return) can return from the parent function! This is precisely what we are asking for.
The smaller pillar
So we begin by writing the second case of the macro, which is less technically challenging:
#define maybe_2(expr, fallback) \
({ \
auto&& __result{(expr)}; \
if (!__result) { \
[[maybe_unused]] auto&& __maybe_error{::__get_error(__result)}; \
using __fallback_type = std::decay_t<decltype(fallback)>; \
return std::unexpected<__fallback_type>(fallback); \
} \
*std::move(__result); \
})
The fallback expression provided the user may use __maybe_error if they want to. We use C++17’s [[maybe_unused]] attribute to semantically communicate that its use is unnecessary, but also to hide potential compiler warnings.
The std::decay is useful because it removes cv-qualifiers, references, and other things that could be obstructing our view on the actual type.
For the __get_error function, we need to handle the possibly of both std::optional and std::expected. We can use a requires expression and compile-time branching to implement it.
template <typename T>
auto __get_error(T&& container) -> decltype(auto) {
if constexpr (requires { container.error(); }) {
return std::forward<T>(container).error();
} else {
return nullptr;
}
}
Explanation
The use of decltype(auto) is a relatively old feature from C++14 that essentially lets one use auto but with the rules of decltype; it exists because auto uses the rules of template deduction, so it does things like drop cv-qualifiers and references, but decltype follows different rules that preserve them with the use of reference collapsing rules. Don’t be confused because we aren’t literally passing an argument to decltype; this combination is actually a reserved keyword specified in the standard.
The requires expression allows us to check if a particular piece of code would compile, if we assume that the variables that it uses exist. In particular, the C++ standard explicitly states that if a type used is a template, then it will return a boolean where the program would otherwise be ill-formed; in other cases, the requires expression yields a hard error, so it is generally only used in contexts when we have a template parameter.
Finally, the std::forward is similar to decltype(auto), in that it was created to preserve some sort of reference type passed. If you remember how std::move works, it doesn’t actually “move” anything; it simply converts the type passed (usually lvalue) back to the appropriate reference type, which can later be used with the move-assignment-operator. More specifically, while std::move unconditionally converts anything into an rvalue (specifically an xvalue), std::forward conditionally casts to rvalue or lvalue depending on the template type.
The larger pillar
Now we need to implement the unary overload, i.e. the macro with a single argument. The reason this is more difficult is we need to be the ones who actually return the correct error.
Suppose first that a function returns std::expected<T, E>. If you want to use this macro inside that function for a procedure that returns std::expected<U, E>, then you cannot simply return the error because there is a type mismatch between T and U; rather, you have to check that it is an std::expected, steal its wrapped value, and finally create an std::expected with the correct type template parameters.
My naive approach was to do something along the lines of:
#define maybe_1(expr) \
({ \
auto&& __result{(expr)}; \
if (!__result) { \
auto&& __maybe_error = ::__get_error(__result); \
using __maybe_error_type = std::decay_t<decltype(__maybe_error)>; \
if constexpr (std::is_same_v<__maybe_error_type, std::nullptr_t>) { \
return std::nullopt; \
} else { \
return std::unexpected(__maybe_error); \
} \
} \
*std::move(__result); \
})
(If you were wondering why we use nullptr over NULL, this is why; it has its own type, std::nullptr_t, which opens the door for metaprogramming.)
But anyway, this approach is incorrect because we cannot return two different types from an expression. You don’t see this happen often in C++ because we don’t have if expressions like in Rust, but with statement expressions it is something we actually need to be careful about.
This is the compiler error you would get, when used in my application:
../src/logic.cpp: In explicit object member function ‘std::expected<void, colorlink::error::ColorLinkError> colorlink::logic::ColorLinkLogic::process_lines(this colorlink::logic::ColorLinkLogic&, std::string)’:
../include/colorlink/macros.hpp:89:21: error: could not convert ‘std::nullopt’ from ‘const std::nullopt_t’ to ‘std::expected<void, colorlink::error::ColorLinkError>’
89 | return std::nullopt; \
| ~~~~~^~~~~~~
| |
| const std::nullopt_t
So if you really think about it, we need some sort of wrapper type with an implicit conversion operator so that when our parent function receives the type back, it can convert it to the correct value.
We shall store the error in a template:
Storage value;
It will be inferred with a similar technique:
auto&& __maybe_error{::__get_error(__result)};
using __maybe_error_type = std::decay_t<decltype(__maybe_error)>;
C++11 was the first to explicitly allow templated conversion operators, including implicit ones, so we can make our operators generic:
template <typename T>
operator std::optional<T>() const {
return std::nullopt;
}
template <typename T, typename E>
operator std::expected<T, E>() const {
/* ... */
}
That’s the meat of our solution. The rest is simple, with the only ceveat being that we treat nullptr as a special input for default-constructing the error, if it does not exist; this is used when we are converting optional error to an expected.
template <typename Storage>
struct __maybe_failure_proxy {
private:
using Self = __maybe_failure_proxy;
public:
Storage value;
template <typename T>
operator std::optional<T>([[maybe_unused]] this const Self& _) {
return std::nullopt;
}
template <typename T, typename E>
operator std::expected<T, E>(this Self&& self) {
using value_type = std::decay_t<Storage>;
if constexpr (std::is_same_v<value_type, std::nullptr_t>) {
static_assert(
std::default_initializable<E>,
"E must be default-initializable to convert from optional failure."
);
return std::unexpected(E {});
} else {
return std::unexpected<E>(std::forward<value_type>(self.value));
}
}
};
So our code compiles this time, using the new type:
#define maybe_1(expr) \
({ \
auto&& __result{(expr)}; \
if (!__result) { \
auto&& __maybe_error = ::__get_error(__result); \
using __maybe_error_type = std::decay_t<decltype(__maybe_error)>; \
return ::__maybe_failure_proxy<__maybe_error_type>(std::move(__maybe_error)); \
} \
*std::move(__result); \
})
Also, I greatly simplified the updated code with the use of deduction guides, part of C++17’s CTAD:
template <typename T>
__maybe_failure_proxy(T&&) -> __maybe_failure_proxy<std::decay_t<T>>;
#define maybe_1(expr) \
({ \
auto&& __result{(expr)}; \
if (!__result) { \
auto&& __maybe_error = ::__get_error(__result); \
return ::__maybe_failure_proxy{::__get_error(__result)}; \
} \
*std::move(__result); \
})
#define maybe_2(expr, fallback) \
({ \
auto&& __result{(expr)}; \
if (!__result) { \
return ::__maybe_failure_proxy{::__get_error(__result)}; \
} \
*std::move(__result); \
})
So now in my application I can write code like in the following form:
auto ColorLinkLogic::get_errors(
[[maybe_unused]] this const Self& self,
const std::string_view lines
) -> std::expected<std::vector<std::string>, error::ColorLinkError>;
auto ColorLinkLogic::process_lines(
this Self& self,
std::string lines
) -> std::expected<void, error::ColorLinkError> {
auto errors {maybe(self.get_errors(lines))};
/* ... */
}
I think you could possibly write it with overloaded functions instead of a proxy type, but then you would need to repeat the Storage template for many functions; it becomes especially annoying if you want to extend this utility to convert to your own error types.
Lambda support
Instead of taking the __maybe_error for granted as a magic variable, some people may prefer a lambda.
I would argue that this isn’t strictly better in this case, because a lambda is much more verbose for a commonly used primitive like this; we don’t need to worry about conflicts of the magic variable’s name in most cases, and if we keep it short (e.g. trimming it to __e from __maybe_error), then we have saved ourselves quite a bit of syntax for a primitive, because some clang-format setups dictate that all lambdas have an explicit return type, even if that type is auto.
Regardless, it can be implemented relatively easily:
#define maybe_2(expr, fallback) \
({ \
auto &&__result{(expr)}; \
if (!__result) { \
[[maybe_unused]] auto &&__e {::__get_error(__result)}; \
return ::__maybe_failure_proxy{::__get_result(fallback, __e)}; \
} \
*std::move(__result); \
})
The new function __get_result simply checks if it is a callable:
template <typename F, typename T>
auto __get_result(F &&fallback, T &&error) -> decltype(auto) {
if constexpr (std::invocable<F, T>) {
return std::forward<F>(fallback)(std::forward<T>(error));
} else {
return std::forward<F>(fallback);
}
}
You can use the lambda variant in more complex cases, and you can define the lambda externally for more complex error handling that requires reusability.
Important edge cases
The more complex you make something, the merrier the room for failure.
Adding std::unexpected if missing
To support the last implicit example, we define the metafunction:
template <typename T>
struct is_expected : std::false_type {};
template <typename E>
struct is_expected<std::unexpected<E>> : std::true_type {};
template <typename T, typename E>
struct is_expected<std::expected<T, E>> : std::true_type {};
If you aren’t familiar with metafunctions, they are essentially small programs that run during compilation. They weren’t an explicit feature of the C++ standard initially (until type traits basically made them standardized) but a happy consequence of other features; the same goes for a popular but mostly obsolete trick you may have heard about named SFINAE. I wrote more about metafunctions here.
We use these in another compile-time branch:
template <typename T, typename E>
operator std::expected<T, E>(this Self&& self) {
using value_type = std::decay_t<Storage>;
if constexpr (std::is_same_v<value_type, std::nullptr_t>) {
static_assert(
std::default_initializable<E>,
"E must be default-initializable to convert from optional failure."
);
return std::unexpected(E {});
} else if constexpr (__is_expected<value_type>::value) {
return std::forward<value_type>(self.value);
} else {
return std::unexpected<E>(std::forward<value_type>(self.value));
}
}
Avoiding dereference of void
Notice how we are dereferencing __result, and that is ill-formed if the return type T is void. (Recall that we expend __result to be an optional or an expected.) We fix this by adding yet another compile-time check to the return before doing so. So we have to update both macros.
C++ STL provides us the unary type trait std::is_void for this.
#define maybe_1(expr) \
({ \
auto&& __result {(expr)}; \
if (!__result) { \
return ::__maybe_failure_proxy{::__get_error(__result)}; \
} \
using __container_type = std::decay_t<decltype(__result)>; \
if constexpr (std::is_void_v<__container_type::value_type>) { \
(void)0; \
} else { \
*std::move(__result); \
} \
})
#define maybe_2(expr, fallback) \
({ \
auto&& __result {(expr)}; \
if (!__result) { \
[[maybe_unused]] auto&& __e {::__get_error(__result)}; \
return ::__maybe_failure_proxy{::__get_result(fallback, __e)}; \
} \
using __container_type = std::decay_t<decltype(__result)>; \
if constexpr (std::is_void_v<__container_type::value_type>) { \
(void)0; \
} else { \
*std::move(__result); \
} \
})
Haha, kidding!
This doesn’t work because in C++ if is a statement, not an expression, and more importantly, the statements in our conditional blocks are NOT the last statements.
Instead, we define another helper function that returns the proper deduced type:
template <typename T>
__maybe_failure_proxy(T &&) -> __maybe_failure_proxy<std::decay_t<T>>;
template <typename T> auto __deref_or_void(T &&container) -> decltype(auto) {
using value_type = typename std::decay_t<T>::value_type;
if constexpr (std::is_void_v<value_type>) {
return;
} else {
return *std::forward<T>(container);
}
}
From there, it’s plug-and-play:
#define maybe_1(expr) \
({ \
auto &&__result{(expr)}; \
if (!__result) { \
return ::__maybe_failure_proxy{::__get_error(__result)}; \
} \
::__deref_or_void(std::move(__result)); \
})
#define maybe_2(expr, fallback) \
({ \
auto &&__result{(expr)}; \
if (!__result) { \
[[maybe_unused]] auto &&__e {::__get_error(__result)}; \
return ::__maybe_failure_proxy{__get_result(fallback, __e)}; \
} \
::__deref_or_void(std::move(__result)); \
})
By the way, I was doing some research and I found that there is an old GNU extension for C specifically called __builtin_choose_expr, which works like this:
__builtin_choose_expr(
std::is_void_v<__val_t>,
(void)0,
*std::move(__result);
);
This would also allow us to achieve the same thing for constant expressions. It seems like it’s not needed for C++ as it does not support it, but it’s a cool historical artifact.
Nested uses of maybe expressions
This already works because statement expressions automatically resolve any scoping issues we would have with macros, so you can do things like:
maybe(maybe(foo()));
Some conveniences
In my actual library, I have maybe_twice and maybe_thrice to make the nesting cleaner:
maybe_twice(foo()); // equivalent to maybe(maybe(foo()));
maybe_thrice(foo()); // equivalent to maybe(maybe(maybe(foo())));
Also, I made it detect nullary functors so that I don’t have to use parentheses:
maybe_twice(foo);
(These changes will be pushed to my repository soon.)
I do kind of wish that we would be able to define our own operators in C++ so that I can have something like an ! operator to invoke the basic maybe overload (e.g. foo()!!), or at least be able to use non-ASCII names for macros (e.g. !(!(foo()))), but it is what it is. You would get the added benefit of your code screaming at you, which is something that could prove useful from time to time.
The Turing Test
We now support a variety of things. Here are the primary examples:
auto foo() -> std::expected<void, int> { return {}; }
auto bar() -> std::expected<int, int> {
maybe(foo()); // minimal
maybe(foo(), std::unexpected(__e)); // explicit version of above
maybe(foo(), std::unexpected(__e + 1)); // our own
maybe(foo(), __e + 1); // implicit std::unexpected() wrapper
maybe(foo(), [](auto&& e) -> auto { return e + 1; }); // lambda variant
return {};
}
Handy tips
If you want to use this solution, here are my personal guidelines:
- If you want to return a simple error, use the
__emacro. - If you observe that you repeatedly use the same error in
maybe, define a local lambda and pass it via the second argument’s overload. - If you need more complex error transformations, do them before the call to
maybeand store the error for reuse; you can also define a namespace function if you have an error pattern repeating across several modules.
Full code
Since my website currently has bad formatting for macros, pasting the entire thing would be horror, so you can find the code and star my GitHub repository to track its progress here.
Additional notes & remarks
Following my implementation, I noticed that another project, cppmatch, from last year, implemented this by using the same compiler extension, so I wanted to give a props to them as well. Nevertheless, I made this blog post since mine works a bit differently and adds some additional features that I wanted to make a proper writeup about, like the binary overload.