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?
- 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.
- Copy/move-constructors and assignment-operators can only fail via exception.
- We want to write "expressive" code, with use of math operators, method-chaining.
- 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:
- write trivial noexcept constructors + Init functions
- manually write clone functions
- use error-codes, nullables, optionals, or other alternative error-handling strategies
- 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:
- It guarantees your catch-handler sees a std::exception.
- It makes try/catch usable in expressions, whereas the keywords are limited to statements.
Additional Resources