In lesson 9.4 -- Detecting and handling errors, we discussed cases where a function encounters an error that it cannot reasonably handle itself. For example, consider a function that calculates and returns a value:
int doIntDivision(int x, int y)
{
return x / y;
}
If the caller passes in a value that is semantically invalid (such as y
= 0
), this function cannot calculate a value to return (as division by 0 is mathematically undefined). What do 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 thing to do is have the function detect the error, but then pass the error back to the caller to deal with in some program-appropriate way.
In the previously linked lesson, we covered two different ways to have a function return an error back 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 does not occur in the set of possible values the function can otherwise return) to indicate an error.
As an example of the latter, the reciprocal()
function that follows returns value 0.0
(which can never otherwise occur) if the user passes in a semantically invalid argument for x
:
#include <iostream>
// The reciprocal of x is 1/x, returns 0.0 if x=0
double reciprocal(double x)
{
if (x == 0.0) // if x is semantically invalid
return 0.0; // return 0.0 as a sentinel to indicate an error occurred
return 1.0 / x;
}
void testReciprocal(double d)
{
double result { reciprocal(d) };
std::cout << "The reciprocal of " << d << " is ";
if (result != 0.0)
std::cout << result << '\n';
else
std::cout << "undefined\n";
}
int main()
{
testReciprocal(5.0);
testReciprocal(-4.0);
testReciprocal(0.0);
return 0;
}
While this is a fairly attractive solution, there are a number of 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 does not work for functions where all possible sentinel values are valid return values.
Consider our doIntDivision()
function above. What value could it return if the user passes in 0
for y
? We can’t use 0
, because 0
divided by anything 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>::lowest() on failure
int doIntDivision(int x, int y)
{
if (y == 0)
return std::numeric_limits<int>::lowest();
return x / y;
}
std::numeric_limits<T>::lowest()
is a function that returns the largest 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
) that we introduced in lesson 9.5 -- std::cin and handling invalid input.
In the example above, if doIntDivision()
cannot proceed, we return std::numeric_limits<int>::lowest()
, which returns the largest 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>::lowest()
to see if it failed. That’s verbose and ugly. - It is an example of a semipredicate problem: if the user calls
doIntDivision(std::numeric_limits<int>::lowest(), 1)
, the returned resultstd::numeric_limits<int>::lowest()
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> doIntDivision(int x, int y)
{
if (y == 0)
return {}; // or return std::nullopt
return x / y;
}
int main()
{
std::optional<int> result1 { doIntDivision(20, 5) };
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 { doIntDivision(5, 0) };
if (result2)
std::cout << "Result 2: " << *result2 << '\n';
else
std::cout << "Result 2: failed\n";
return 0;
}
This prints:
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> o1 { 5 }; // initialize with a value
std::optional<int> o2 {}; // initialize with no value
std::optional<int> o3 { std::nullopt }; // initialize with no value
To see if a std::optional
has a value, we can choose one of the following:
if (o1.has_value()) // call has_value() to check if o1 has a value
if (o2) // use implicit conversion to bool to check if o2 has a value
To get the value from a std::optional
, we can choose one of the following:
std::cout << *o1; // dereference to get value stored in o1 (undefined behavior if o1 does not have a value)
std::cout << o2.value(); // call value() to get value stored in o2 (throws std::bad_optional_access exception if o2 does not have a value)
std::cout << o3.value_or(42); // call value_or() to get value stored in o3 (or value `42` if o3 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::optional
has value semantics, meaning it actually contains its value, and assignment copies the value. If we return astd::optional
by 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 doIntDivision()
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 a number of reasons:
- Using
std::optional
effectively 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::optional
is convenient and intuitive.
Returning a std::optional
does come with a few downsides:
- We have to make sure the
std::optional
contains a value before getting the value. If we dereference astd::optional
that does not contain a value, we get undefined behavior. std::optional
does 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.
Best practice
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.
Related content
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. See the std::expected reference for more information.
Using std::optional
as an optional function parameter
In lesson 12.11 -- Pass by address (part 2), we 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 printIDNumber(const int *id=nullptr)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printIDNumber(); // we don't know the user's ID yet
int userid { 34 };
printIDNumber(&userid); // we know the user's ID now
return 0;
}
You can do this:
#include <iostream>
#include <optional>
void printIDNumber(std::optional<const int> id = std::nullopt)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printIDNumber(); // we don't know the user's ID yet
int userid { 34 };
printIDNumber(userid); // we know the user's ID now
printIDNumber(62); // 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::optional
will 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*
.
For advanced readers
Although std::optional
doesn’t support references directly, you can use std::reference_wrapper
(which we cover in lesson 17.5 -- Arrays of references via 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
:
#include <functional> // for std::reference_wrapper
#include <iostream>
#include <optional>
#include <string>
struct Employee
{
std::string name{}; // expensive to copy
int id;
};
void printEmployeeID(std::optional<std::reference_wrapper<Employee>> e=std::nullopt)
{
if (e)
std::cout << "Your ID number is " << e->get().id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printEmployeeID(); // we don't know the Employee yet
Employee e { "James", 34 };
printEmployeeID(e); // we know the Employee's ID now
return 0;
}
And for comparison, the pointer version:
#include <iostream>
#include <string>
struct Employee
{
std::string name{}; // expensive to copy
int id;
};
void printEmployeeID(const Employee* e=nullptr)
{
if (e)
std::cout << "Your ID number is " << e->id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printEmployeeID(); // we don't know the Employee yet
Employee e { "James", 34 };
printEmployeeID(&e); // we know the Employee'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 Employee
{
std::string name{}; // expensive to copy
int id;
};
void printEmployeeID()
{
std::cout << "Your ID number is not known.\n";
}
void printEmployeeID(const Employee& e)
{
std::cout << "Your ID number is " << e.id << ".\n";
}
int main()
{
printEmployeeID(); // we don't know the Employee yet
Employee e { "James", 34 };
printEmployeeID(e); // we know the Employee's ID now
printEmployeeID( { "Dave", 62 } ); // we can even pass rvalues
return 0;
}
Best practice
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.