Last Update:
How to Use Monadic Operations for `std::optional` in C++23
Table of Contents
In this post we’ll have a look at new operations added to std::optional
in C++23. These operations, inspired by functional programming concepts, offer a more concise and expressive way to work with optional values, reducing boilerplate and improving code readability.
Let’s meet and_then()
, transform()
and or_else()
, new member functions.
Traditional Approach with if/else
and optional C++20
In C++20 when you work with std::optional
you have to rely heavily on conditional checks to ensure safe access to the contained values. This often led to nested if/else
code blocks, which could make the code verbose and harder to follow.
Consider the task of fetching a user profile. The profile might be available in a cache, or it might need to be fetched from a server. Once retrieved, we want to extract the user’s age and then calculate their age for the next year. Here’s how this might look using the traditional approach:
std::optional<UserProfile> fetchFromCache(int userId);
std::optional<UserProfile> fetchFromServer(int userId);
std::optional<int> extractAge(const UserProfile& profile);
int main() {
const int userId = 12345;
std::optional<int> ageNext;
std::optional<UserProfile> profile = fetchFromCache(userId);
if (!profile)
profile = fetchFromServer(userId);
if (profile) {
std::optional<int> age = extractAge(*profile);
if (age)
ageNext = *age + 1;
}
if (ageNext)
cout << format("Next year, the user will be {} years old", *ageNext);
else
cout << "Failed to determine user's age.\n";
}
As you can see in the example, each step requires its own conditional check. This not only increases the amount of boilerplate code but can also make it challenging to trace the flow of operations, especially in more complex scenarios. The introduction of monadic operations in C++23 offers a more elegant solution to this challenge.
The C++23 Way: Monadic Extensions
Let’s revisit our user profile example using the monadic extensions:
std::optional<UserProfile> fetchFromCache(int userId);
std::optional<UserProfile> fetchFromServer(int userId);
std::optional<int> extractAge(const UserProfile& profile);
int main() {
const int userId = 12345;
const auto ageNext = fetchFromCache(userId)
.or_else([&]() { return fetchFromServer(userId); })
.and_then(extractAge)
.transform([](int age) { return age + 1; });
if (ageNext)
cout << format("Next year, the user will be {} years old", *ageNext);
else
cout << "Failed to determine user's age.\n";
}
In this refactored version, the code’s intent is much clearer.
Here’s a breakdown of the improvements:
- Chaining: The monadic operations allow us to chain calls together, which reduces the need for nested
if/else
checks. Each step in the process is a link in a single, unified chain. - Expressiveness: Functions like
or_else
,and_then
, andtransform
clearly convey their purpose. For instance,or_else
indicates a fallback action if the optional is empty, andand_then
suggests a subsequent operation if the previous one succeeded. - Reduced Boilerplate: The monadic approach reduces the amount of repetitive code. There’s no need to manually check the state of the
std::optional
at each step, as the monadic operations handle this internally. - Improved Error Handling: If any step in the chain fails (i.e., returns an empty
std::optional
), the subsequent operations (custom callables) are skipped, and the final result remains an empty optional. This behavior ensures that errors are propagated gracefully through the chain. ageNext
can be declaredconst
now!
Let’s have a look at the functions in detail.
New Monadic Functions
1. and_then
:
The and_then
operation allows for the chaining of functions that return std::optional
. If the std::optional
on which and_then
is called contains a value, the provided function is invoked with this value. If the function’s invocation is successful, the result is returned; otherwise, an empty std::optional
is returned.
std::optional<int> divide(int a, int b) {
if (b == 0) return std::nullopt;
return a / b;
}
auto result = std::optional<int>{20}.and_then([](int x)
{ return divide(x, 5); }
);
// result contains 4
The and_then()
function expects a callable that takes a value of the type contained within the std::optional
and returns another std::optional
. Here are the requirements for the callable provided to and_then()
:
- Input Type: The callable should accept a single argument. The type of this argument should match the type of the value contained within the
std::optional
on whichand_then()
is called. If thestd::optional
is const-qualified, the callable should accept a const reference; if it’s an rvalue, the callable should accept by value or rvalue reference. - Return Type: The callable should return an
std::optional<U>
, whereU
can be any type. This is a key distinction fromtransform()
, which can return any typeU
, but not anstd::optional<U>
. In contrast,and_then()
specifically expects the callable to return anotherstd::optional
.
For example, consider the function extractAge
from our previous examples:
std::optional<int> extractAge(const UserProfile& profile) {
if (profile.hasValidAge()) {
return profile.getAge();
} else {
return std::nullopt;
}
}
In this case, extractAge
is a valid callable for and_then()
when used on a std::optional<UserProfile>
. It takes a UserProfile
as an argument and returns a std::optional<int>
.
This article started as a preview for Patrons, sometimes even months before the publication. If you want to get extra content, previews, free ebooks and access to our Discord server, join the C++ Stories Premium membership or see more information.
2. transform
:
The transform
operation is similar to and_then
, but with a key difference: the function provided to transform
returns a plain value, not an std::optional
. This value is then wrapped in a new std::optional
. If the original std::optional
is empty, the function isn’t invoked, and an empty std::optional
is returned.
std::optional<int> number = 5;
auto squared = number.transform([](int x) { return x * x; });
// squared contains 25
The transform()
function expects a callable with the following requirements:
- Input Type: The callable should accept a single argument. The type of this argument should match the type of the value contained within the
std::optional
on whichtransform()
is called. Depending on the const-qualification and value category (lvalue/rvalue) of thestd::optional
, the callable might need to accept the argument as a const reference, non-const reference, or rvalue reference. - Return Type: The callable should return a type
U
that:U
must be a non-array object type.U
must not bestd::in_place_t
orstd::nullopt_t
.- The return type should not be another
std::optional
. This is a key distinction fromand_then()
. Whileand_then()
expects the callable to return anstd::optional
,transform()
expects the callable to return a plain value (or a type that can be wrapped inside anstd::optional
).
For example, consider the function userNameToUpper
from our previous examples:
std::string userNameToUpper(const UserProfile& profile) {
std::string name = profile.getUserName();
std::transform(name.begin(), name.end(), name.begin(), ::toupper);
return name;
}
In this case, userNameToUpper
is a valid callable for transform()
when used on a std::optional<UserProfile>
. It takes a UserProfile
as an argument and returns a std::string
. The returned string is directly constructible in the location of the new std::optional<std::string>
.
3. or_else
:
The or_else
operation provides a way to specify a fallback in case the std::optional
is empty. If the std::optional
contains a value, it is returned as-is. If it’s empty, the provided function is invoked, and its result (which should also be an std::optional
) is returned.
Example:
std::optional<int> getFromCache() {
// ... might return a value or std::nullopt
return std::nullopt;
}
std::optional<int> getFromDatabase() {
return 42; // fetched from database
}
auto value = getFromCache().or_else(getFromDatabase);
// value contains 42
The or_else()
function expects a callable which:
- No Input Argument: The callable should not expect any arguments. It’s invoked when the
std::optional
on whichor_else()
is called does not contain a value. - Return Type: The callable should return an
std::optional<T>
, whereT
is the same type as the value type of the originalstd::optional
on whichor_else()
is being called. Essentially, if the originalstd::optional
is empty, the callable provides an alternativestd::optional<T>
value.
For example, consider the following function:
std::optional<UserProfile> defaultProfile() {
return UserProfile("DefaultUser", 0);
}
If you have a std::optional<UserProfile>
that might be empty, you can use or_else()
with defaultProfile
to provide a default value:
std::optional<UserProfile> user = fetchUserProfile();
user = user.or_else(defaultProfile);
In this example, if fetchUserProfile()
returns an empty std::optional
, the defaultProfile
function will be called to provide a default UserProfile
. The result will be a std::optional<UserProfile>
that either contains the fetched user profile or the default profile.
Summary
Throughout this article, we delved into the enhancements brought about by C++23 for std::optional
through the introduction of monadic operations. We began by understanding the traditional approach using if/else
constructs, which, while functional, often led to more verbose and nested code structures. This was especially evident in scenarios where multiple optional values needed to be processed in sequence.
By using these new functions functions, we can write more concise and readable code with the option to chain operations.
But there’s more: in C++23 we also got std::expected
. This wrapper type is especially handy for handling error codes from your functions. It’s similar to optional/variant… and on the start it also has monadic operations. That way you can apply the same patterns for those two types. The new type is, hovewer, a topic for a separate article.
Back to you
- Do you use
std::optional
? - Have you tried monadic extensions?
Share your comments below
I've prepared a valuable bonus for you!
Learn all major features of recent C++ Standards on my Reference Cards!
Check it out here:
Similar Articles:
- C++20 Ranges Algorithms - sorting, sets, other and C++23 updates
- Five Advanced Initialization Techniques in C++: From reserve() to piecewise_construct and More.
- Understanding Ranges Views and View Adaptors Objects in C++20/C++23
- Combining Collections with Zip in C++23 for Efficient Data Processing
- Examples of Projections from C++20 Ranges