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.

  1. Catch what you can reason that the function can throw.
  2. Don't catch everything - there are enough std::logic_error exceptions running amok which should be assertions that it is dangerous to do so.
  3. Catch as close to where the exception will be thrown as possible.
  4. 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.