5.3. Software exceptions - A more sophisticated error handling mechanism for your code

In the previous section, we saw that error codes were a simple way for reporting errors to calling code, however they had several shortcomings:

  • They block the return type of the function
  • They are easy to miss and don't really have good support from the compiler
  • They can't handle non-linear control flow

For these reasons, several modern programming languages introduced the concept of software exceptions. A software exception is similar to a hardware exception, but (as the name implies) realized purely in software. It provides a means for notifying code about error conditions without (ab)using the return type of a function, like you would with error codes. Just like hardware exceptions, software exceptions provide a means to escape the regular control flow of a program and thus provide non-linear control flow.

A software exception is typically a fancy name for a datastructure containing information about any sort of exceptional condition that might occur in your code. Most often, these will be errors, such as 'file not found' or 'unable to parse number from string' etc. Depending on the language, there are either specialized types for software exceptions (like in Java or C#) or almost every type is valid as an exception datastructure (like in C++).

A key part of exception handling, both in hardware and software, is the exceptional control flow that it enables: The occurrence of an exception causes the program to divert from its regular control flow and execute different code than it typically would. Just as hardware exceptions get deferred to exception handlers, so do software exceptions, with the difference that the hardware exception handlers are defined by the operating system, whereas software exception handlers are defined by the programmer.

Languages that have built-in support for exceptions do so by providing a bunch of keywords for raising exceptions (signaling other code that an exceptional condition has occurred) and for handling exceptions. For raising exceptions, typical keywords are throw (C++, Java, JavaScript, C#) or raise (Python, OCaml, Ruby, Ada). For handling exceptions, two keywords are used most of the time, one to indicate the region in the code for which the exception handler should be valid, and a second one for the actual exception handler. Most often, try/catch is used (C++, Java, JavaScript, C#, D), but there are other variants, such as try/with (OCaml), try/except (Python) or begin/rescue (Ruby).

Exceptions in C++

C++ is one of the few systems programming languages that support exceptions. Here is a small example on how to use exceptions in C++:

#include <iostream>
#include <exception>

void foo() {
    throw 42;
}

int main() {
    try {
        foo();
    } catch(...) {
        std::cout << "Exception caught and handled!" << std::endl;
    }
    return 0;
}

Run this example

As you can see, we use throw to throw an exception (here simply the value 42). When we want to handle exceptions, we surround the code for which we want to handle exceptions with a try/catch clause. Code that might throw exceptions goes inside the try clause, and the code that handles exceptions goes inside the catch clause. If we write catch(...) we are catching every possible exception, at the cost of not knowing what the actual exception value is.

Instead of throwing raw numbers, C++ provides a number of standard exception types in the STL. There is a base type std::exception, and then a bunch of subtypes for various exceptional conditions. We can use these types to specialize our catch statement to catch only the types of exceptions that we are interested in:

#include <iostream>
#include <exception>

void foo() {
    throw std::runtime_error{"foo() failed"};
}

int main() {
    try {
        foo();
    } catch(const std::runtime_error& ex) {
        std::cout << "Exception '" << ex.what() << "' caught and handled!" << std::endl;
    }
    return 0;
}

Run this example

Since the exception types are polymorphic, we can also write catch(const std::exception& ex) and catch every exception type that derives from std::exception. You can even write your own exception type deriving from std::exception and it will work with this catch statement as well. As you can see from the example, all subtypes of std::exception also carry a human-readable message, which is defined when creating the exception (std::runtime_error{"foo() failed"}) and can be accessed through the virtual function const char* what().

Examples of exceptions in the STL

There are many functions in the C++ STL that can throw exceptions, as a look into the documentation tells us. Here are some examples:

  • std::allocator::allocate(): The default memory allocator of the STL will throw an exception if the requested amount of memory exceeds the word size on the current machine, or if the memory allocation failed, typically because the underlying system allocator (malloc) ran out of usable memory.
  • std::thread: As an abstraction for a thread of execution managed by the operating system, there are many things that can go wrong when using threads. An exception is thrown if a thread could not be started, if trying to detach from an invalid thread, or if trying to join with a thread that is invalid or unjoinable.
  • The string conversion functions std::stoi and std::stof (and their variants): Since these functions convert strings to integer or floating-point numbers, a lot can go wrong. They are a great example for a function that uses exceptions, because the return value of the function is already occupied by the integer/floating-point value that was parsed from the string. If parsing fails, either because the string is not a valid number of its size exceeds the maximum size of the requested datatype, an exception is thrown.
  • std::vector: Some of its methods can throw an exception, for example if too many elements are requested in reserve or an out-of-bounds index is passed to at.
  • std::future::get: We will cover futures in a later chapter, but they are essentially values that might be available in the future. If we call get on such a future value and the value has not yet been computed, an exception can be raised. An interesting feature of std::future is that computing the value may itself raise an exception, but it won't do so right away. Instead, it will raise the exception once get is called.

Exception best practices in C++

Exception handling in C++ can be tricky because there are little rules built into the language. As such, there are a handful of hard rules that the C++ language enforces on the programmer when dealing with exceptions, and a plethora of 'best practices'. Let's start with the hard rules!

Exception rule no. 1: Never throw exceptions in a destructor

The reasoning for this rule is a bit complicated and has to do with stack unwinding, a process which we will learn about later in this chapter. In a nutshell, when an exception is thrown, everything between the throw statement and the next matching catch statement for this exception has to be cleaned up properly (i.e. all local variables). This is done by calling the destructor of all these local variables. What happens if one of these destructors throws an exception as well? Now we have two exceptions that have to be handled, but we can handle neither before the other. For that reason, the C++ language defines that in such a situation, std::terminate will be called, immediately terminating your program. To prevent this, don't throw exceptions in destructors!

Since C++11, destructors are implicitly marked with the noexcept keyword, so throwing from a destructor will immediately call std::terminate once the exception leaves the destructor. Note that catching the exception within the destructor is fine though!

Exception rule no. 2: Use noexcept to declare that a function will never throw

C++11 introduced the noexcept keyword, which can be appended to the end of a function declaration, like so:

void foo() noexcept {}

With noexcept, you declare that the given function will never throw an exception. If it violates this contract and an exception is thrown from this function, std::terminate is immediately called! There are also some complicated rules for things that are implicitly noexcept in C++, such as destructors or implicitly-declared constructors. noexcept is also part of the function signature, which means that it can be detected as a property of a function in templates. A lot of the STL containers make use of this property to adjust how they have to deal with operations on multiple values where any one value might throw. To undestand this, here is an example:

Think about std::vector::push_back: It might require a re-allocation and copying/moving all elements from the old memory block to the new memory block. Suppose you have 10 elements that must be copied/moved. The first 4 elements have been copied/moved successfully, and now the fifth element is throwing an exception is its copy/move constructor. The exception leaves the push_back function, causing the vector to be in a broken state, with some of its elements copied/moved and others not. To prevent this, push_back has to deal with a potential exception during copying/moving of every single element, which can be a costly process. If instead push_back detects that the copy/move constructor of the element type is noexcept, it knows that there can never be an exception during copying/moving, so the code is much simpler! The way a function on an object behaves when it encounters an exception is called the exception guarantee and is the subject of the next rule!

Exception rule no. 3: Always provide some exception guarantee in your functions

There are four kinds of exception guarantees: No exception guarantee, basic exception guarantee, strong exception guarantee, and nothrow exception guarantee. They all deal with what the state of the program is after an exception has occurred within a function, and they are all strict supersets of each other.

A function with no exception guarantee makes no assumptions over the state of the program after an exception as occurred. This is equivalent to undefined behaviour, there might be memory leaks, data corruption, anything. Since exceptions are a form of errors, and errors are to be expected in any program, it is a bad idea to have no exception guarantee in a function.

The next stronger form is the basic exception guarantee. It just states that the program will be in a valid state after an exception has occurred, leaving all objects intact and leaking no resources. This however does not mean that the state of the program is left unaltered! In the case of std::vector::push_back, it would be perfectly valid to end up with an empty vector after an exception has occurred, even if the vector contained elements beforehand.

If you want an even stronger exception guarantee, the strong exception guarantee is what you are looking for. Not only does it guarantee that the program is in a valid state after an exception has occured, it also defines that the state of the program will be rolled back to the state just before the function call that threw an exception. std::vector::push_back actually has the strong exception guarantee, so an exception in this function will leave the vector in the state it was in before push_back was called!

The last level of guarantee is the nothrow exception guarantee, which is what we get if we mark a function noexcept. Here, we state that the function is never allowed to throw an exception.

Exception rule no. 4: Use exceptions only for exceptional conditions

This rules sounds obvious, but it still makes sense to take some time to understand what we mean by 'exceptional conditions'. In general, these are situations which, while being exceptional, are still expected to happen in the normal workings of our program. In the end this comes down to likelyhood of events. If our program reads some data from a file, how likely is it that the file we are trying to open does not exist or our program does not have the right permissions? This depends on your program of course, but let's say that the likelyhood of the file not existing is between 1%-5%. Most of the time, it won't happen, but are you willing to bet your life on it? Probably not, so this is a good candidate for an 'exceptional condition' and hence for using exceptions (or any other error handling mechanism). We might go as far as saying that anything that has a non-zero chance of failing is a candidate for an exceptional condition (we will see what this means in the next chapter), however for some situations, it is no practical to assume that they will ever occur in any reasonable timeframe. There are probably situations where a bug in a program was caused by a cosmic ray flipping a bit in memory, but these conditions are so rare (and almost impossible to detect!) that we as programmers typically don't care about themIn addition to the rarity of such an event, it is also a hardware error, not a software error! As a sidenote: While the average programmer might not care about bit flips due to ionizing radiation, the situation is different in space, where radiation-hardened electronics are required!.

So you use exceptions (or any kind of error handling) for exceptional conditions, fine. But what is not an exceptional condition? There are two other categories of situations in a program that stand apart from exceptional conditions, even though they also deal with things straying from the expected behaviour: Logical conditions and assertions.

Logical conditions are things like bounds-checks when accessing arrays with dynamic indices, or null-checks when dereferencing pointers. In general, these are part of the program logic. Take a look at a possible implementation of the std::vector::at function:

template <class T, class Alloc>
typename vector<T, Alloc>::const_reference
vector<T, Alloc>::at(size_t idx) const
{
    return this->_begin[idx];
}

What happens if we call this function with an index that is out of bounds (i.e. it is greater than or equal the number of elements)? In this case, we would access memory out of bounds, which results in undefined behaviour, meaning our program would be wrong! Now we have to ask ourselves: Can this function be called with an index that is out of bounds? Here, a look at the domain of the function helps, which is the set of all input parameters. For the function std::vector::at, which has the signature size_t -> const T&, the domain is the set of all values that a size_t can take. Recall that size_t is just an unsigned integer-type as large as the word size on the current machine, so it is the same as uint64_t on a 64-bit machine. Its maximum value is \( 2^{64}-1 \), which is a pretty big value. Clearly, most vectors won't have that many elements, so it is a real possiblity that our function gets called with an argument that would produce undefined behaviour. To make our program correct, we have to recognize this as a logical condition that our program has to handle. How it does this is up to us as programmers, but it does have to handle it in one way or another. The typical way to do this is to use some sort of check in the code that separates the values from the domain into valid and invalid values for the function, and use some error handling mechanism for the invalid values, for example exceptions:

template <class T, class Alloc>
typename vector<T, Alloc>::const_reference
vector<T, Alloc>::at(size_t idx) const
{
    if (idx >= size())
        this->throw_out_of_range();
    return this->_begin[idx];
}

What about assertions? Assertions are invariants in the code that must hold. If they don't, they indicate an error in the program logic. For functions, invariants refer to the conditions that your program must be in so that calling the function is valid. It would be perfectly valid to define the std::vector::at function with the invariant that it must only be called with an index that lies within the bounds of the vector. In fact, this is exactly what std::vector::operator[] does! Its implementation would be equal to our initial code for std::vector::at:

template <class T, class Alloc>
typename vector<T, Alloc>::const_reference
vector<T, Alloc>::operator[](size_t idx) const
{
    return this->_begin[idx];
}

Notice how it is a design choice whether to use explicit error reporting (and thus allowing a wider range of values) or using function invariants. std::vector::operator[] is faster due to its invariants (it does not have to perform a bounds check), but it is also less safe, because violating the invariants results in undefined behaviour, which can manifest in strange ways. As a compromise, we can use assertions, which are checks that are only performed in a debug build. If an assertion is violated, the program is terminated, often with an error message stating which assertion was violated. We can use assertions like this:

template <class T, class Alloc>
typename vector<T, Alloc>::const_reference
vector<T, Alloc>::operator[](size_t idx) const
{
    assert(idx < size(), "Index is out of bounds in std::vector::operator[]");
    return this->_begin[idx];
}

So assertions are for reporting programmer errors, while exceptions are for reporting runtime errors.

Lastly, error reporting should not be abused to implement control flow that could be implemented with the regular control flow facilities of a language (if, for etc.). Here is an example of what not to do:

#include <iostream>
#include <exception>
#include <vector>

void terrible_foreach(const std::vector<int>& v) {
    size_t index = 0;
    while(true) {
        try {
            auto element = v.at(index++);
            std::cout << element << std::endl;
        } catch(const std::exception& ex) {
            return;
        }
    }
}

int main() {
    terrible_foreach({1,2,3,4});
    return 0;
}

Run this example

While this code works, it abuses the non-linear control flow that exceptions provide to implement something that would be much easier and more efficient if it were implemented with a simple if statement.

How exceptions are realized - A systems perspective

We will conclude this chapter with a more in-depth look at how exceptions are actually implemented under the hood. The non-linear control flow has a somewhat magical property, and it pays off to understand what mechanisms modern operating systems provide to support software exceptions.

At the heart of exception handling is the ability to raise an exception at one point in the code, then magically jump to another location in the code and continue program execution from there. In x86-64 assembly language, jumping around in code is not really something magical: We can use the jmp instruction for that. jmp just manipulates the instruction pointer to point to a different instruction than just simply the next instruction. So say we are in a function x() and throw an exception, with a call chain that goes like main() -> y() -> x(), and a catch block inside main(). We could use jmp to jump straight from x() to the first instruction of the catch block in main().

Image showing a non-linear jump from x() to main()

If we do this, we skip over a lot of code that never gets executed. Recall that functions include automatic cleanup of the stack in C++. The compiler generated a bunch of instructions for stack cleanup for the functions x() and y(). If we use jmp to exit x() early, we skip these stack unwinding instructions. This has the effect that our variables are not cleaned up correctly...

This is not the only problem with using jmp! We also have to figure out where to jump to, i.e. where the correct exception handler for our exception is located in the code. For the simple example, the compiler might be able to figure this out, but as soon as dynamic dispatch comes into play, the location of the next matching exception handler can only be determined at runtime!

To automatically clean up the stack, we have to get some help from the operating system. Linux provides two functions called setjmp and longjmp for doing non-local jumps in the code while correctly cleaning up stack memory. setjmp memorizes the current program state (registers, stack pointer, instruction pointer) at the point where it is called, and longjmp resets the information to what was stored with setjmp. Windows has a similar mechanism, and the C standard library even provides a platform-agnostic implementation for these functions (also called setjmp and longjmp). setjmp is also one of those strange functions that return twice (like fork): Once for the regular control flow, and once after resuming from a longjmp call. Wikipedia has a great example that illustrates how these two functions can be used to realize exceptions in the C language (which has no built-in exception support):

#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>

enum { SOME_EXCEPTION = 1 } exception;
jmp_buf state;

int main(void)
{
  if (!setjmp(state))                      // try
  {
    if (/* something happened */)
    {
      exception = SOME_EXCEPTION;
      longjmp(state, 0);                  // throw SOME_EXCEPTION
    }
  } 
  else switch(exception)
  {             
    case SOME_EXCEPTION:                  // catch SOME_EXCEPTION
      puts("SOME_EXCEPTION caught");
      break;
    default:                              // catch ...
      puts("Some strange exception");
  }
  return EXIT_SUCCESS;
}

Now, in C we don't have destructors, so simply cleaning up the stack memory might be enough, but in C++ we also have to call the destructor for every object within the affected stack region. setjmp/longjmp don't help here. Instead the compiler effectively has to insert code that memorizes how to correctly unwind the stack in every function that might throw an exception. When an exception gets thrown, the code then walks up the stack using these markers to perform correct clean up, until an appropriate exception handler has been found.

The details depend on the compiler and hardware architecture. As an example, gcc follows the Itanium C++ ABI. ABI is shorthand for application binary interface (similar to API, which is application programming interface) and defines how function calls and data structures are accessed in machine code.

Correctly handling exceptions requires extra code, which can be slower than if we didn't use exceptions. Older implementations incured a cost even if an exception never occurred, modern compilers have gotten better at optimizing this, so exceptions often have virtually zero overhead in the 'happy path', i.e. when no exception is raised. Raising an exception and handling it still has overhead though. Which brings us to the downsides of using exceptions!

The downsides of using exceptions

While exceptions are quite convenient for the programmer, they also have quite substantial downsides. In the past, the performance overhead of exceptions was the major argument against using them. C++ compilers usually provide flags to disable exceptions, such as -fno-exception in the gcc compiler. Nowadays, this is not such a strong argument anymore as compilers have gotten better at optimizing exceptions.

The non-local control flow is actually one of the bigger downsides of exceptions. While it is very convenient, it can also be hard to grasp, in particular when call stacks get very deep. Jumping from c() to a() can be mildly confusing to figure out, jumping from z() to a() through 24 different functions will become almost impossible. This issue is closely related to the next issue: Exceptions are silent. This is really more of a C++ problem than a general problem with exceptions, but in C++, just from looking at the signature of a function, there is no way of knowing what - if any - exception(s) this function might throw. Sure, there is noexcept, but it only tells you that a function does not throw. A function that is not noexcept might throw any exception, or - even worse - it could also be noexcept but it was not written as suchThis is similar to const. A function that is not const might mutate data, but it could also be that someone simply forgot to add const to the function signature. For this reason, the term const-correctness has been established in the C++ community, and it is pretty well understood that making as many things const as possible is a good idea. As we saw, Rust takes this to its extreme and makes everything immutable ('const') by default.. There are mechanisms in other languages for making exceptions more obvious, Java for example has checked exceptions which are part of the function signature and thus have to be handled, but C++ does not have such a feature.

Lastly, from a systems programming perspective, exceptions are not always the right call. There still might be a larger performance overhead in an error case than if error codes are used, and not every error can be modelled with an exception. You can't catch a segmentation fault in a catch block! So catch might give a false sense of security in the context of systems programming.

Where to go from here?

So, error codes are bad, exceptions are also kind of bad. Where do we go from here? As you might have realized from the lack of Rust-code in this chapter, Rust does not support exceptions and instead has a different error handling mechanism. Where the Option<T> type from the last chapter was used to get rid of null, we will see in the next chapter how Rust's Result<T, E> type makes error handling quite elegant in Rust.