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