The Definitive Guide to std::expected in C++

std::expected is a powerful feature introduced in C++23 that offers a modern, type-safe alternative to traditional error-handling methods.

Throughout my time as a developer, error handling has been a critical aspect of writing robust and maintainable code. Best practices for error handling have evolved, and these can vary from language to language.

Traditionally, C++ developers have relied on mechanisms like return codes and exceptions to manage errors. While these methods have their merits, they also come with their own set of challenges, such as handling exceptions in safety-critical applications or ensuring error information is adequately propagated with return codes.

std::expected allows developers to represent a value or an error in a single object, simplifying the handling of success and failure scenarios in a clean and readable way.

This utility type improves code clarity and maintainability. It also aligns with modern C++ practices by promoting explicit error handling and reducing the need for exceptions in situations where they might be overkill.

Why Modern C++ Developers Need to Embrace std::expected

The inclusion of std::expected in the C++23 standard library addresses several pain points associated with exceptions and return codes. It offers a more versatile and expressive solution. Here’s why std::expected is a game-changer for C++ developers:

  • Type-Safe Error Handling: std::expected enforces type safety by clearly distinguishing between successful results and error states, reducing the risk of runtime errors.
  • Enhanced Code Readability: With std::expected, the intent of error handling is explicitly represented in the code, making it easier to understand and maintain.
  • Improved Performance: Unlike exceptions, which can incur significant overhead, std::expected provides a more lightweight alternative suitable for performance-sensitive applications.
  • Greater Flexibility: std::expected can be seamlessly integrated with existing codebases, offering a gradual path to modernizing error handling without a complete rewrite.

Whether you are developing high-performance applications, writing robust libraries, or maintaining legacy code, understanding and utilizing std::expected can lead to more reliable and maintainable codebases.

What is std::expected?

Definition and Purpose

std::expected is a new addition to the C++23 standard library, designed to provide a type-safe way to represent the outcome of operations that may succeed or fail. Unlike traditional error-handling techniques such as exceptions or error codes, std::expected encapsulates either a valid result or an error within a single object. This duality allows developers to handle errors explicitly and concisely, promoting more readable and maintainable code.

In essence, std::expected<T, E> is a template class that represents either an expected value of type T or an unexpected error of type E. The introduction of std::expected addresses the need for a more modern and expressive error-handling approach in C++, making it easier to write robust software without the complexities and overhead associated with exceptions.

std::expected Motivation and Advantages

The primary motivation behind std::expected is to simplify error handling by:

  • Promoting Type Safety: Ensuring that errors are handled explicitly, preventing unchecked errors that could lead to undefined behavior.
  • Enhancing Code Readability: Clear and straightforward error-handling logic improves the overall maintainability of the code.
  • Reducing Overhead: Offering a lightweight alternative to exceptions, which can be particularly beneficial in performance-critical applications.

By using std::expected, developers can avoid the pitfalls of traditional error-handling methods and adopt a more consistent and predictable approach to managing success and failure in their applications.

Key Features of std::expected

  • Type-Safe Representation: Differentiates between success and error states using explicit types.
  • Unified Error Handling: Combines success and error handling into a single, cohesive mechanism.
  • Lightweight and Efficient: Offers a performance advantage over exceptions by avoiding the overhead of stack unwinding.
  • Flexible and Extensible: Easily integrates with existing codebases and supports a wide range of use cases.

Code Example: Using std::expected

Let’s dive into a practical example to see how std::expected works and compare it to traditional error-handling methods.

Example: Division Function with std::expected

std::expected<double, std::string> divide(double numerator, double denominator) {
    if (denominator == 0.0) {
        return std::unexpected("Error: Division by zero");
    }
    return numerator / denominator;
}

In this example, the divide function uses std::expected<int, std::string> to represent either a valid division result or an error message. The function returns a valid result if the divisor is not zero; otherwise, it returns an error message. This approach makes the error handling explicit and straightforward, enhancing the code’s readability and maintainability.

Comparison of std::expected with Alternative Error Handling Methods

Comparison of std::expected with Return Codes

