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, optional types - especially std::optional<bool>
- may in some situations 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 shouldn't be a surprise. 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 not intuitive, 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 as the has_value()
method). In some contexts – most notably the if
, while
, and for
expressions, logical operators, and the conditional (ternary) operator C++ is allowed to use it to perform an implicit cast (a complete list of contexts can be found in the Contextual conversions section on cppreference). In our case, it led to a behavior that, at first sight, seemed incorrect. Thinking about this a bit more, the seemingly intuitive behavior should not even be expected. An std::optional<bool>
variable can have one of three possible values:
true
false
-
std::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 be 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 into tests. As an example, here is a test that happily passes, but shouldn't:
TEST(stdOptionalBoolTest, IncorrectTest) {
ASSERT_TRUE(std::optional<bool>{false});
}
How to deal with std::optional<bool>
?
Before I discuss the ways to handle the std::optional<bool>
type in code, I would like to 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 you see someone introduce a new
std::optional<bool>
variable or function, make sure all call sites are reviewed and amended if needed - have a good unit test coverage that can detect bugs caused by introducing
std::optional<bool>
; if feasible, create a lint rule that flags suspicious usages ofstd::optional<bool>
Now, here are a few strategies 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. A big disadvantage of this approach is that someone will inevitably want to 'simplify' this code by removing the 'unnecessary' == false
and, as a result, introduce a bug.
Unwrap the optional value with the .value()
method
If you know that the value on the given code path is always set, you can unwrap it by calling the .value()
method like so:
std::optional<bool> isMorning = false;
if (isMorning.value()) {
std::cout << "Good Morning!" << std::endl;
} else {
std::cout << "Good Afternoon!" << std::endl;
}
Note, however, that it won’t work if the value is not set. Invoking the .value()
method if the value is 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 is not set. Personally, I never use this approach.
Use .value_or()
to provide the default value for cases when the value is not set
The std::optional
type offers the .value_or()
method that allows you to provide 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
If you decided to use std::optional
, you did it because you wanted to enable a scenario where the value may not be set. Now you need to handle this case. Here is one way to do this:
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 suffer from the very same issue as your code. This would make them unreliable because they might pass even though they shouldn't. As an example, the following assertion doesn't fail:
ASSERT_TRUE(std::optional{false});
It can be fixed 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 also exhibit the same behavior? The std::optional
type is a template, so its behavior is the same for any type parameter. We can see it by running the following code:
std::optional<int> n = 0;
if (n) {
std::cout << "n is not 0" << std::endl;
}
It will print:
n is not 0
The problem with std::optional<bool>
is just more pronounced due to the typical usage 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 more common to write the condition above explicitly as: if (n != 0)
which yields the expected result because no implicit conversion will be involved.
Top comments (2)
I've never been of a fan this implicit conversion to boolean that
std::optional
exposes. I always use.has_value()
. The code may seem heavier to some people, but I believe the intent is much clearer and maintainability is improved.You post is a good example of "explicit is better that implicit" (and maybe why you should not provide implicit conversion operators / constructors in C++ :)
Yes, this implicit conversion (along with dereferencing with
*
) is a trap. But it is what it is, and it's not going to change. We use optional types a lot and I encourage everyone to always use.has_value()
and.value()
for clarity.