Exceptions in C++
Table of Contents
Published on 14 October 2024.
Last modified 16 October 2024.
I am not a fan of exceptions. Unfortunately, exceptions are an integral part of standard C++. This article is not meant to apologia for exceptions, but an earnest look at how to use them responsibly and overcome the difficulties in handling them. First, I will discuss what exceptions are, and the rationale for their use. Then, I will explain why I dislike exceptions so much. Finally, I will summarize in the conclusions section.
1. The Actions and Mechanisms of Exceptions
1.1. Exceptions are Defined by Throwing
There is no formal definition of what an exception actually is. In fact, any object, including integers, may act as an exception when thrown. The explanation provided by cppreference states that
Throwing an exception initializes an object with dynamic storage duration, called the exception object. If the type of the exception object would be one of the following types, the program is ill-formed:
- an incomplete type
- an abstract class type
- a pointer to an incomplete type other than (possibly cv-qualified) void
So, any object may be an exception, and if the object isn't a complete type, it's ill-formed and will not compile. Of note, exception objects have dynamic storage duration; the implementation of that dynamic storage duration is not specified. On Windows, MSVC stores exceptions on the stack. GCC and clang store exceptions on the heap. There may exist implementations that use some statically allocated storage, but I'm unaware of such.
When an exception is thrown, a copy of the object being thrown is made in the
dynamic storage, and the stack unwinds until it reaches the exception handler. I
believe it's easier to illustrate this in an example. Consider a class
Foo
whose destructor has visible side effects so we can see what happens.
#include <cstdio> struct Foo{ int value; ~Foo() { printf("The value of this Foo object is %d\n", value); } }; void func1(); void func2(); int main(int argc, char** argv) { Foo foo0{0}; try { func2(); } catch (int exc) { printf("Caught exc %d\n", exc); } try { func1(); } catch (int exc) { printf("Caught exc %d\n", exc); } if (argc > 1) { func2(); } return 0; } void func1() { Foo foo1{1}; func2(); printf("This will never be printed.\n"); } void func2() { Foo foo2{2}; throw 3; }
This program creates an instance of foo in its main function, so its destructor will be called when the program exits. It has exception handlers for calls to func1 (which calls func2) and func2, but it also has an unguarded call to func2 that will be invoked if more arguments are supplied to the program.
$ ./throw_test The value of this Foo object is 2 Caught exc 3 The value of this Foo object is 2 The value of this Foo object is 1 Caught exc 3 The value of this Foo object is 0
When no additional arguments are provided, it works as you would expect. Calling func 2, it invokes the destructor for the foo it created. Likewise, when calling 1, the destructors are traversed so both func 2's foo and func 1's foo are destroyed in reverse order as it winds back up the to the exception handler.
$ ./throw_test abort The value of this Foo object is 2 Caught exc 3 The value of this Foo object is 2 The value of this Foo object is 1 Caught exc 3 terminate called after throwing an instance of 'int' aborted
When no handler is provided,
std::terminate
is invoked, which means that the
foo
in main does not get its destructor called. It is important to let no
normal exceptions escape beyond main if you want to clean you program up
correctly! See
cppreference
:
If an exception is thrown and not caught, including exceptions that escape the initial function of std::thread, the main function, and the constructor or destructor of any static or thread-local objects, then std::terminate is called. It is implementation-defined whether any stack unwinding takes place for uncaught exceptions.
Destructors and move constructors must not throw exceptions; throwing an exception before the current in-flight exception reaches its handler guarantees program termination.
There are more details to stack unwinding, but suffice it to say that it is an orderly process. The only thing that you have to worry about is resources acquired without an object whose destructor will release them. For this reason, I recommend that all code that performs inter-op with C to wrap the use of resources in a class whose destructor releases them.
1.2.
The
noexcept
Specifier
The
noexcept
specifier is a declaration that the function will not throw, and
will not pass exceptions from functions that throw which it calls. The only
functions you should mark as
noexcept
are move constructors and move
assignment operators. The
noexcept
specifier is used by compilers to make
optimizations; functions marked as
noexcept
which throw or do not handle an
exception pass an exception will trigger
std::terminate.
2. Why I Dislike Exceptions
2.1. Exceptions Make Static Analysis Difficult
Whenever you call a function, you have no clue if that function will throw an exception or not. It is unreasonable to use a try/catch expression on every function call, so the responsible thing is to only try to catch exceptions that you think are reasonable to come from a function call. The problem, is that it is impossible to know that a function will not throw unless you can look at its source, down to the most fundamental level. Further complicating matters is hidden control flow. Take, for example, the following snippet:
std::vector<Foo> fooVec(); for (std::size_t x = 0; x < 10; ++x) { Foo foo{x, someOtherArgs}; foo.mutate(evenMoreArgs); fooVec.push_back(std::move(foo)); }
Where can exceptions happen in this code? When
foo
is constructed, it uses the
default
Allocator.
The default allocator throws
std::bad_alloc
if allocation
fails. As an aside, Shepherd's Oasis has an
interesting article
about fallible
and infallible allocators. Now there's an even more serious problem with this:
malloc,
or other underlying implementations of the allocation function, will
actually return valid pointers when the system is configured to use memory
overcommitment; this is the default case for linux. When a linux system does run
out of memory and invokes the
OOM killer,
SIGKILL
is sent to a process to
abruptly terminate it, without allowing it to clean up after itself. In this
regard, the C++ runtime on a typical linux system is ill equipped to handle OOM
situations. On the other hand, if for some reason a
std::bad_alloc
exception
is thrown, there isn't much of a good way to handle it other than to allow it to
unwind the stack, and exit from the program. That is far more graceful than
receiving
SIGKILL.
There's one more small problem with
std::bad_alloc
being
thrown - throwing an exception may require additional heap allocation, depending
on the platform, which may cause an additional exception to be thrown in the
middle of the first exception being thrown! See the quote below, from the
cppreference
documentation.
If any function that is called directly by the stack unwinding mechanism, after initialization of the exception object and before the start of the exception handler, exits with an exception, std::terminate is called. Such functions include destructors of objects with automatic storage duration whose scopes are exited, and the copy constructor of the exception object that is called (if not elided) to initialize catch-by-value arguments.
Ignoring the standard library for a moment, non-trivial applications will have other dependencies, sometimes including libraries for which the vendors will only supply binaries. If they have good documentation, they may note which functions will throw exceptions, but documentation is often out of date.
It would be nice if the syntax of C++ required marking functions which throw or calls other functions from which exceptions may come from further calls. Interestingly, this is similar to the way that Java was designed with so-called checked exceptions, but the designers gave an escape mechanism with unchecked exceptions. I am not a Java programmer, so forgive me if my understanding is wrong. From what I understand, the prevailing practice is to catch checked exceptions, and then throw an unchecked exception. No good design can't be overcome by bad practice.
2.2. Exceptions Have Unpredictable Performance
This is a complaint that is most valid in the context of embedded software, particularly flight software. Of course, proponents of exceptions will explain that when the happy path is taken, exceptions are much faster. The problem is that when the sad path is taken enough, exceptions are actually much slower than checking returns. See P2544R0 for some hard numbers, and this report (which should be taken with a grain of salt). There is also the matter of binary bloat from RTTI (run-time type information) which is necessary to catch exceptions. The specification does not state this, but it is implicit and there is no implementation of C++ with exceptions which does not rely on RTTI. It is trivial to prove that RTTI is required to catch exceptions:
try { functionWithMultipleThrows(); } catch (ExcA& excA) { log(excA); } catch (ExcB& excB) { log(excB); } catch (ExcC& excC) { log(excC); }
How does the catch block know which kind of exception to catch other than with
RTTI? It is true that you can compile with exceptions and
-fno-rtti
, but the
compiler will actually still generate RTTI for the exceptions, as noted by
Khalil Estell
in his valiant effort to make exceptions palatable in firmware.
2.3. Exceptions Require Hidden Resource Management
C++ was designed in such a way that construction expressions don't have a type;
because they have no associated type, you can't check a result of the expression
to know if it succeeded or failed, without the constructor throwing an
exception. A workaround to avoiding throwing an exception is to add an init
method with a result type to check that must be called before the object can be
used, or to have an internal 'valid' variable, and expect the consumer to check
the state of the object. This is a very unpleasant way to write code, and is
antithetical
to the
RAII
idiom. I believe that RAII is significantly
overrated; I think a better approach is to use
named constructors
which return a
result that is either the desired object on success, or some other type on
error. Then, the programmer must check the result and do the correct thing - or
blithely access the success variant and enjoy either a thrown exception from
std::variant
or undefined behavior if it's implemented as a tagged
union. Ultimately, it is the programmer's job to do the correct thing - I just
think that the nonlocality of exceptions makes that job significantly more
difficult.
While on the topic of RAII, destructors, and exceptions, if any code which requires cleanup is not encapsulated in a resource managing class and an exception is thrown, that cleanup will not be called. Such code is called exception unsafe, and is dangerous to be exposed to Exceptions. For this reason, exceptions must not be exposed to a C ABI unless they are caught.
I can think of one good reason to use exceptions: implementing assertions with exceptions allows testing of assertions. The problem is that you don't want to catch those exceptions in runtime code, and instead terminate.
2.4. Exceptions Hamper Parallelization
The OpenMP 5.2 specification states that:
- A throw executed inside a region that arises from a thread-limiting directive must cause execution to resume within the same region, and the same thread that threw the exception must catch it. If the directive is also exception-aborting then whether the exception is caught or thethrow results in runtime error termination is implementation defined.
Jason Patterson has this to say about exceptions in parallel code:
The very idea of rollback/unwinding which is so central to exception handling more-or-less inherently implies that there is a sequential call chain to unwind, or some other way to "go back" through the callers to find the nearest enclosing catch block. This is horribly at odds with any model of parallel programming, which makes exception handling very much less than ideal going forward into the many-core, parallel programming era which is the future of computing.
…
The simple fact is the concept of rollback/unwinding just doesn't work very well in a highly parallel situation, even a simple one like fork/join, let alone more sophisticated and useful models like thread pools or CSP/actors. Trying to retrofit exceptions and rollback/unwinding into a parallel environment seems like an exercise in complexity, frustration and ultimately futility.
Microsoft has this to say about exceptions in the concurrency runtime:
You can omit this final task-based continuation if you don't have specific exceptions to catch. Any exception will remain unhandled and can terminate the app.
Cppreference states for the
execution policy
used by
std::for_each
, that:
During the execution of a parallel algorithm with any of these execution policies, if the invocation of an element access function exits via an uncaught exception, std::terminate is called, but the implementations may define additional execution policies that handle exceptions differently.
2.5. isocpp FAQ on exceptions
All quotes in this section are found on the Standard C++ Foundation FAQ page for exceptions .
Using exceptions for error handling makes your code simpler, cleaner, and less likely to miss errors.
Simpler is debatable, and it's not a good goal to strive for in isolation. The
simplest code does nothing at all, but isn't very useful. Sometimes you must
handle things with the required amount of complexity. Cleaner is a buzzword, and
in the sense it's used here, I would disagree. Exceptions remove locality of
error handling from where the error comes from. With return codes, there is a
traceable path. It's just as possible to throw away exceptions as it is to
willfully discard a
nodiscard
attributed object, and an uncaught exception
will simply call
std::terminate
on your program.
try { functionWithThrowOfA(); functionWithThrowOfB(); functionWithThrowOfC(); } catch (...) { /* Assumption that only A, B, or C will be thrown */ /* cleanup */ /* also catches all other exceptions! */ /* may catch bad_alloc or logical_error */ }
You might say that there should either be separate catch blocks for each of the exception type, or entirely separate try and catch blocks for those statements, or to move the exception handling up higher. This clearly disregards the reason of "cleaner" code. The biggest problem I see with exceptions in control flow is that it presents a path that maps from one to many (any?) - when you throw, you don't know where the exception will be caught, other than ultimately by the runtime if no other handler is present.
And further, on the topic of 'clean' code, how clean is it to leak your abstraction to your caller? It's irresponsible to swallow every exception, so the correct thing is to catch all the exceptions your function may throw, in their own catch blocks. It's not so clean looking at 4 or 5 catch blocks, at least no cleaner than switching over a status code.
I will cede the point that it is easier to use a
nodiscard
object and not
properly handle its underlying error.
It is always the responsibility of the
programmer to do the right thing.
Humans are fallible, so we have code
reviews. Even the most senior devs get their code reviewed. My opinion is that
accidentally ignoring a returned value is no different from any other semantic
bug.
First of all there are things that just can’t be done right without exceptions. Consider an error detected in a constructor; how do you report the error?
That's a fantastic point! And the answer is simple: use a named constructor
whose return type is a result, like the C++23's
std::expected
, or
abseil's
absl::StatusOr<T>
, or roll your own using C++17's
std::variant.
You can still
keep cleanup code in the destructor, and not have to worry about your
constructor throwing.
So writing constructors can be tricky without exceptions, but what about plain old functions? We can either return an error code or set a non-local variable (e.g., errno)… The trouble with return values are that choosing the error return value can require cleverness and can be impossible:
My retort, once again, is to use results. There is also a code block associated with this quote.
double d = my_sqrt(-1); // return -1 in case of error if (d == -1) { /* handle error */ } int x = my_negate(INT_MIN); // Duh?
my_sqrt
either knows that its domain is safe to handle, or it can use a result
type, or it can trap un undefined behavior. I don't think this should be treated
differently from a bad array access. If it's such a present possibility, then
maybe the domain should change to complex numbers instead.
my_negate
should
also know its domain, or use a result type, or the input should be validated by
the caller prior to calling it, or again, trap.
Skipping further down into the FAQ, there's a question posed as "I'm interpreting the previous FAQs as saying exception handling is easy and simple; did I get it right?"
No! Wrong! Stop! Go back! Do not collect $200.
The message isn’t that exception handling is easy and simple. The message is that exception handling is worth it. The benefits outweigh the costs.
Here are some of the costs:
- Exception handling is not a free lunch. It requires discipline and rigor. To understand those disciplines, you really should read the rest of the FAQ and/or one of the excellent books on the subject.
- Exception handling is not a panacea. If you work with a team that is sloppy and undisciplined, your team will likely have problems no matter whether they use exceptions or return codes. Incompetent carpenters do bad work even if they use a good hammer.
- Exception handling is not one-size-fits-all. Even when you have decided to use exceptions rather than return codes, that doesn’t mean you use them for everything. This is part of the discipline: you need to know when a condition should be reported via return-code and when it should be reported via an exception.
- Exception handling is a convenient whipping boy. If you work with people who blame their tools, beware of suggesting exceptions (or anything else that is new, for that matter). People whose ego is so fragile that they need to blame someone or something else for their screw-ups will invariably blame whatever "new" technology was used. Of course, ideally you will work with people who are emotionally capable of learning and growing: with them, you can make all sorts of suggestions, because those sorts of people will find a way to make it work, and you’ll have fun in the process.
Fortunately there is plenty of wisdom and insight on the proper use of exceptions. Exception handling is not new. The industry as a whole has seen many millions of lines of code and many person-centuries of effort using exceptions. The jury has returned its verdict: exceptions can be used properly, and when they are used properly, they improve code.
Are the costs justified? Let's go through the points one by one.
- Exception handling is not a free lunch. It requires discipline and rigor. To understand those disciplines, you really should read the rest of the FAQ and/or one of the excellent books on the subject.
Exception handling is indeed not a free lunch. There is a size and performance penalty to using exceptions; the size comes from runtime type information, and the performance is that taking the sad path has a huge performance penalty.
- Exception handling is not a panacea. If you work with a team that is sloppy and undisciplined, your team will likely have problems no matter whether they use exceptions or return codes. Incompetent carpenters do bad work even if they use a good hammer.
This is such a strawman that I refuse to elaborate any further.
- Exception handling is not one-size-fits-all. Even when you have decided to use exceptions rather than return codes, that doesn’t mean you use them for everything. This is part of the discipline: you need to know when a condition should be reported via return-code and when it should be reported via an exception.
This fits in with the narrative about exceptions being only for exceptional purposes. When you have a hammer, everything starts to look like a nail. This also misses when you should trap or terminate instead.
- Exception handling is a convenient whipping boy. If you work with people who blame their tools, beware of suggesting exceptions (or anything else that is new, for that matter). People whose ego is so fragile that they need to blame someone or something else for their screw-ups will invariably blame whatever "new" technology was used. Of course, ideally you will work with people who are emotionally capable of learning and growing: with them, you can make all sorts of suggestions, because those sorts of people will find a way to make it work, and you’ll have fun in the process.
Again, a terrible strawman. Exceptions are no longer new, and they have a terrible reputation which they have well earned by the extreme amount of abuse they have received. I don't think the fundamental point he is making is wrong, but if this is supposed to still be an earnest answer at the time of publication of this article in 2024, it is an argument in bad faith.
3. Safely and Sanely Handling Exceptions
Let's say you're stuck with the STL. That means, for better or worse, you must deal with the reality of exceptions. I believe the best practice is to not throw your own exceptions, and to be aware of which functions you call which may invoke exceptions. Hopefully, the documentation you use is up to date.
- Catch what you can reason that the function can throw.
-
Don't catch everything - there are enough
std::logic_error
exceptions running amok which should be assertions that it is dangerous to do so. - Catch as close to where the exception will be thrown as possible.
- If you can't reasonably handle an exception you've caught, you may as well terminate.
4. Conclusions
Exceptions muddy control flow and have bad performance characteristics in sad
path code, but provide a better path for cleanup than
std::terminate
or
std::abort
. However, in the presence of stack or memory exhaustion, or a
programming bug, it is safer to use
std::terminate.
If no exception handler is
provided in the main function, a thrown exception will invoke
std::terminate
without unwinding the stack.
Implementing assertions using exceptions is tempting, but dangerous. Using an exception for assertions allows testing code to catch the exception to verify that violating assumptions produces the intended result. The issue is that in non-testing code, it is not desirable to be able to catch the exceptions.
If you are writing desktop or server software, do whatever floats your boat, but
be aware of what your
libraries expect.
I would still advocate for preferring
results (e.g.
std::expected
or
absl::StatusOr
) to exceptions.
If you are writing safety critical software, don't throw exceptions, except as
noted later in this paragraph. Use a try-catch block in your main function to
handle any reasonable exception you think may be thrown - this does not include
assertion failures, variant access failures, or other exceptions of their
ilk. Be cautious about calling std::exit or std::terminate unless you know that
all resources are released. Ensure that your assertions only use a single unique
type of exception for testing, or otherwise trap. Use the
nodiscard
attribute
vigorously.
5. Recommended Further Reading and Talks
Exceptionally Bad: The Misuse of Exceptions in C++ & How to Do Better - Peter Muldoon- CppCon 2023
Zero-overhead deterministic exceptions: Throwing values by Herb Sutter
C++ Exceptions Reduce Firmware Code Size - Khalil Estell - ACCU 2024
The Power of 10: Rules for Developing Safety-Critical Code
Why I Hate Exceptions by Xah Lee
Joel Spolsky on why he doesn't like Exceptions - Joel is the creator of stack overflow, and has a lot of interesting articles on his blog, Joel on Software.
Making Wrong Code Look Wrong - a further dive into exceptions by Joel Spolsky, as well as a historical account of the invention and abuse of Hungarian notation.
Cleaner, more elegant, and harder to recognize by Raymond Chen, senior Microsoft Windows developer.
Exceptions Considered Harmful by Jules May
Exception Handling Considered Harmful by Jason Patterson
The Error Model by Joe Duffy. This is a very long read, so I want to capture his conclusions here:
In summary, the final model featured:
- An architecture that assumed fine-grained isolation and recoverability from failure.
- Distinguishing between bugs and recoverable errors.
- Using contracts, assertions, and, in general, abandonment for all bugs.
- Using a slimmed down checked exceptions model for recoverable errors, with a rich type system and language syntax.
- Adopting some limited aspects of return codes – like local checking – that improved reliability.
…
Abandonment, and the degree to which we used it, was in my opinion our biggest and most successful bet with the Error Model. We found bugs early and often, where they are easiest to diagnose and fix. Abandonment-based errors outnumbered recoverable errors by a ratio approaching 10:1, making checked exceptions rare and tolerable to the developer.