std::optional? Proceed with caution!

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 – 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 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 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 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?

Before I discuss the ways to handle the std::optional type in code, I would like to a few strategies that can prevent bugs caused by std::optional:

  • raise awareness of the unintuitive behavior of std::optional in some contexts
  • when you see someone introduce a new std::optional 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; if feasible, create a lint rule that flags suspicious usages of std::optional

Now, here are a few strategies to handle the std::optional 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 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 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 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.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post
[20-days-of-dynamodb]-day-6-–-atomic-counters-with-updateitem

[20 Days of DynamoDB] Day 6 – Atomic counters with UpdateItem

Next Post
12-popular-but-useless-vs-code-extensions-you’re-probably-using

12 Popular but Useless VS Code Extensions You’re Probably Using

Related Posts