Traditionally, functions might return a status code to indicate success or failure, often requiring separate mechanisms to convey error details.

// Using std::expected
std::expected<double, std::string> divide_expected(double numerator, double denominator) {
    if (denominator == 0.0) {
        return std::unexpected("Error: Division by zero");
    }
    return numerator / denominator;
}

// Using return codes
bool divide_return_code(double numerator, double denominator, double& result, std::string& error) {
    if (denominator == 0.0) {
        error = "Error: Division by zero";
        return false;
    }
    result = numerator / denominator;
    return true;
}

While this method works, it has several drawbacks:

  • Lack of Type Safety: The status code and error message are separate, which can lead to inconsistent handling and increased potential for errors.
  • Reduced Readability: The logic for error handling is scattered, making the code harder to read and maintain.

std::expected addresses these issues by providing a unified and type-safe approach to handle both the result and the error.

Comparison of std::expected with Exceptions

Another common approach is to use exceptions for error handling.

// Using std::expected
std::expected<double, std::string> divide_expected(double numerator, double denominator) {
    if (denominator == 0.0) {
        return std::unexpected("Error: Division by zero");
    }
    return numerator / denominator;
}

// Using exceptions
double divide_exception(double numerator, double denominator) {
    if (denominator == 0.0) {
        throw std::runtime_error("Error: Division by zero");
    }
    return numerator / denominator;
}

While exceptions provide a robust mechanism for error handling, they have their own set of challenges:

  • Performance Overhead: Exceptions can introduce significant overhead, especially in performance-critical applications, due to stack unwinding and exception handling costs.
  • Complexity: Handling exceptions can lead to more complex code, making it harder to follow the error-handling logic.

std::expected offers a more lightweight and straightforward alternative, making error handling explicit and reducing the need for complex exception handling mechanisms.

std::expected provides a modern, type-safe approach to error handling in C++, addressing the shortcomings of traditional methods like return codes and exceptions. By encapsulating both success and error states within a single object, std::expected enhances code readability, maintainability, and performance, making it an essential tool for modern C++ development.

Basic Usage of std::expected

Creating a std::expected Object

Using std::expected starts with creating an object that can hold either a value or an error. The type std::expected<T, E> represents an expected value of type T or an error of type E. Here’s a simple example to illustrate the basic usage.

Example: Creating std::expected Objects

// Function that performs division and uses std::expected to handle errors
std::expected<int, std::string> safe_divide(int numerator, int denominator) {
  if (denominator == 0) {
    return std::unexpected("Error: Division by zero");
  }

  return numerator / denominator;
}

In this example, safe_divide is a function that takes two integers and returns an std::expected<int, std::string>. If the denominator is zero, the function returns an error message using std::unexpected; otherwise, it returns the result of the division.

Checking for Success or Error

Once you have an std::expected object, you need to check whether it contains a valid value or an error. The has_value() method lets you determine if the operation was successful, and the value() and error() methods allow you to access the contained value or error, respectively.

Example: Checking and Accessing std::expected

#include <iostream>
#include <expected>
#include <string>

// Function that performs division and uses std::expected to handle errors
std::expected<int, std::string> safe_divide(int numerator, int denominator) {
    if (denominator == 0) {
        return std::unexpected("Error: Division by zero");
    }
    return numerator / denominator;
}

int main() {
  	// Example: Checking and Accessing std::expected
    auto result = safe_divide(10, 2);
  
    // Check
    if (xresult.has_value()) {
        std::cout << "Result: " << result.value() << '\n';
    } else {
        std::cout << result.error() << '\n';
    }

  	auto errorResult = safe_divide(10, 0);
  
  	// Check
    if (errorResult.has_value()) {
        std::cout << "Result: " << errorResult.value() << '\n';
    } else {
        std::cout << errorResult.error() << '\n';
    }

    return 0;
}

In this code, result and errorResult are std::expected<int, std::string> objects. The has_value() method checks if the division was successful, and value() or error() provides access to the result or error message.

Transforming and Accessing Results with std::expected

