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";
}

Run @Compiler Explorer

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";
}

Run @Compiler Explorer

In this refactored version, the code’s intent is much clearer.

Here’s a breakdown of the improvements:

  1. 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.
  2. Expressiveness: Functions like or_else, and_then, and transform clearly convey their purpose. For instance, or_else indicates a fallback action if the optional is empty, and and_then suggests a subsequent operation if the previous one succeeded.
  3. 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.
  4. 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.
  5. ageNext can be declared const 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():

  1. 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 which and_then() is called. If the std::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.
  2. Return Type: The callable should return an std::optional<U>, where U can be any type. This is a key distinction from transform(), which can return any type U, but not an std::optional<U>. In contrast, and_then() specifically expects the callable to return another std::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:

  1. 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 which transform() is called. Depending on the const-qualification and value category (lvalue/rvalue) of the std::optional, the callable might need to accept the argument as a const reference, non-const reference, or rvalue reference.
  2. Return Type: The callable should return a type U that:
    • U must be a non-array object type.
    • U must not be std::in_place_t or std::nullopt_t.
    • The return type should not be another std::optional. This is a key distinction from and_then(). While and_then() expects the callable to return an std::optional, transform() expects the callable to return a plain value (or a type that can be wrapped inside an std::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:

  1. No Input Argument: The callable should not expect any arguments. It’s invoked when the std::optional on which or_else() is called does not contain a value.
  2. Return Type: The callable should return an std::optional<T>, where T is the same type as the value type of the original std::optional on which or_else() is being called. Essentially, if the original std::optional is empty, the callable provides an alternative std::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