Designing interfaces with error handling
Error handling is an important and often overlooked part of the interface of functions and classes. Error handling is a heavily debated topic in C++, but often the discussions tend to focus on exceptions versus some other error mechanism. Although this is an interesting area, there are other aspects of error handling that are even more important to understand before focusing on the actual implementation of error handling. Obviously, both exceptions and error codes have been used in numerous successful software projects, and it is not uncommon to stumble upon projects that combine the two.
A fundamental aspect of error handling, regardless of programming language, is to distinguish between programming errors (also known as bugs) and runtime errors. Runtime errors can be further divided into recoverable runtime errors and unrecoverable runtime errors. An example of an unrecoverable runtime error is stack overflow (see Chapter 7, Memory Management). When an unrecoverable error occurs, the program typically terminates immediately, so there is no point in signaling these types of errors. However, some errors might be considered recoverable in one type of application but unrecoverable in others.
An edge case that often comes up when discussing recoverable and unrecoverable errors is the somewhat unfortunate behavior of the C++ standard library when running out of memory. When your program runs out of memory, this is typically unrecoverable, yet the standard library (tries) to throw a std::bad_alloc
exception when this happens. We will not spend time on unrecoverable errors here, but the talk De-fragmenting C++: Making Exceptions and RTTI More Affordable and Usable by Herb Sutter (https://sched.co/SiVW) is highly recommended if you want to dig deeper into this topic.
When designing and implementing an API, you should always reflect on what type of error you are dealing with, because errors from different categories should be handled in completely different ways. Deciding whether errors are programming errors or runtime errors can be done by using a methodology called Design by Contract; this is a topic that deserves a book on its own. However, I will here introduce the fundamentals, which are enough for our purposes.
There are proposals for adding language support for contracts in C++, but currently contracts haven't made it to the standard yet. However, many C++ APIs and guidelines assume that you know the basics about contracts because the terminology contracts use makes it easier to discuss and document interfaces of classes and functions.
Contracts
A contract is a set of rules between the caller of some function and the function itself (the callee). C++ allows us to explicitly specify some rules using the C++ type system. For example, consider the following function signature:
int func(float x, float y)
It specifies that func()
is returning an integer (unless it throws an exception), and that the caller has to pass two floating-point values. However, it doesn't say anything about what floating-point values that are allowed. For instance, can we pass the value 0.0 or a negative value? In addition, there might be some required relationship between x
and y
that cannot easily be expressed using the C++ type system. When we talk about contracts in C++, we usually refer to the rules that exist between a caller and a callee that cannot easily be expressed using the type system.
Without being too formal, a few concepts related to Design by Contract will be introduced here in order to give you some terms that you can use to reason about interfaces and error handling:
- A precondition specifies the responsibilities of the caller of a function. There may be constraints on the parameters passed to the function. Or, if it's a member function, the object might have to be in a specific state before calling the function. For example, the precondition when calling
pop_back()
on astd::vector
is that the vector is not empty. It's the responsibility of the caller ofpop_back()
to ensure that the vector is not empty. - A postcondition specifies the responsibilities of the function upon returning. If it's a member function, in what state does the function leave the object? For example, the postcondition of
std::list::sort()
is that the elements in the list are sorted in ascending order. - An invariant is a condition that should always hold true. Invariants can be used in many contexts. A loop invariant is a condition that must be true at the beginning of every loop iteration. Further, a class invariant defines the valid states of an object. For example, an invariant of
std::vector
is thatsize() <= capacity()
. Explicitly stating the invariants around some code gives us a better understanding of the code. Invariants are also a tool that can be used when proving that some algorithm does what it's supposed to do.
Class invariants are very important; we will therefore spend some more time discussing what they are and how they affect the design of classes.
Class invariants
As mentioned, a class invariant defines the valid states of an object. It specifies the relationship between the data members inside a class. An object can temporarily be in an invalid state during the time a member function is being executed. The important thing is that the invariant is upheld whenever the function passes the control to some other code that can observe the state of the object. This can happen when the function:
- Returns
- Throws an exception
- Invokes a callback function
- Calls some other function that might observe the state of the currently calling object; a common scenario is when passing a reference to
this
to some other function
It's important to realize that the class invariant is an implicit part of the precondition and postcondition for every member function of a class. If a member function leaves an object in an invalid state, the postcondition has not been fulfilled. Similarly, a member function can always assume that the object is in a valid state when the function is called. The exception to this rule is the constructors and the destructor of a class. If we wanted to insert code to check that the class invariant holds true, we could do that at the following points:
struct Widget {
Widget() {
// Initialize object…
// Check class invariant
}
~Widget() {
// Check class invariant
// Destroy object…
}
auto some_func() {
// Check precondition (including class invariant)
// Do the actual work…
// Check postcondition (including class invariant)
}
};
The copy/move constructors and copy/move assignment operators were left out here, but they follow the same pattern as the constructor and some_func()
, respectively.
When an object has been moved from, the object might be in some empty or reset state. This is also a valid state of the object and is therefore part of the class invariant. However, usually there are only a few member functions that can be called when the object is in this state. For example, you cannot call push_back()
, empty()
, or size()
on a std::vector
that has been moved from, but you can call clear()
, which will put the vector in a state where it is ready to be used again.
You should be aware, though, that this extra reset state makes the class invariant weaker and less useful. To avoid this state completely, you should implement your classes in such a way so that moved-from objects are reset to the state the object would have after default construction. My recommendation is to always do this, except in the very rare cases where resetting the moved-from state to the default state carries an unacceptable performance penalty. In that way, you can reason much better about moved-from states, and the class is safer to use because calling member functions on that object is fine.
If you can ensure that an object is always in a valid state (the class invariant holds true), you are likely to have a class that is hard to misuse, and if you have bugs in the implementation, they will usually be easy to spot. The last thing you want is to find a class in your code base and wonder whether some behavior of that class is a bug or a feature. Violation of a contract is always a serious bug.
In order to be able to write meaningful class invariants, we are required to write classes with high cohesion and with few possible states. If you have ever written a unit test for a class that you have authored yourself, you have probably noticed that while writing the unit test, it became clear that the API could be improved from the initial version. A unit test forces you to use and reflect on the interface of the class rather than the implementation details. In the same way, a class invariant makes you think about all the valid states an object could be in. If you find it hard to define a class invariant, it's usually because your class has too many responsibilities and handles too many states. Therefore, defining class invariants usually means that you end up with well-designed classes.
Maintaining contracts
Contracts are parts of the API that you design and implement. But how do you maintain and communicate a contract to the clients using your API? C++ has no built-in support for contracts yet, but there is ongoing work to add it to future versions of C++. There are some options, though:
- Use a library such as Boost.Contract.
- Document the contracts. This has the disadvantage that the contracts are not checked when running the program. Also, documentation tends to be outdated when the code changes.
- Use
static_assert()
and theassert()
macro defined in<cassert>
. Asserts are portable, standard C++. - Build a custom library with custom macros similar to asserts but with better control of the behavior of failed contracts.
In this book, we will use asserts, one of the most primitive ways of checking for contract violations. Still, asserts can be very effective and have an enormous impact on code quality.
Enabling and disabling asserts
Technically, we have two standard ways to assert things in C++: using static_assert()
or the assert()
macro from the <cassert>
header. static_assert()
is validated during the compilation of the code, and therefore requires an expression that can be checked during compile time rather than runtime. A failed static_assert()
results in a compilation error.
For asserts that can only be evaluated during runtime, you need to use the assert()
macro instead. The assert()
macro is a runtime check that is typically active during debugging and testing, and completely disabled when the program is built in release mode. The assert()
macro is typically defined something like this:
#ifdef NDEBUG
#define assert(condition) ((void)0)
#else
#define assert(condition) /* implementation defined */
#endif
This means that you can completely remove all the asserts and the code for checking the conditions by defining NDEBUG
.
Now, with some terminology from Design by Contract under your belt, let's focus on contract violations (errors) and how to handle them in your code.
Error handling
The first thing to do when designing APIs with proper error handling is to distinguish between programming errors and runtime errors. So, before we dive into error handling strategies, we will use Design by Contract to define what type of error we are dealing with.
Programming error or runtime error?
If we find a violation of a contract, we have also found an error in our program. For example, if we can detect that someone is calling pop_back()
on an empty vector, we know that there is at least one bug in our source code that needs to be fixed. Whenever a precondition is not met, we know we are dealing with a programming error.
On the other hand, if we have a function that loads some record from disk and cannot return the record because of a read error on the disk, then we have detected a runtime error:
auto load_record(std::uint32_t id) {
assert(id != 0); // Precondition
auto record = read(id); // Read from disk, may throw
assert(record.is_valid()); // Postcondition
return record;
}
The precondition is fulfilled, but the postcondition cannot be met because of something outside of our program. There is no bug in the source code, but the function cannot return the record found on disk because of some disk-related error. Since the postcondition cannot be fulfilled, a runtime error has to be reported back to the caller, unless the caller can recover from it itself by retrying and so on.
Programming errors (bugs)
In general, there is no point in writing code that signals and handles bugs in your code. Instead, use asserts (or some of the other alternatives mentioned previously) to make the developer aware of issues in the code. You should only use exceptions or error codes for recoverable runtime errors.
Narrowing the problem space by assumptions
An assert specifies what assumptions you, as the author of some code, have made. You can only guarantee that the code works as intended if all the asserts in your code hold true. This makes coding much easier because you can effectively limit the amount of cases that you need to handle. Asserts are also a tremendous help for your team when using, reading, and modifying code written by you. All the assumptions are clearly documented in the form of assert statements.
Finding bugs with asserts
A failed assert is always a serious bug. There are basically three options when you find an assert that fails during testing:
- The assert is correct, but the code is wrong (either because of a bug in the implementation of the function, or a bug on the call-site). In my experience, this is the most common case. Getting the asserts correct is usually easier than getting the code around them correct. Fix the code and test again.
- The code is correct, but the assert is wrong. Sometimes this happens and it is usually pretty uncomfortable if you are looking at old code. Changing or removing an assert that fails can be time consuming because you need to be 100% sure that the code actually works and understand why an old assert has suddenly started to fail. Usually, this is because of a new use case that the original authors did not think about.
- Both the assert and the code are wrong. This usually requires a redesign of the class or function. Maybe the requirements have changed, and the assumptions made by the programmer are no longer true. But don't despair; instead, you should be glad that those assumptions were explicitly written using asserts; now you know why the code is not working anymore.
Runtime asserts require testing, otherwise the asserts will not be exercised. Newly written code with many asserts usually breaks when testing. This doesn't mean that you are a bad programmer; it means that you have added meaningful asserts that catch some of the errors that otherwise could have made it to production. Also, bugs that make a test version of your program terminate are also likely to be fixed.
Performance impact
Having many runtime asserts in your code will most likely degrade the performance of your test builds. However, asserts are never meant to be used in the final version of your optimized program. If your asserts make your test build too slow to be usable, finding the set of asserts that slows down your code is usually easy to track in a profiler (see Chapter 3, Analyzing and Measuring Performance, for more info about profiling).
By having the release build of your program completely ignore all sorts of programming errors, your program will not spend time checking error states caused by bugs. Instead, your code will run faster and only spend time solving the actual problem it was meant to solve. It will only check for runtime errors that need to be recovered.
To summarize, programming errors should be detected when testing the program. There is no need to use exceptions or some other error handling mechanism for dealing with programming errors. Instead, a programming error should preferably log something meaningful and terminate the program to inform the programmer that the bug needs to be fixed. Following this guideline dramatically reduces the number of places we need to handle exceptions in our code. We will have better performance in our optimized build and hopefully fewer bugs since they have been detected by failed asserts. However, there are situations where errors can occur at runtime, and those errors need to be handled and recovered by the code we implement.
Recoverable runtime errors
If a function cannot uphold its part of the contract (the postcondition, that is), a runtime error has occurred and needs to be signaled to some place in the code that can handle it and recover the valid state. The purpose of handling recoverable errors is to pass an error from the place where the error occurred to the place where the valid state can be recovered. There are many ways to achieve this. There are two sides of this coin:
- For the signaling part we can choose between C++ exceptions, error codes, returning a
std::optional
orstd::pair
, or usingboost::outcome
orstd::experimental::expected
. - Preserving the valid state of the program without leaking any resources. Deterministic destructors and automatic storage duration are the tools that make this possible in C++.
The utility classes std::optional
and std::pair
will be covered in Chapter 9, Essential Utilities. We will now focus on C++ exceptions and how to avoid leaking resources when recovering from an error.
Exceptions
Exceptions are the standard error handling mechanism provided by C++. The language was designed to be used with exceptions. One example of this is constructors that fail; the only way to signal errors from constructors is by using exceptions.
In my experience, exceptions are used in many different ways. One reason for this is that distinct applications can have vastly different requirements when dealing with runtime errors. With some applications, such as a pacemaker or a power plant control system, which may have a severe impact if they crash, we may have to deal with every possible exceptional circumstance, such as running out of memory, and keep the application in a running state. Some applications even completely stay away from using the heap memory, either because the platform doesn't have any heap available at all, or because the heap introduces an uncontrollable level of uncertainty as the mechanics of allocating new memory are out of the application's control.
I assume that you already know the syntax of throwing and catching exceptions and will not cover it here. A function that is guaranteed to not throw an exception can be marked as noexcept
. It's important to understand that the compiler does not verify this; instead, it is up to the author of the code to figure out whether their function could throw an exception.
A function marked with noexcept
makes it possible for the compiler to generate faster code in some cases. If an exception would be thrown from a function marked with noexcept
, the program will call std::terminate()
instead of unwinding the stack. The following code demonstrates how to mark a function as not throwing:
auto add(int a, int b) noexcept {
return a + b;
}
You may notice that many code examples in this book don't use noexcept
(or const
) even if it would have been appropriate in production code. This is only because of the format of a book; it would make the code hard to read to add noexcept
and const
at all the places that I normally would.
Preserving the valid state
Exception handling requires us programmers to think about exception safety guarantees; that is, what is the program state before and after an exception has occurred? Strong exception safety can be seen as a transaction. A function either commits all state changes, or performs a complete rollback in the case of an exception.
To make this a bit more concrete, let's take a look at the following simple function:
void func(std::string& str) {
str += f1(); // Could throw
str += f2(); // Could throw
}
The function appends the result of f1()
and f2()
to the string, str
. Now consider what would happen if an exception was thrown when calling the function f2()
; only the result from f1()
would be appended to str
. What we want instead is to have str
untouched if an exception occurs. This can be fixed by using an idiom called copy-and-swap. It means that we perform the operations that might throw exceptions on temporary copies before we let the application's state be modified by non-throwing swap()
functions:
void func(std::string& str) {
auto tmp = std::string{str}; // Copy
tmp += f1(); // Mutate copy, may throw
tmp += f2(); // Mutate copy, may throw
std::swap(tmp, str); // Swap, never throws
}
The same pattern can be used in member functions to preserve the valid state of an object. Let's say we have a class with two data members and a class invariant that says that the data members cannot compare equal, as follows:
class Number { /* ... */ };
class Widget {
public:
Widget(const Number& x, const Number& y) : x_{x}, y_{y} {
assert(is_valid()); // Check class invariant
}
private:
Number x_{};
Number y_{};
bool is_valid() const { // Class invariant
return x_ != y_; // x_ and y_ must not be equal
}
};
Next, assume we are adding a member function that updates both data members, like this:
void Widget::update(const Number& x, const Number& y) {
assert(x != y && is_valid()); // Precondition
x_ = x;
y_ = y;
assert(is_valid()); // Postcondition
}
The precondition states that x
and y
must not compare equal. If the assignment of x_
and y_
can throw, x_
might be updated but not y_
. This may result in a broken class invariant; that is, an object in an invalid state. We want the function to preserve the valid state of the object it had before the assignment operations if an error occurs. Again, one possible solution is to use the copy-and-swap idiom:
void Widget::update(const Number& x, const Number& y) {
assert(x != y && is_valid()); // Precondition
auto x_tmp = x;
auto y_tmp = y;
std::swap(x_tmp, x_);
std::swap(y_tmp, y_);
assert(is_valid()); // Postcondition
}
First, local copies are created without modifying the state of the object. Then, if no exception has been thrown, the state of the object can be changed using a non-throwing swap()
. The copy-and-swap idiom can also be used when implementing assignment operators to achieve strong exception safety guarantees.
Another important aspect of error handling is to avoid leaking resources when an error occurs.
Resource acquisition
The destruction of C++ objects is predictable, meaning that we have full control over when, and in what order, resources that we have acquired are released. This is further illustrated in the following example, where the mutex variable m
is always unlocked when exiting the function, as the scoped lock releases it when we exit the scope, regardless of how and where we exit:
auto func(std::mutex& m, bool x, bool y) {
auto guard = std::scoped_lock{m}; // Lock mutex
if (x) {
// The guard automatically releases the mutex at early exit
return;
}
if (y) {
// The guard automatically releases if an exception is thrown
throw std::exception{};
}
// The guard automatically releases the mutex at function exit
}
Ownership, lifetime of objects, and resource acquisition are fundamental concepts in C++, and we will cover them in Chapter 7, Memory Management.
Performance
Unfortunately, exceptions have a bad reputation when it comes to performance. Some concerns are legitimate, whereas some are based on historical observations when exceptions were not implemented efficiently by the compilers. However, today there are two main reasons why people abandon exceptions:
- The size of the binary program is increased even if exceptions are not being thrown. Even though this is usually not an issue, it doesn't follow the zero-overhead principle since we are paying for something that we don't use.
- Throwing and catching exceptions is relatively expensive. The runtime cost of throwing and catching exceptions is not deterministic. This makes exceptions unsuitable in contexts with hard real-time requirements. In this case, other alternatives such as returning a
std::pair
with a return value and an error code might better.
On the other hand, exceptions perform outstandingly when no exceptions are being thrown; that is, when the program follows the success path. Other error reporting mechanisms such as error codes require checking return codes in if-else
statements even when the program runs without any errors.
Exceptions should happen rarely, and typically when an exception occurs, the extra performance penalty that exception handling adds is usually not an issue in those situations. It's usually possible to perform computations that could potentially throw before or after some performance-critical code runs. In that way, we can avoid having exceptions thrown and caught at the places in our program where we cannot afford to have exceptions.
To make a fair comparison between exceptions and some other error reporting mechanism, it's important to specify what to compare. Sometimes exceptions are compared with no error handling at all, which is unfair; exceptions need to be compared with a mechanism that offers the same functionality, of course. Don't abandon exceptions for performance reasons before you have measured the impact they might have. You can read more about analyzing and measuring performance in the next chapter.
Now we will move away from error handling and explore how we can use lambda expressions to create function objects.