Tuesday, January 12, 2016

Typed Exceptions are Pointless

Summary

Major languages like C++, Java, C#, and ML allow you to throw exceptions of different types.  This is a needless complication; programs only need a type-less "signal" that indicates whether a computation succeeded or failed.

In this simpler model of type-less exceptions, we can do the following in C++
  • Continue to use RAII to protect resources.
  • When you must use try/catch, the only legitimate catch-handler is catch(...) .
  • Decorate functions with noexcept wherever possible.

Why do exceptions exist?

Before discussing the topic at hand, we must first understand: why do programming languages need exceptions?
  1. In C++, the only way to fail an object's constructor, such that the caller never sees a fully formed object, is by throwing an exception.
  2. Copy/move-constructors and assignment-operators can only fail via exception.
  3. We want to write "expressive" code, with use of math operators, method-chaining.
  4. STL and boost types, including popular classes like std::vector and std::function, use exceptions.
Notice that we can avoid the use of exceptions in all cases:
  1. write trivial noexcept constructors + Init functions
  2. manually write clone functions
  3. use error-codes, nullablesoptionals, or other alternative error-handling strategies
  4. roll our own container types, or use something like EASTL
So technically, exceptions aren't necessary.   But, if we want to write code a certain way, we must use them with all their associated baggage.  It's a classic engineering trade-off.

Getting to the point.

Let's say we have to calculate the sum of numbers in a file.  The requester says "just give me a number, that's all we want to store in the database".  We start by writing this function:
Number CalculateSum(const char* pDirName)
{
    try {
        boost::filesystem::path filePath(pDirName);
        filePath /= "numbers.txt";
        std::vector<Number> numbers = ReadNumbers(filePath);
        Number sum = std::accumulate(numbers.begin(), numbers.end(), Number(0));
        return sum;
    }
    catch (const std::bad_alloc& e) {
        // ... handle out-of-memory? which allocation? ...
    }
    catch (const std::ifstream::failure e) {
        // ... handle filestream failure? what filestream? ...
    }
    catch (const std::overflow_error& e) {
        // ... handle overflow? which subexpression? ...
    }
    // "they" said catch(...) is evil, but what if another type were thrown?
    return Number(std::nan(""));
}

We're doing some file I/O (in ReadNumbers), allocating memory (inside boost::filesystem::path and std::vector), doing some math ... many different kinds of actions are going on here.  Semantically, a failure in any of those steps is a total failure in CalculateSum.  Our only recourse is to take appropriate action at our current layer.

To reiterate: At this scope, we cannot know which step failed.  Even if we did, we couldn't do things any differently.  This is why I say that typed-exceptions are pointless.  The type and contents of the exception are completely useless; we only care about whether an exception was thrown at all.

Let's rewrite that function.
Number CalculateSum(const char* pDirName) noexcept
{
    try {
        boost::filesystem::path filePath(pDirName);
        filePath /= "numbers.txt";
        std::vector<Number> numbers = ReadNumbers(filePath);
        Number sum = std::accumulate(numbers.begin(), numbers.end(), Number(0));
        return sum;
    }
    catch (...) {
        return Number(std::nan(""));
    }
}

Notice that this time, our function is marked "noexcept", and indeed we are sure that no exceptions will leak out.  catch(...) is the way of transforming a throwing function into a noexcept function.

If you're still not convinced that catch(...) is the proper approach, consider what the introduction of noexcept in C++11 implies.  noexcept is a boolean concept, supplanting the multi-typed throw()-specifier.  The noexcept boolean argument can be calculated on template functions, whereas no such facility exists for throw()-specifiers.  noexcept in C++17 is also becoming part of a function's type, paving the way for compile-time checking of exception propagation.  Overall the language now considers "whether an exception was thrown" to be more important than the type of the exception.

I will take this opportunity to point out that if you're with me so far, you'll agree that this section of the C++ FAQ about exceptions  is dispensing really bad advice.  For extra entertainment value, consult the corresponding section of the C++FQA.

Finally, note that RAII is still the preferred way of protecting resources.  In the example, we still use a std::vector to avoid leaking memory, and so on.

Compile-time errors are still missing?

That's right.  Unfortunately, the standards committee chose the runtime-checked approach, which was (and still is) highly contentious.  This SO post sheds some light, although ultimately it was a hasty decision that simply is.

IMO we could live with a stop-gap of compile-time checked warnings whenever a possibly-throwing expression appears in an unguarded section of a noexcept function.  This shouldn't be difficult to implement, perhaps even as a clang tool or plugin.

What types of exceptions should I throw?

Any type you want!  Just don't catch them by type.

I suggest sticking to std::exception or a type derived from it.  Since the type will be ignored by the catch-handler, only the std::exception::what() method will be relevant for diagnostics.

e.what() about std::exception?

std::exception has a const char* what() const method, which can be used to print a diagnostic message in a catch-handler.  Since catch(...) doesn't provide access to the exception object, what can we do?  One option is to repeat the error-handling in both a catch-std::exception and catch(...) block:
    try {
        return foo();
    }
    catch (const std::exception& e) {
        std::cerr << "caught exception: " << e.what() << std::endl;
        return Error();
    }
    catch (...) {
        std::cerr << "caught exception: " << std::endl;
        return Error();
    }
But that's way more verbose, and it's the exception to the rule of "the only legitimate catch-handler is catch(...)".  One alternative is to hide the try/catch inside a function, like so:
template <class TCatch, class TTry>
auto try_(TTry&& try_block, TCatch&& catch_block) -> decltype(try_block())
{
    try {
        return try_block();
    }
    catch (const std::exception& e) {
        return catch_block(e);
    }
    catch (...) {
        return catch_block(std::exception());
    }
}

// and use it like so
    return try_(
        [&]() {
            return foo();
        },
        [&](const std::exception& e) {
            std::cerr << "caught exception: " << e.what() << std::endl;
            return Error();
        });

I'm not particularly advocating this technique, but it has two interesting properties:
  1. It guarantees your catch-handler sees a std::exception.
  2. It makes try/catch usable in expressions, whereas the keywords are limited to statements.

Additional Resources

The go language uses a non-typed exception mechanism, described in Defer, Panic, and Recover.

The java language has the option of checked exceptions, the moral equivalent of C++ throw()-specifiers, but that are checked at compile-time.

C++ exception specifications are evil.

No comments:

Post a Comment