C++23's monadic extensions to std::optional
After giving a talk at 040coders in Eindhoven about C++23’s monadic extensions to std::optional, I decided it was a good time to share my thoughts in a blog post.
C++17 introduced std::optional, a type that can either hold a value of a specified type or be empty (i.e., contain no value). With its introduction a few methods were provided:
- has_value() and operator bool() to check if it holds a value.
- value() and operator*() to retrieve the value.
- value_or() to get the value or return a fallback value in case the optional holds no value.
These methods allow for very straight-forward and imperitive programming, as seen in the following example.
std::optional<int> number{ 42 };
if (number.has_value())
std::cout << number.value() << '\n';
else
std::cout << "No number\n";
// Or the equivalent
if (number)
std::cout << *number << '\n';
else
std::cout << "No number\n";
Although straight-forward, there was some demand for monadic functions, allowing a more functional approach to working with std::optional.
In C++23, the following functions were added to std::optional:
- or_else
- and_then
- transform
Let's look at a C++17 example of how code using std::optional might look.
// Get car status from local cache.
std::optional<CarStatus> get_car_status_from_cache(int car_id);
// Get car status from web backend.
std::optional<CarStatus> get_car_status_from_http(int car_id);
int main(int argc, char* argv[]) {
std::optional<CarStatus> car_status = get_car_status_from_cache(1);
if (!car_status.has_value()) {
car_status = get_car_status_through_http(1);
}
std::optional<DiagnosticData> diagnostic_data;
if (car_status.has_value()) {
diagnostic_data = car_status->diagnostic_data;
}
std::optional<std::string> error_msg;
if (diagnostic_data.has_value()) {
error_msg = std::format("Error Code: {}", diagnostic_data->error_code);
}
std::string user_msg;
if (error_msg.has_value()) {
user_msg = *error_msg;
} else {
user_msg = "No Errors Detected";
}
std::cout << user_msg;
}
At first glance it appears that quite a bit is going on in this code. But let's dive into the new monadic functions and see if it really is, or could be written more concise and readable.
or_else
The or_else function allows you to provide an alternative std::optional in case the original one is empty. It takes a callable that generates a new std::optional when needed.
In case of the above example, it can be used for the first part where the car status is fetched from the cache, or, if it is not available, from the web backend.
The following
std::optional<CarStatus> car_status = get_car_status_from_cache(1);
if (!car_status.has_value()) {
car_status = get_car_status_through_http(1);
}
can be rewritten to
auto car_status = get_car_status_from_cache(1)
.or_else([] { return get_car_status_through_http(1); });
and_then
The and_then function is similar to flat_map in functional programming languages. It lets you transform the value of the original std::optional into another std::optional, or return an empty one if the original is empty.
Consider the code from the earlier example
std::optional<DiagnosticData> diagnostic_data;
if (car_status.has_value()) {
diagnostic_data = car_status->diagnostic_data;
}
Using and_then it can be rewritten to
auto diagnostic_data = car_status
.and_then([](const auto& status) { return status.diagnostic_data; });
transform
The transform function is like map in functional languages. It transforms the value inside the std::optional, wrapping the result in a new std::optional. If the original optional was empty, it returns an empty optional.
Consider again the earlier example
std::optional<std::string> error_msg;
if (diagnostic_data.has_value()) {
error_msg = std::format("Error Code: {}", diagnostic_data->error_code);
}
Using transform, this can be rewritten to
auto error_msg = diagnostic_data
.transform([](const auto& diag) { return std::format("Error Code: {}", diag.error_code); });
value_or
value_or already exists since C++17. Nevertheless it deserves some attention, as it nicely fits together with the monadic extensions.
Consider the last part of the earlier example
std::string user_msg;
if (error_msg.has_value()) {
user_msg = *error_msg;
} else {
user_msg = "No Errors Detected";
}
Using value_or, it can be rewritten to
auto user_msg = error_msg
.value_or("No Errors Detected");
Chaining it all together
The real power comes when it all needs to be chained together. Here’s how the entire example could look using the new C++23 monadic functions, resulting in more concise and readable code:
auto user_msg = get_car_status_from_cache(1)
.or_else([] { return get_car_status_through_http(1); })
.and_then([](const auto& status) { return status.diagnostic_data; })
.transform([](const auto& diag) { return std::format("Error Code: {}", diag.error_code); })
.value_or("No Errors Detected");
This chaining approach eliminates multiple if checks, reduces boilerplate, and minimizes the risk of undefined behavior due to unhandled empty states.
Conclusion
C++23’s monadic extensions for std::optional bring several advantages: cleaner syntax, less boilerplate, safer handling of empty states, and more expressive code. I believe these additions can significantly improve how we write C++ and encourage a more functional approach to handling optional values.
I'd love to hear your thoughts on this new feature!