std::expected provides useful methods for transforming and accessing its contents in a safe and expressive way. These methods include and_then for chaining operations and transform for modifying values.

Example: Using and_then and transform

#include <iostream>
#include <expected>
#include <string>
#include <cmath>

// Function that performs division and uses std::expected to handle errors
std::expected<int, std::string> safe_divide(int numerator, int denominator) {
    if (denominator == 0) {
        return std::unexpected("Error: Division by zero");
    }
    
    return numerator / denominator;
}

// Function to square a number
int square(int value) {
    return value * value;
}

// Function that uses and_then to chain an operation that squares the result of a successful division
std::expected<int, std::string> square_if_success(int numerator, int denominator) {
    return safe_divide(numerator, denominator).and_then([](int value) {
        return std::expected<int, std::string>(square(value));
    });
}

int main() {
    // Example: Using and_then and transform
    auto result = square_if_success(10, 2);
  
  	// Check
    if (result.has_value()) {
        std::cout << "Squared Result: " << result.value() << '\n';
    } 
    else {
        std::cout << result.error() << '\n';
    }

    auto errorResult = square_if_success(10, 0);
   	
    // Check
    if (errorResult.has_value()) {
        std::cout << "Squared Result: " << errorResult.value() << '\n';
    } 
    else {
        std::cout << errorResult.error() << '\n';
    }

    // Using transform to apply a transformation to the value if it exists
    auto transformedResult = safe_divide(10, 2).transform([](int value) {
        return value + 1;
    });

    // Check
    if (transformedResult.has_value()) {
        std::cout << "Transformed Result: " << transformedResult.value() << '\n';
    } 
    else {
        std::cout << transformedResult.error() << '\n';
    }

    return 0;
}

In this code, the square_if_success function uses and_then to chain an operation that squares the result of a successful division. The transform method could similarly be used to apply a transformation to the value if it exists.

std::expected offers a powerful and type-safe way to handle errors in C++. By using methods like has_value(), value(), error(), and_then, and transform, you can handle success and error states more gracefully, leading to cleaner and more maintainable code. This basic usage section covers how to create and work with std::expected objects, check for success or errors, access values safely, and handle errors effectively, providing a solid foundation for more advanced usage scenarios.

Advanced Use Cases for std::expected

Chaining Operations with std::expected

One of the powerful features of std::expected is its ability to chain operations in a monadic style, allowing for clean and concise error propagation and handling. This is achieved through the use of the and_then and or_else methods.

  • and_then: This method is used to chain operations that are only executed if the expected contains a valid value. It helps propagate success values through a series of operations.
  • or_else: This method is used to provide alternative operations or error-handling paths when an expected contains an error.

Example: Chaining with and_then

Consider a scenario where you want to perform a series of transformations on a number, each step depending on the success of the previous one.

#include <iostream>
#include <expected>
#include <string>

// Function that performs division and uses std::expected to handle errors
std::expected<int, std::string> safe_divide(int numerator, int denominator) {
    if (denominator == 0) {
        return std::unexpected("Error: Division by zero");
    }
    return numerator / denominator;
}

// Function that adds 5 to a number
std::expected<int, std::string> add_five(int value) {
    return value + 5;
}

// Function that squares a number
std::expected<int, std::string> square(int value) {
    return value * value;
}

int main() {
    // Example: Chaining with and_then
    auto result = safe_divide(20, 2)
        .and_then(add_five)
        .and_then(square);

    // Check
    if (result.has_value()) {
        std::cout << "Final Result: " << result.value() << '\n';
    } else {
        std::cout << result.error() << '\n';
    }

    // Example: Using or_else to handle errors
    auto errorResult = safe_divide(20, 0)
        .and_then(add_five)
        .and_then(square)
        .or_else([](std::string error) {
            std::cerr << "Error occurred: " << error << '\n';
            return std::expected<int, std::string>(0); // Providing a default value
        });

    // Check
    if (errorResult.has_value()) {
        std::cout << "Handled Result: " << errorResult.value() << '\n';
    } else {
        std::cout << errorResult.error() << '\n';
    }

    return 0;
}

