C++ Error Handling with Style: The Benefits of optional and expected

C++ Error Handling with Style: The Benefits of optional and expected

Error handling is an essential part of software development, and the C++ has evolved over time to provide various mechanisms for handling errors and exceptions. In this blog post, we will explore the history of error handling in C++ and how new std::optional and std::expected classes are useful for it.

Untitled.png

Error Handling in C++

One of the earliest approaches, which was used in the C programming language, was the use of a global error variable called errno to indicate the success or failure of a function. This approach has several drawbacks, including the potential for errno to be overwritten by another function, and the difficulty of providing detailed information about the error. Therefore, using errno is generally not considered a good idea in C++.

Another traditional approach to error handling is to use the return statement to indicate the success or failure of a function. Functions would typically return a special sentinel value (such as -1 or nullptr) to indicate an error, and the caller would be responsible for checking the return value and handling the error appropriately. One of the drawbacks of using the return statement to indicate the success or failure of a function is that it can be easy for errors to be overlooked or mishandled. This is because the caller of a function must check the return value and interpret the meaning of the error code, which can be difficult to do consistently across a large codebase. In addition, using sentinel values such as -1 to indicate an error can be difficult when the function is expected to return a range of valid values that includes error codes.

Another approach that requires mentioning is exceptions. But exceptions also have their own drawbacks, including the overhead of creating and propagating the exception object, and the potential for unexpected behavior if the exception is not caught. This can be a concern for performance-critical applications. In addition, exceptions can be difficult to debug, because the point where the exception is thrown may not be the same as the point where it is caught.

One of the common drawbacks of the traditional error-handling techniques in C++, such as returning error codes or using exceptions, is that they do not explicitly indicate which functions might throw or return an error. This means that it is the responsibility of the user of the function to be aware of the possible errors and handle them appropriately, which can be difficult and error-prone.

Optional and Expected

Untitled 1.png

std::optional and std::expected are two new classes introduced in C++17 and C++23 respectively and they provide a more expressive way of handling errors and exceptional conditions. These classes can be used as alternatives to returning error codes or using exceptions, and provide a safer and more intuitive way of representing and handling errors in C++.

std::optional wraps a variable and its existence information, so that it can represent the possibility that a function may not return a value. std::expected is similar but it allows you to return an error object from a function. For example, a function that returns an std::expected<int, std::string> can return either a normal int value, or an error object of type std::string, which can be used to provide detailed information about the error.

In the example below, the generatePost function takes a title as input, and returns a std::optional<BlogPost>. If the title is empty, then the function returns an empty std::optional, using the {}initializer or the std::nullopt value. Otherwise, the function magically creates a post and returns it as std::optional<BlogPost>.

std::optional<BlogPost> generatePost(Title  title){
    if (title.empty()){
        return {}; // OR std::nullopt
    }
    BlogPost post = doAIStuff(title);
    return post;
}

Accessing the value of the optional or excepted object when it doesn’t hold a value is an undefined behavior. Therefore, it is important to check if the optional contains a value before accessing it, using the has_value() function or the bool () operator. In the example, generatePost function is used and its result is stored in a std::optional<BlogPost> object. If it contains a value the post is published, otherwise an error message is printed.

auto myNewBlogPost = generatePost(fancy_title);

if (myNewBlogPost.has_value()){
    publish(myNewBlogPost.value);
} else{
    std::cout << "Even AI cannot generate a post without a title!\n";
}

In the case where a function does not return a value, it does not necessarily have to be considered an error. The program can continue as usual using a default value. In such cases, the value_or function can be useful. This function checks if a value exists in the optional object and returns that value. If no value exists, it returns the value that was passed to the function. So in the example, if we have a default blog post, we can use it as follows.

// Instead of
if (myNewBlogPost.has_value()){
    publish(myNewBlogPost.value);
} else{
    std::cout << "Title is empty! Publishing a Lorem Ipsum Text.\n";
    publish(lorem_ipsum_post);
}

// In one line
publish(myNewBlogPost.value_or(lorem_ipsum_post));

Consider the case where the generatePost function receives an additional input specifying a minimum fitness score for the generated post and does not return posts with a lower score. Using std::expected would be a better option for this scenario because it can provide the user with more information about why a value was not returned, allowing for a more effective handling of the issue.

std::expected<BlogPost, std::string> generatePost(Title title, FitnessScore fitness_limit){
    if (title.empty()){
        return std::make_unexpected{"Even AI cannot generate a post without a title!"};
    }

    BlogPostwithFitness result = doAIStuff(title);
    if (result.fitness < fitness_limit){
        return std::make_unexpected{"Unfortunately our AI is not at that level yet!"};
    }

    return result.post;
}

auto myNewBlogPost = generatePost(fancy_title);

if (myNewBlogPost.has_value()){
    publish(myNewBlogPost.value);
} else{
    std::cout << myNewBlogPost.error();
}

Monadic Functions

The upcoming release of C++23 will include monadic function support for std::optional, also there is a proposal for std::expected. These updates will introduce new member functions called transform, and_then, and or_else, which allow for the chaining of optional functions. This can result in cleaner and more concise code.

The transform function is used to apply a function to change the value (and possibly the type) stored in an optional. It takes a function as input, applies it to the value stored in the optional, and returns the result wrapped in an optional. If there is no stored value, then it returns an empty optional.

The and_then function is used to compose functions that return std::optional. It takes a function as input, applies it to the value stored in the optional if it is present, and returns the result. If the optional is empty, then the entire expression evaluates to an empty optional.

The or_else function returns the optional if it has a value, otherwise it calls a given function and returns the result. This allows for the handling of empty optionals in a functional manner.

In the example below, you can see how chaining monadic functions simplify the code with if-else blocks.

/// Without Monadic Functions

auto valid = checkTitle(title);

if (valid){
    post = generatePost(title);
} else{
    reportFailure();
    return;
}

if (post.has_value()){
    publish(result);
} else {
    reportFailure();
}

/// With Monadic Functions

checkTitle(title)
    .and_then(generatePost)
    .transform(publish)
    .or_else(reportFailure);

Conclusion

In conclusion, std::optional and std::expected are useful tools for error handling in Modern C++. std::optional allows for the representation of missing values, while std::expected allows for the inclusion of error information in the returned value. These types can greatly improve the clarity and reliability of C++ code. Although they are available with C++17 and C++23 respectively, you can check these repositories to use optional and expected with the earlier C++ versions.

Finally, if you have both optional and excepted available I would suggest:

  • Function may or may not return a value and that is expected → Use optional

  • Function is expected to return a value but if it fails you don’t care the reason or there is a single reason for that → Use optional

  • Function is expected to return a value but might fail with several reasons, and you have different handling mechanisms for different error cases → Use expected