C++17 introduced std::optional which has many different usage for different purposes. But one of the most useful area to use it is in API design. It can be used to resolve a typical problem that arises in situations that we will look in a minute. The idea is to be able to return different type of data from a function at different stages of a program depending on the specific requirement like error conditions and so on.
Let’s say you are writing a function that searches an std::map for a key and if the key is found the value associated with the key is returned to the caller. Let’s make it a bit more specific. Let’s say you have designed a class PersonDir (Person Directory) containing an std::unordered_map to hold people’s name and their respective ages. Now, you want to write a method that takes a key (name) to the map and returns the corresponding value (age) that is associated with it. So we use the find() method of the std:: container and look for the key in the container. A possible implementation may look like the following:unordered_map
class PersonDir{ public: int lookup(const std::string &key) const noexcept { auto itr = my_store.find(key); if (itr != my_store.end()) { return itr->second; } } private: std::unordered_map<std::string, int> my_store{ { "John", 49 }, { "Mike", 33 } };};
You will notice that the lookup method checks the return value of the find() function and if the find() has returned a valid iterator (meaning the key was found) then the lookup function returns the value (age) of the person (key).
However, if the key was not found in the person database then the method has nothing to return. However, if your code doesn’t return anything on failing to find the key in the map, the compiler will flag this as a warning and if you are using -Werror flag (to stop compilation if there is a warning) then you won’t be able to compile this code. The only other solution in this kind of scenario is to return something like -1 in case of failure. But that is messy and not very elegant.
std::optional
C++17 provides std::optional which can be a solution to situation like this. What it enables you to do is to declare the optional return type of the method (e.g. lookup in our case) as std::optional and in the case of a failure (e.g. key not present in the database) you can return std::nullopt. You will declare the return type of the function as std::optional<T> where T will represent the data type of your return value.
<iostream> <optional> <string> <unordered_map>class PersonDir{ public: std::optional<int> lookup(const std::string &key) const noexcept { auto itr = my_store.find(key); if (itr != my_store.end()) { return itr->second; } return std::nullopt; } private: std::unordered_map<std::string, int> my_store{ { "John", 49 }, { "Mike", 33 } };};int main(){ PersonDir dir; auto result = dir.lookup("Pratik"); if (result.has_value()) { std::cout << "Key was found in the database and the value is " << *result << "\n"; } else { std::cout << "Key wasn't found in the database\n"; } return 0;}
~/std_expected_sample$ g++ --std=c++17 -Wall -Werror -Wextra std_optional_sample.cpp -o std_optional_sample ~/std_expected_sample$ ./std_optional_sample Key wasn't found in the database
Notice that the earlier problem of having to return something like -1 is no more there. We can safely return std::nullopt instead. The caller can check the validity of the return value (notice in main function) with has_value() method.
However, with std::optional what you cannot do is to return something more meaningful like an error code or an error string to indicate the nature of the error.
std::expected for custom error code
With the C++23 feature like std::expected you can now customise your error code and do not have to be dependent on the std::nullopt anymore. You can declare the return type of your function with two function template parameters: std::expected<T, E> where T is the data type of your actual return type and E is the data type of your error types.
<expected> <iostream> <string> <unordered_map>class PersonDir{ public: [[nodiscard]] std::expected<int, std::string> lookup(const std::string &key) const noexcept { auto itr = my_store.find(key); if (itr != my_store.end()) { return itr->second; } return std::unexpected(std::string{ "Key Not Found" }); } private: std::unordered_map<std::string, int> my_store{ { "John", 49 }, { "Mike", 33 } };};int main(){ PersonDir dir; auto result = dir.lookup("Pratik"); if (result.has_value()) { std::cout << "Key was found in the database and the value is " << *result << "\n"; } else { std::cout << result.error() << "\n"; } return 0;}
As you can see with std::expected we can customise our error code and be more specific what exactly went wrong by returning a specific error string “Key Not Found.”
But that still feels bit cosmetic and not really a pressing need to use std::expected at least in this scenario.
With std::expected you can achieve more. You can define a custom error type and specify that in the template parameter E as shown earlier and the compiler will allow you to return your custom error code as you may need in the course of your function definition.
This example we just saw is dealing with a single type of error which is the key is not found in the unordered_map. The situation becomes lot more complicated if you have various different error conditions with varying degree of checks in the code and you need to return the appropriate error code in each of those conditions. For example:
- you may like to do input validation and return appropriate error code if the input is not valid to start with
- you may like to check and match further aspects of the key-value pair (for example if it matches with a particular age or lower than a certain age etc) and return appropriate error code if it doesn’t match the criteria you have set
Take these as example and customise as per your requirements. But the point is the real benefit of std::expected can be realised if you have to deal with a lot more error conditions and different error codes needs to be returned on those situations.
<expected> <iostream> <string> <string_view> <unordered_map>enum class ErrorTypes{ KeyNotFound, InvalidInput, AgeDoesNotMatch};class PersonDir{ public: [[nodiscard]] std::expected<int, ErrorTypes> lookup(const std::string &key, int age) const noexcept { if (key.empty()) { return std::unexpected(ErrorTypes::InvalidInput); } auto itr = my_store.find(key); if (itr != my_store.end()) { if (itr->second != age) { return std::unexpected(ErrorTypes::AgeDoesNotMatch); } return itr->second; } return std::unexpected(ErrorTypes::KeyNotFound); } private: std::unordered_map<std::string, int> my_store{ { "John", 49 }, { "Mike", 33 } };};constexpr std::string_view to_string(ErrorTypes error) noexcept{ switch (error) { case ErrorTypes::KeyNotFound: { return "Key not found"; } case ErrorTypes::InvalidInput: { return "Invalid Input"; } case ErrorTypes::AgeDoesNotMatch: { return "Age doesn't match"; } } return "Unknown error";}std::ostream &operator<<(std::ostream &os, ErrorTypes error){ os << to_string(error); return os;}int main(){ PersonDir dir; auto result = dir.lookup("Pratik", 23); if (result.has_value()) { std::cout << "Key was found in the database and the value is " << *result << "\n"; } else { std::cout << result.error() << "\n"; } return 0;}
~/std_expected_sample$ g++-14 -std=c++23 -Wall -Wextra -pedantic std_expected_sample_extended.cpp -o std_expected_sample_extended ~/std_expected_sample$ ./std_expected_sample_extended Key not found
Look at the ErrorTypes and see how it defines the custom set of errors and then returns those error codes from the lookup method. You have now lot more freedom to return the appropriate error code from your defined custom error type. There are some additional works needs to be done to print the human readable versions of the error code but that is out of this discussion.
So if you have access to the C++23 compiler in your project and freedom to use it for your development purposes then use it and see the difference yourself.
get the Sample Codes used in this post
Leave a Reply