In this example, the operations are chained together using and_then, and each step only executes if the previous step is successful. If any operation fails, the chain breaks, and the error is propagated.

Transforming Values and Errors

std::expected allows for the transformation of both the value and the error using the transform and transform_error methods, respectively.

  • transform: This method applies a transformation to the value if it is present, leaving the error unchanged.
  • transform_error: This method applies a transformation to the error if it is present, leaving the value unchanged.

Example: Using transform and transform_error

#include <iostream>
#include <expected>
#include <string>

// Function that performs division and uses std::expected to handle errors
std::expected<int, std::string> safe_divide(int numerator, int denominator) {
    if (denominator == 0) {
        return std::unexpected("Error: Division by zero");
    }
    return numerator / denominator;
}

int main() {
    // Example: Using transform and transform_error
    auto result = safe_divide(10, 2)
        .transform([](int value) {
            return value * value; // Square the result if it is present
        });

    // Check
    if (result.has_value()) {
        std::cout << "Transformed Result: " << result.value() << '\n';
    } else {
        std::cout << result.error() << '\n';
    }

    auto errorResult = safe_divide(10, 0)
        .transform_error([](const std::string& error) {
            return error + " - Please provide a non-zero denominator."; // Add details to the error message
        });

    // Check
    if (errorResult.has_value()) {
        std::cout << "Result: " << errorResult.value() << '\n';
    } else {
        std::cout << errorResult.error() << '\n';
    }

    return 0;
}

In this example, transform is used to square the result of a division, and transform_error is used to add additional details to an error message if an error occurs.

Advanced Techniques with std::expected

Example: Using std::expected for Error Aggregation

std::expected can also be used to aggregate errors from multiple operations and provide a consolidated error report.

#include <iostream>
#include <expected>
#include <vector>
#include <string>

// Function to validate a single input
std::expected<void, std::string> validate_input(int input) {
    if (input < 0) {
        return std::unexpected("Error: Negative value not allowed");
    }
  
    if (input > 100) {
        return std::unexpected("Error: Value exceeds maximum limit");
    }
  
    return {};
}

// Function to validate multiple inputs and aggregate errors
std::expected<void, std::vector<std::string>> validate_inputs(const std::vector<int>& inputs) {
    std::vector<std::string> errors;
    for (const auto& input : inputs) {
        auto result = validate_input(input);
        if (!result) {
            errors.push_back(result.error());
        }
    }
  
    // Check
    if (!errors.empty()) {
        return std::unexpected(errors);
    }
  
    return {};
}

int main() {
    std::vector<int> inputs = {10, -5, 150, 20};

    // Example: Using std::expected for Error Aggregation
    auto result = validate_inputs(inputs);

    // Check
    if (result.has_value()) {
        std::cout << "All inputs are valid.\n";
    } else {
        std::cout << "Errors:\n";
        for (const auto& error : result.error()) {
            std::cout << "- " << error << '\n';
        }
    }

    return 0;
}

In this example, the validate_inputs function aggregates errors into a vector and returns them as a single std::expected object. This allows for comprehensive error reporting in a clean and concise manner.

std::expected provides a versatile and powerful toolset for advanced error handling in C++. By leveraging methods like and_then, transform, and transform_error, developers can create clean, concise, and robust code. The ability to integrate with legacy code and the potential performance benefits make std::expected an essential tool for modern C++ development. Advanced techniques such as error aggregation further expand its utility, demonstrating flexibility and effectiveness in various scenarios.

Additional Resources

To further explore std::expected and modern C++ error handling, check out these additional resources:


Discover more from John Farrier

Subscribe to get the latest posts sent to your email.

2 thoughts on “The Definitive Guide to std::expected in C++

  1. Nice. We followed this pattern back in 2017 I think and it was very helpful in cleaning up code paths.
    I think boost back then had it but we tried to avoid boost and rolled our own (fairly trivial). Glad to see it. made it into the standard library.

Leave a Reply

Discover more from John Farrier

Subscribe now to keep reading and get access to the full archive.

Continue reading