Ready to practice?
Sign up to access interactive coding exercises and track your progress.
Representing Optional Values with std::optional
Safely represent values that may or may not exist without using null pointers.
In an earlier lesson, we discussed situations where a function encounters an error it cannot reasonably handle itself. For example, consider a function that calculates and returns a value:
int calculateSquareRoot(int value)
{
return std::sqrt(value);
}
If the caller passes in a semantically invalid value (such as a negative number), this function cannot calculate a meaningful result (as the square root of a negative number is mathematically undefined in real numbers). What should we do in that case? Because functions that calculate results should have no side effects, this function cannot reasonably resolve the error itself. In such cases, the typical approach is to have the function detect the error and pass it back to the caller to deal with in some program-appropriate way.
We previously covered two different ways to have a function return an error to the caller:
- Have a void-returning function return a bool instead (indicating success or failure).
- Have a value-returning function return a sentinel value (a special value that doesn't occur in the set of possible values the function can otherwise return) to indicate an error.
As an example of the latter, the findAverage() function that follows returns value -1.0 (which can never otherwise occur for a valid average) if the user passes in a semantically invalid argument:
#include <iostream>
// The average of values, returns -1.0 if count=0
double findAverage(double sum, int count)
{
if (count == 0) // if count is semantically invalid
return -1.0; // return -1.0 as a sentinel to indicate an error occurred
return sum / count;
}
void testAverage(double sum, int count)
{
double result{ findAverage(sum, count) };
std::cout << "The average of " << sum << " over " << count << " items is ";
if (result != -1.0)
std::cout << result << '\n';
else
std::cout << "undefined\n";
}
int main()
{
testAverage(100.0, 5);
testAverage(50.0, 2);
testAverage(75.0, 0);
return 0;
}
While this is a fairly workable solution, there are several potential downsides:
- The programmer must know which sentinel value the function is using to indicate an error (and this value may differ for each function returning an error using this method).
- A different version of the same function may use a different sentinel value.
- This method doesn't work for functions where all possible sentinel values are valid return values.
Consider our calculateSquareRoot() function above. What value could it return if the user passes in a negative number? We can't use 0, because the square root of 0 yields 0 as a valid result. In fact, there are no values that we could return that cannot occur naturally.
So what are we to do?
First, we could pick some (hopefully) uncommon return value as our sentinel and use it to indicate an error:
#include <limits> // for std::numeric_limits
// returns std::numeric_limits<int>::min() on failure
int calculateSquareRoot(int value)
{
if (value < 0)
return std::numeric_limits<int>::min();
return static_cast<int>(std::sqrt(value));
}
std::numeric_limits<T>::min() is a function that returns the most negative value for type T. It is the counterpart to the std::numeric_limits<T>::max() function (which returns the largest positive value for type T).
In the example above, if calculateSquareRoot() cannot proceed, we return std::numeric_limits<int>::min(), which returns the most negative int value back to the caller to indicate that the function failed.
While this mostly works, it has two downsides:
- Every time we call this function, we need to test the return value for equality with
std::numeric_limits<int>::min()to see if it failed. That's verbose and awkward. - It is an example of a semipredicate problem: if the user calls
calculateSquareRoot(std::numeric_limits<int>::min() * std::numeric_limits<int>::min()), the returned resultstd::numeric_limits<int>::min()will be ambiguous as to whether the function succeeded or failed. That may or may not be a problem depending on how the function is actually used, but it's another thing we have to worry about and another potential way that errors can creep into our program.
Second, we could abandon using return values to return errors and use some other mechanism (e.g. exceptions). However, exceptions have their own complications and performance costs, and may not be appropriate or desired. That's probably overkill for something like this.
Third, we could abandon returning a single value and return two values instead: one (of type bool) that indicates whether the function succeeded, and the other (of the desired return type) that holds the actual return value (if the function succeeded) or an indeterminate value (if the function failed). This is probably the best option of the bunch.
Prior to C++17, choosing this latter option required you to implement it yourself. And while C++ provides multiple ways to do so, any roll-your-own approach will inevitably lead to inconsistencies and errors.
Returning a std::optional
C++17 introduces std::optional, which is a class template type that implements an optional value. That is, a std::optional<T> can either have a value of type T, or not. We can use this to implement the third option above:
#include <iostream>
#include <optional> // for std::optional (C++17)
// Our function now optionally returns an int value
std::optional<int> calculateSquareRoot(int value)
{
if (value < 0)
return {}; // or return std::nullopt
return static_cast<int>(std::sqrt(value));
}
int main()
{
std::optional<int> result1{ calculateSquareRoot(16) };
if (result1) // if the function returned a value
std::cout << "Result 1: " << *result1 << '\n'; // get the value
else
std::cout << "Result 1: failed\n";
std::optional<int> result2{ calculateSquareRoot(-9) };
if (result2)
std::cout << "Result 2: " << *result2 << '\n';
else
std::cout << "Result 2: failed\n";
return 0;
}
This displays:
Result 1: 4 Result 2: failed
Using std::optional is quite easy. We can construct a std::optional<T> either with or without a value:
std::optional<int> opt1{ 10 }; // initialize with a value
std::optional<int> opt2{}; // initialize with no value
std::optional<int> opt3{ std::nullopt }; // initialize with no value
To see if a std::optional has a value, we can choose one of the following:
if (opt1.has_value()) // call has_value() to check if opt1 has a value
if (opt2) // use implicit conversion to bool to check if opt2 has a value
To get the value from a std::optional, we can choose one of the following:
std::cout << *opt1; // dereference to get value stored in opt1 (undefined behavior if opt1 does not have a value)
std::cout << opt2.value(); // call value() to get value stored in opt2 (throws std::bad_optional_access exception if opt2 does not have a value)
std::cout << opt3.value_or(99); // call value_or() to get value stored in opt3 (or value `99` if opt3 doesn't have a value)
Note that std::optional has a usage syntax that is essentially identical to a pointer:
| Behavior | Pointer | std::optional |
|---|---|---|
| Hold no value | initialize/assign {} or std::nullptr |
initialize/assign {} or std::nullopt |
| Hold a value | initialize/assign an address | initialize/assign a value |
| Check if has value | implicit conversion to bool | implicit conversion to bool or has_value() |
| Get value | dereference | dereference or value() |
However, semantically, a pointer and a std::optional are quite different.
- A pointer has reference semantics, meaning it references some other object, and assignment copies the pointer, not the object. If we return a pointer by address, the pointer is copied back to the caller, not the object being pointed to. This means we can't return a local object by address, as we'll copy that object's address back to the caller, and then the object will be destroyed, leaving the returned pointer dangling.
- A
std::optionalhas value semantics, meaning it actually contains its value, and assignment copies the value. If we return astd::optionalby value, thestd::optional(including the contained value) is copied back to the caller. This means we can return a value from the function back to the caller usingstd::optional.
With this in mind, let's look at how our example works. Our calculateSquareRoot() now returns a std::optional<int> instead of an int. Inside the function body, if we detect an error, we return {}, which implicitly returns a std::optional containing no value. If we have a value, we return that value, which implicit returns a std::optional containing that value.
Within main(), we use an implicit conversion to bool to check if our returned std::optional has a value or not. If it does, we dereference the std::optional object to get the value. If it doesn't, then we execute our error condition. That's it!
Pros and cons of returning a std::optional
Returning a std::optional is nice for several reasons:
- Using
std::optionaleffectively documents that a function may return a value or not. - We don't have to remember which value is being returned as a sentinel.
- The syntax for using
std::optionalis convenient and intuitive.
Returning a std::optional does come with a few downsides:
- We have to make sure the
std::optionalcontains a value before getting the value. If we dereference astd::optionalthat does not contain a value, we get undefined behavior. std::optionaldoes not provide a way to pass back information about why the function failed.
Unless your function needs to return additional information about why it failed (either to better understand the failure, or to differentiate different kinds of failure), std::optional is an excellent choice for functions that may return a value or fail.
Return a `std::optional` (instead of a sentinel value) for functions that may fail, unless your function needs to return additional information about why it failed.
`std::expected` (introduced in C++23) is designed to handle the case where a function can return either an expected value or an unexpected error code.
We previously discussed how pass by address can be used to allow a function to accept an "optional" argument (that is, the caller can either pass in nullptr to represent "no argument" or an object). However, one downside of this approach is that a non-nullptr argument must be an lvalue (so that its address can be passed to the function).
Perhaps unsurprisingly (given the name), std::optional is an alternative way for a function to accept an optional argument (that is used as an in-parameter only). Instead of this:
#include <iostream>
void printStudentID(const int* idNumber = nullptr)
{
if (idNumber)
std::cout << "Student ID is " << *idNumber << ".\n";
else
std::cout << "Student ID is not known.\n";
}
int main()
{
printStudentID(); // we don't know the student's ID yet
int studentID{ 42 };
printStudentID(&studentID); // we know the student's ID now
return 0;
}
You can do this:
#include <iostream>
#include <optional>
void printStudentID(std::optional<const int> idNumber = std::nullopt)
{
if (idNumber)
std::cout << "Student ID is " << *idNumber << ".\n";
else
std::cout << "Student ID is not known.\n";
}
int main()
{
printStudentID(); // we don't know the student's ID yet
int studentID{ 42 };
printStudentID(studentID); // we know the student's ID now
printStudentID(73); // we can also pass an rvalue
return 0;
}
There are two advantages to this approach:
- It effectively documents that the parameter is optional.
- We can pass in an rvalue (since
std::optionalwill make a copy).
However, because std::optional makes a copy of its argument, this becomes problematic when T is an expensive-to-copy type (like std::string). With normal function parameters, we worked around this by making the parameter a const lvalue reference, so that a copy would not be made. Unfortunately, as of C++23 std::optional does not support references.
Therefore, we recommend using std::optional<T> as an optional parameter only when T would normally be passed by value. Otherwise, use const T*.
Although `std::optional` doesn't support references directly, you can use `std::reference_wrapper` to mimic a reference. Let's take a look at what the above program looks like using a `std::string` id and `std::reference_wrapper`:
struct Student { std::string name{}; // expensive to copy int idNumber; };
void printStudentID(std::optional<std::reference_wrapper<Student>> student = std::nullopt) { if (student) std::cout << "Student ID is " << student->get().idNumber << ".\n"; else std::cout << "Student ID is not known.\n"; }
int main() { printStudentID(); // we don't know the Student yet
Student s{ "Alice", 42 };
printStudentID(s); // we know the Student's ID now
return 0;
}
And for comparison, the pointer version:
```cpp
#include <iostream>
#include <string>
struct Student
{
std::string name{}; // expensive to copy
int idNumber;
};
void printStudentID(const Student* student = nullptr)
{
if (student)
std::cout << "Student ID is " << student->idNumber << ".\n";
else
std::cout << "Student ID is not known.\n";
}
int main()
{
printStudentID(); // we don't know the Student yet
Student s{ "Alice", 42 };
printStudentID(&s); // we know the Student's ID now
return 0;
}
These two programs are nearly identical. We'd argue the former isn't more readable or maintainable than the latter, and isn't worth introducing two additional types into your program for.
In many cases, function overloading provides a superior solution:
#include <iostream>
#include <string>
struct Student
{
std::string name{}; // expensive to copy
int idNumber;
};
void printStudentID()
{
std::cout << "Student ID is not known.\n";
}
void printStudentID(const Student& student)
{
std::cout << "Student ID is " << student.idNumber << ".\n";
}
int main()
{
printStudentID(); // we don't know the Student yet
Student s{ "Alice", 42 };
printStudentID(s); // we know the Student's ID now
printStudentID({ "Bob", 73 }); // we can even pass rvalues
return 0;
}
Prefer `std::optional` for optional return types.
Prefer function overloading for optional function parameters (when possible). Otherwise, use std::optional<T> for optional arguments when T would normally be passed by value. Favor const T* when T is expensive to copy.
Representing Optional Values with std::optional - Quiz
Test your understanding of the lesson.
Practice Exercises
std::optional for Optional Values
Learn to use std::optional to return values that may or may not exist. Understand how to create, check, and extract values from optionals, and when to use them instead of sentinel values.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!