DEV Community

Cover image for std::optional? Proceed with caution!
Pawel Kadluczka
Pawel Kadluczka

Posted on • Originally published at blog.3d-logic.com

std::optional? Proceed with caution!

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;
}
Enter fullscreen mode Exit fullscreen mode

Running this code prints:

Good Afternoon!
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

This time the output is:

Good Morning!
Enter fullscreen mode Exit fullscreen mode

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});
}
Enter fullscreen mode Exit fullscreen mode

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 of std::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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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});
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

It will print:

n is not 0
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
pgradot profile image
Pierre Gradot • Edited

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++ :)

Collapse
 
moozzyk profile image
Pawel Kadluczka

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.