Is std::bad_optional_access a small crime against exceptions?

Viewed 4413

If std::optional's value() member function is called when the optional has no actual value initialized, a std::bad_optional_access is thrown. As it is derived directly from std::exception, you need either catch (std::bad_optional_access const&) or catch (std::exception const&) for dealing with the exception. However, both options seem sad to me:

  • std::exception catches every single exception
  • std::bad_optional_access exposes implementation details. Consider the following example:
Placement Item::get_placement() const {
  // throws if the item cannot be equipped
  return this->placement_optional.value();
}
void Unit::equip_item(Item acquisition) {
  // lets the exception go further if it occurs
  this->body[acquisition.get_placement()] = acquisition;
}
// somewhere far away:
try {
  unit.equip_item(item);
} catch (std::bad_optional_access const& exception) { // what is this??
  inform_client(exception.what());
}

So, to catch the exception you need to be well-informed about the usage of std::optional in the Item's implementation, being led to a list of already known issues. Neither I want to catch and rewrap std::bad_optional_access because (for me) the key part of exceptions is the possibility of ignoring them until needed. This is how I see the right approach:

std::exception
  <- std::logic_error
    <- std::wrong_state (doesn't really exist)
      <- std::bad_optional_access (isn't really here)

So, the "far away" example could be written like this:

try {
  unit.equip_item(item);
} catch (std::wrong_state const& exception) { // no implementation details
  inform_client(exception.what());
}

Finally,

  • Why is std::bad_optional_access designed like it is?
  • Do I feel exceptions correctly? I mean, were they introduced for such usage?

Note: boost::bad_optional_access derives from std::logic_error. Nice!

Note 2: I know about catch (...) and throwing objects of types other than std::exception family. They were omitted for brevity (and sanity).

Update: unfortunately, I can't accept two answers, so: if you're interested in the topic, you can read Zuodian Hu's answer and their comments.

7 Answers

You say that the key appeal of exceptions is that you can ignore them for as deep of a call stack as you can. Presumably, given your ambition of avoiding to leak implementation details, you no longer can let an exception propagate beyond the point where that exception cannot be understood and fixed by its handler. That seems to be a contradiction with your ideal design: it punts fixing the exception to the user, but bad_optional_access::what has exactly no context on what just happened–leaking implementation details to the user. How do you expect a user to take meaningful action against a failure to equip an item when all they see is, at best, "could not equip item: bad_optional_access"?

You have obviously made an over-simplification, but the challenge remains. Even with a "better" exception hierarchy, std::bad_optional_access simply does not have enough context that anything beyond extremely close callers might know what to do with it.

There are several fairly distinct cases in which you might want to throw:

  1. You want control flow to be interrupted without much syntactical overhead. For instance, you have 25 different optionals that you want to unwrap, and you want to return a special value if any of them fails. You put a try-catch block around the 25 accesses, saving yourself 25 if blocks.
  2. You have written a library for general use that does a lot of things that can go wrong, and you want to report fine-grained errors to the calling program to give it the best chance of programmatically doing something smart to recover.
  3. You have written a large framework that performs very high-level tasks, such that you expect that usually, the only reasonable outcome of an operation failing is to inform the user that the operation has failed.

When you run into issues with exceptions not feeling right, it's usually because you're trying to handle an error meant for a different level than the one you wish it was operating at. Wishing for changes to the exception hierarchy is just trying to bring that exception in line for your specific use, which causes tensions with how other people use it.

Clearly, the C++ committee believes that bad_optional_access belongs to the first category, and you're asking why it's not part of the third category. Instead of trying to ignore exceptions until you "need" to do something with them, I believe that you should flip the question around and ask yourself what is intended to catch the exception.

If the answer truly is "the user", then you should throw something that's not a bad_optional_access and that instead has high-level features like localized error messages and enough data on it that inform_user is able to bring up a dialog with a meaningful title, main text, subtext, buttons, an icon, etc.

If the answer is that this is a general game engine error and that it might happen in the regular course of the game, then you should throw something that says that equipping the item failed, not that there was a state error. It's more likely that you'll be able to recover from failing to equip an item than from having a non-descript state error, including if, down the road, you need to produce a pretty error for the user.

If the answer is that you might try to equip 25 items in a row and you want to stop as soon as something goes wrong, then you need no changes to bad_optional_access.

Also note that different implementations make different uses more or less convenient. In most modern C++ implementations, there is no performance overhead on code paths that do not throw, and a huge overhead on paths that do throw. This often pushes against the use of exceptions for the first category of errors.

So, to catch the exception you need to be well-informed about the usage of std::optional in the Item's implementation

No, to catch the exception, you must read the documentation for get_placement, which will tell you that it throws std::bad_optional_access. By choosing to emit that exception, the function is making the emission of that exception a part of the interface of that function.

And therefore, it is no more dependent on Item's implementation than it would be if it directly returned a std::optional. You choose to put it in your interface, so you ought to live with the consequences.

To put it another way, if you felt that putting std::optional as a parameter type or return value was wrong, then you should feel the same way about emitting bad_optional_exception directly.


Ultimately, this all goes back to one of the most fundamental questions of error handling: how far away from the site of the error can you get before the specific nature of the error becomes effectively meaningless or even completely different?

Let's say you're doing text processing. You've got a file with each line containing 3 floating-point numbers. You're processing it line by line, and inserting each set of three values into a list. And you have a function that converts strings to floats, which throws an exception if that conversion fails.

So the code broadly looks like this:

for each line
  split the line into a 3-element list of number strings.
  for each number string
    convert the string into a number.
    add the number to the current element.
  push the element into the list.

Alright, so... what happens if your string-to-float converter throws? That depends; what do you want to happen? That's determined by who catches it. If you want a default value on an error, then the code in the innermost loop catches it and writes a default value into the element.

But maybe you want to log that a particular line has an error, then skip that line (don't add it to the list), but continue processing the rest of the text as normal. In that case, you catch the exception in the first loop.

At that point, the meaning of the error has changed. The error which was thrown was "this string doesn't contain a valid float", but that's not how your code handles it. In fact, the catching code has completely lost the context of the error. It doesn't know whether it was the first, second, or third string in the text which caused the failure. At best, it knows that it was somewhere along that line, and maybe the exception happens to contain a couple of pointers to the bad string range (though that's increasingly dangerous the farther that exception gets from its source, due to the possibility of dangling pointers).

And what if a failed conversion ought to mean that the entire process can no longer be trusted, that the list you're building is invalid and should be discarded? This has even less context than the previous case, and the meaning is even more muddled and distant. At this point, the error just means to terminate the list building process. Maybe you put together a log entry, but that's about all you're going to do at this point.

The farther you get from where the exception is thrown, the more context about the error is lost, and the more the meaning ultimately drifts from the initial meaning of the error. That's not just about being an implementation detail; it's about the locality of information and the response to that information.

So basically, code close to the source of the error is catching specific exceptions with contextual meaning. The farther the catch gets from the source of the error, the more likely it is that the catching code is going to be very generic, dealing with vague "this didn't work because reasons" kinds of things. This is where vague types like std::logic_error come in.

Indeed, one could imagine that at each step in the process, the exception is reinterpreted (and by "reinterpreted", I mean converting it into a different type via catch/throw). The string-to-float converter throws a meaningful exception: could not convert string to float. The layer trying to build an element from 3 strings converts the exception to something that has value to its caller: string index X is malformed. And at the last phase, the exception is generalized to: couldn't parse the list due to line Y.

The idea that a single exception type can jump through whole libraries of code and designed intent and still retain its initial meaning is a fantasy. Exceptions work great when they have to pass through neutral code, such as throwing an exception from a callback or some other case of indirect function execution. In this case, the code which provoked the execution still has the local context of the process that provoked the exception. But the farther from the local context who knows what's going on you get, the less meaningful a specific exception becomes.

Inheriting from logic_error is wrong for these reasons. Catching a bad_optional_access is ultimately a very local thing. Past a certain point, the meaning of that error changes.

A "logic error" represents a failure of your program to make sense. But an optional which doesn't contain a value does not necessarily represent such a problem. In one piece of code, it could be a perfectly valid thing to have an empty optional, and the exception being thrown is simply how that gets reported to the caller. Another piece of code might treat an optional being empty at a certain point as a user having made some prior mistake in their API usage. One of these is a logic error, and the other isn't.

Ultimately, the right thing to do is make sure that your classes APIs all emit exceptions which are meaningful to the caller. And it's not clear what bad_optional_access means to the caller of get_placement.

Exposing Implementation Details

If you wish for your user to be entirely unaware of std::optional in your implementation, your interface would either check operator bool or has_value and do one of the following:

  1. return a status code
  2. throw a custom exception type
  3. handle the emptiness in such a way that the client has no knowledge that an internal error ever happened

...or your interface would catch std::bad_optional_access and do one of the above. In either case, your client has no idea you used std::optional.

Note that whether you found out about the emptiness of the optional through an explicit check or an exception is a design choice (but personally I wouldn't catch and re-throw either in most cases).

Logic Error?

Based on the conceptual model for optional in the pre-standardization paper, std::optional is a value wrapper with a valid empty state. Hence, the intent was for emptiness to be intentional in normal usage. There are two general ways handling emptiness, as I stated in the comments:

  1. use operator bool or has_value, then handle emptiness inline or use the wrapped value through operator* or operator->.
  2. use value and bail out of the scope if the optional is empty

In either case, you should be expecting the optional to potentially be empty, and designed for that to be a valid state in your program.

In other words, when you use operator bool or has_value to check for emptiness, it is not to prevent an exception being thrown. Instead, you are choosing to not use the exception interface of optional at all (usually). And when you use value, you are choosing to accept optional potentially throwing std::bad_optional_access. Hence, the exception should never be a logic error in the intended usage of optional.

UPDATE

Logic Errors in the Design of C++

You seem to misunderstand the Standard's intended definition of what a logic error is.

In the design of C++ in recent years (not the same in history), a logic error is a programmer error the application shouldn't try to recover from because it can't reasonably recover. This includes things like de-referencing dangling pointers and references, using operator* or operator-> on an empty optional, passing invalid arguments to a function, or otherwise breaking API contracts. Note that dangling pointers' existence is not a logic error, but de-referencing a dangling pointer is a logic error.

In these cases of true logic errors, the Standard purposely chooses not to throw because they are true logic errors on the part of the programmer, and the caller can't be reasonably expected to handle all bugs in the code they call.

When a well-designed (under this philosophy) Standard Library function throws, it's never supposed to be because the code or the caller wrote buggy code. For buggy code, the Standard let's you fall flat on your face for writing the bug. For example, many functions in <algorithn> run infinite loops if you pass them bad begin and end iterators, and never even try to diagnose the fact that you did that. They certainly don't throw std::invalid_argument. "Good" implementations do try to diagnose this in Debug builds though, because those logic errors are bugs. When a well-designed (under this philosophy) Standard Library function throws, it's supposed to be because a truly exceptional and unavoidable event occurred. has many throwing functions, because you can't really ever know for sure what's on some random file system. That's the situation exceptions are supposed to be used for.

In the paper linked below, Herb Sutter speaks against std::logic_error's existence as an exception type for this very reason. Clearly stating the philosophy, catching std::logic_error or any of its children amounts to introducing runtime overhead to fix programmer logic bugs. Any true logic error condition you want to detect should be asserted on, really, so the bug can be reported back to the people who wrote the bug.

In the optional interface, designed with the above in mind, value throws so that you can programmatically deal with it in a sensible way with the expectation that whoever catches it either don't care what bad_optional_access means (catch( ... ) // catch everything) or can specifically deal with bad_optional_access. That exception really isn't meant to propagate far at all. When you purposely call value, you do so because you acknowledge the optional may be empty, and you choose to exit the current scope if it does turn out to be empty.

See the first section of this paper (download) for the philosophical rationale.

First, if you don't want to expose the imlementation, than the exceptions shouldn't even cross the border between the implementation and the client code. This is a common idiom that no exception should cross the boundaries of libraries, APIs, etc.

Next, the fact that you store something in an optional is the implementation that you should control by yourself. That means that you should check that the optional is not empty (at least if you don't want the client knowing the details of the implementation).

And finally, answer the question: is it an error that the client code performs an operation on an empty object? If that is something that it is allowed to do, than no exception should be thrown (e.g. error code may be returned). If that is a real problem that shouldn't happen, throwing an exception is appropriate. You may catch the std::bad_optional_access in your code and throw something else from the catch block.

Another solution to your problem could be nested exceptions. That means that you catch a lower level exception (in your case std::bad_optional_access) and then throw another exception (of any type, in your case you may implement a wrong_state : public std::logic_error) using the std::throw_with_nested function. Using this approach you:

  1. Preserve the information about the lower level exception (it is still stored as a nested exception)

  2. Hide this information about the nested exception from the user

  3. Allow user to catch the exception as a wrong_state or a std::logic_error.

see the example: https://coliru.stacked-crooked.com/view?id=b9bc940f2cc6d8a3

Consider this REAL WORLD example of when NOT to use std:bad_optional_access, involving the INELEGANT 900 lines of code, wrapped up into one HUGE class, just to render a vulkan triangle, in THIS example at https://vulkan-tutorial.com/code/06_swap_chain_creation.cpp

I am in the process of reimplementing the one HUGE HelloTriangleApplication class into multiple smaller classes. And,the QueueFamilyIndices struct begins as a couple of empty std::optional<uinit32_t> lists , ergo, EXACTLY the sort of not-yet-a-things which std::optional was invented to handle.

So, obviously, I wanted to test each class, before subclassing it into another class. But, this involved leaving some not-yet-a-things uninitialized until a parent's subclass was latter implemented.

It seemed right, at least to me, NOT to use std:bad-optional-access as a placeholder for future values, but rather to just code a 0 in the parent class, as a placeholder for not yet implemented std:optional not-yet-a-things. Which was enough to avoid my IDE reporting those annoying "bad optional access" warnings.

This is a good question with good answers. I want to highlight some of the main points more directly, and also add some points that I disagree with from the other answers. I come at this more from the POV of abstract flow of information, with the idea that all infinite variants of specific situations become easier to handle when appropriate information is passed around effectively.

The TL;DR here is:

  • Using bad_optional_access in a semantically incorrect way is common, but is the root cause of a lot of the other stuff (like logic_error) appearing to not making sense, and
  • value() should be used only when you know it has a value; it's not intended as some "exception-y" variation of value_or(). It doesn't make sense when there is no value: A thing with no value does not have a value, and so retrieving its value isn't something you can do. If you call 'value()' when there's no value then you made a mistake somewhere.

Regarding the use of value() itself:

  • If you cannot guarantee that an optional has a value, you use has_value() or value_or(). The use of value() assumes the optional has a value and by using it, you are stating that assumption as an invariant (i.e. assert(x.has_value()) is expected to pass), and if it doesn't have a value, then the invariant has been violated and an exception is appropriate. value() does not have meaning when the optional doesn't have a value. It is the same reason that you do not compute a / b in situations where b might be 0 -- you either know it isn't 0, or you check first. Likewise dereferencing invalid iterators, accessing invalid pointers, calling front() on an empty container, uh... computing the unbiased variance of a single sample... things like that.

  • Following that point, if you see a bad_optional_access, then this means there is a bug in your code: one of your assumptions (it had a value) was false. In other words, this is a development error, and in an ideal world a user should never encounter this exception in the same way a user should never encounter an assertion failure or a divide-by-zero or a null pointer access: It does not represent a user-actionable error, it represents code that needs to be fixed. Ideally only you as a developer should encounter this particular exception.

  • This is specifically why it is a logic_error: You used value() but did not honor its preconditions, and the implied assumption that you made about it having a value was not correct. You made a programming error by using value() in a situation where you could not guarantee that it had a value.

That said, the world isn't ideal. Also, generally speaking, if some exception below some layer of code is meant to represent a more user-appropriate error above that layer of code, then you need to translate that exception. For example:

  • An exception may expose an implementation detail that you want to abstract away.
  • An exception may contain information that doesn't make sense to a user, but may represent a larger more generic issue that is important to a user.

And so you need to translate that. For example:

Placement Item::get_placement() const {
  // throws if the item cannot be equipped
  return this->placement_optional.value();
}

The comment literally says "throws if the item cannot be equipped", but bad_optional_access doesn't mean "an item cannot be equipped". So if you allow it to be thrown out of that function, then you've miscommunicated the conceptual issue by throwing a semantically incorrect exception. Instead:

// elsewhere
class item_equip_exception : ... {
   ...
};

// then:
Placement Item::get_placement() const {
  // throws if the item cannot be equipped
  try {
      return this->placement_optional.value();
  } catch (const std::bad_optional_access &x) {
      throw item_equip_exception(...);
  }
}

Because that's what you're really trying to communicate.

However, an even more correct version of that would be:

Placement Item::get_placement() const {
  // throws if the item cannot be equipped
  if (!this->placement_optional.has_value())
      throw item_equip_exception(...);
  return this->placement_optional.value();
}

The reason this is more correct is because now you are calling value() in a situation where your assumption that it has a value should be guaranteed. And in this case, if you end up with a bad_optional_access, then it's truly a serious logic error. This now means that, as long as you're consistent with this approach, at the very top level of your application you can now actually catch std::logic_error and it really will mean that some program logic went terribly wrong, and you can inform the user as such.

All of the issues in the original post can basically be boiled down to semantics:

  • If you use value() when there might be no value, that's a programming error, and...
  • If you then use that "programming error" to signify a general user-facing error, then...
  • ... you've now tangled up all the semantics and nothing makes sense any more, including `std::logic_error'.

Where on the other hand:

  • If there reasonably may be no value and that signifies some higher level thing like "the item can't be equipped", and...
  • You check for that and throw a more appropriate exception and never call value() when there's no value, then...
  • ... you can now communicate that to the user, the logic_error -> bad_optional_access inheritance meaning continues to make sense, and you can still separately catch programmer errors at a higher level.

So yeah; "say what you mean" applies as much to information flow in programming as it does to speaking in real life!

Related