Search code examples
c++exceptionstdoption-typehierarchy

Is std::bad_optional_access a small crime against exceptions?


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.


Solution

  • 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.