The std::optional type is a great addition to the standard C++ library in C++ 17. It allows to end the practice of using some special (sentinel) value, e.g., -1, to indicate that an operation didn’t produce a meaningful result. There is one caveat though – std::optional<bool> may in some situation behave counterintuitively and can lead to subtle bugs.
Let’s take a look at the following code:
bool isMorning = false;
if (isMorning) {
std::cout << "Good Morning!" << std::endl;
} else {
std::cout << "Good Afternoon" << std::endl;
}
Running this code prints:
Good Afternoon!
This is not a surprise. But let’s see what happens if we change the bool type to std::optional<bool> like this:
std::optional<bool> isMorning = false;
if (isMorning) {
std::cout << "Good Morning!" << std::endl;
} else {
std::cout << "Good Afternoon!" << std::endl;
}
This time the output is:
Good Morning!
Whoa? Why? What’s going on here?
While this is likely not what we expected, it’s not a bug. The std::optional type defines an explicit conversion to bool that returns true if the object contains a value and false if it doesn’t (exactly the same as the has_value() method). In some contexts – most notably the if, while, for expressions, logical operators, the conditional (ternary) operator (a complete list can be found in the Contextual conversions section on cppreference) – C++ is allowed to use it to perform an implicit cast. In our case it led to a behavior that at the first sight seems incorrect. Thinking about this a bit more, the seemingly intuitive behavior should not be expected. An std::optional<bool> variable can have one of three possible values:
truefalsestd::nullopt(i.e., not set)
and there is no interpretation under which the behavior of expressions like if (std::nullopt) is universally meaningful. Having said that, I have seen multiple engineers (myself included) fall into this trap.
The problem is that spotting the bug can hard as there are no compiler warnings or any other indications of the issue. This is especially problematic when changing an existing variable from bool to std::optional<bool> in large codebases because it is easy to miss some usages and introduce regressions.
The problem can also sneak easily to your tests. Here is an example of a test that happily passes but only due to a bug:
TEST(stdOptionalBoolTest, IncorrectTest) {
ASSERT_TRUE(std::optional<bool>{false});
}
How to deal with std::optional<bool>?
Before we discuss the ways to handle std::optional<bool> type in code, it could be useful to mention a few strategies that can prevent bugs caused by std::optional<bool>:
- raise awareness of the unintuitive behavior of
std::optional<bool>in some contexts - when a new
std::optional<bool>variable or function is introduced make sure all places where it is used are reviewed and amended if needed - have a good test unit coverage that can detect bugs caused by introducing
std::optional<bool>to your codebase - if feasible, create a lint rule that flags suspicious usages of
std::optional<bool>
In terms of code there are few ways to handle the std::optional<bool> type:
Compare the optional value explicitly using the == operator
If your scenario allows treating std::nullopt as true or false you can use the == operator like this:
std::optional<bool> isMorning = std::nullopt;
if (isMorning == false) {
std::cout << "It's not morning anymore..." << std::endl;
} else {
std::cout << "Good Morning!" << std::endl;
}
This works because the std::nullopt value is never equal to an initialized variable of the corresponding optional type. One big disadvantage of this approach is that someone will inevitably want to simplify the expression by removing the unnecessary == false and, as a result, introducing a regression.
Unwrap the optional value with the value() method
If you know that the value in the given codepath is always set you can unwrap the value by calling the value() method like in the example below:
std::optional<bool> isMorning = false;
if (isMorning.value()) {
std::cout << "Good Morning!" << std::endl;
} else {
std::cout << "Good Afternoon!" << std::endl;
}
Note that it won’t work if the value might not be set – invoking the .value() method if the value was not set will throw the std::bad_optional_access exception
Dereference the optional value with the * operator
This is very similar to the previous option. If you know that the value on the given code path is always set you can use the * operator to dereference it like this:
std::optional<bool> isMorning = false;
if (*isMorning) {
std::cout << "Good Morning!" << std::endl;
} else {
std::cout << "Good Afternoon!" << std::endl;
}
One big difference from using the value() method is that the behavior is undefined if you dereference an optional whose value was not set. Personally, I never go with this solution.
Use value_or() to provide the default value for cases when the value is not set
std::optional has the value_or() method that allows providing the default value that will be returned if the value is not set. Here is an example:
std::optional<bool> isMorning = std::nullopt;
if (!isMorning.value_or(false)) {
std::cout << "It's not morning anymore..." << std::endl;
} else {
std::cout << "Good Morning!" << std::endl;
}
If your scenario allows treating std::nullopt as true or false using value_or() could be a good choice.
Handle std::nullopt explicitly
There must have been a specific reason you decided to use std::optional<bool> – you wanted to enable the scenario where the value is not set. Now you need to handle this case. Here is how:
std::optional<bool> isMorning = std::nullopt;
if (isMorning.has_value()) {
if (isMorning.value()) {
std::cout << "Good Morning!" << std::endl;
} else {
std::cout << "Good Afternoon!" << std::endl;
}
} else {
std::cout << "I am lost in time..." << std::endl;
}
Fixing tests
If your tests use ASSERT_TRUE or ASSERT_FALSE assertions with std::optional<bool> variables they might be passing even if they shouldn’t as they suffer from the very same issue as your code. As an example, the following assertion will happily pass:
ASSERT_TRUE(std::optional{false});
You can fix this by using ASSERT_EQ to explicitly compare with the expected value or by using some of the techniques discussed above. Here are a couple of examples:
ASSERT_EQ(std::optional{false}, true);
ASSERT_TRUE(std::optional{false}.value());
Other std::optional type parameters
We spent a lot of time discussing the std::optional<bool> case. How about other types? Do they suffer from the same issue? The std::optional type is a template so its behavior is the same for any type parameter. Here is an example with std::optional<int>:
std::optional<int> n = 0;
if (n) {
std::cout << "n is not 0" << std::endl;
}
which generates the following output:
n is not 0
The problem with std::optional<bool> is just more pronounced due to the typical usages of bool. For non-bool types it is fortunately no longer a common practice to rely on the implicit cast to bool. These days it is much common to write the condition above explicitly as: if (n != 0) which will compare with the value of as long as it is populated.