Written by:
Last updated: 7 July 2022
Please fill in the feedback form for lecture 7.
In most large programs, there are many ways in which a function can fail. For example, you might have:
"10011210"double, but you
give it a NaN valueIn all these examples, these functions are unable to perform their intended tasks due to situations that are out of their control.
How should we handle such errors?
C APIs typically use some kind of error code, typically seen in system calls:
char buf[64];
ssize_t res = read(fd, buf, 64);
if (res < 0) {
printf("read() returned an error: %s", strerror(errno));
} else {
/* parse the buffer */
}read system call
We could also write such functions that either set errno
or return some kind of error code, but the situation quickly gets
unwieldly when we need to pass the error out multiple layers of function
calls.
const int SUCCESS = 0;
const int FILE_DOES_NOT_EXIST = 1;
const int FILE_CANNOT_BE_READ = 2;
const int CANNOT_PARSE_AS_INT = 3;
const int INT_TOO_LARGE = 4;
int readAnIntFromFile(const char* filename, int& out) {
int fd = open(filename, O_RDONLY);
if (fd == -1) {
return FILE_DOES_NOT_EXIST;
}
char buf[64];
ssize_t len = read(fd, buf, 64);
if (len < 0) {
return FILE_CANNOT_BE_READ;
}
close(fd);
int val;
auto [end, ec] = std::from_chars(buf, buf + len, val);
if (ec != std::errc{}) {
if (ec == std::errc::result_out_of_range{}) {
return INT_TOO_LARGE;
}
return CANNOT_PARSE_AS_INT;
}
out = val;
return SUCCESS;
}Handling the error conditions makes the code a lot longer.
And there are still many further issues with this function:
read returns an error, the file will be left
unclosederrno if they get the error code
of FILE_DOES_NOT_EXIST or FILE_CANNOT_BE_READ,
but not for the other two errorsclose succeedsAnd the mess doesn’t end here — we didn’t actually do anything to “fix” the errors in this function. Instead, we propagated those errors out to the caller, which will need to handle them as well.
It would be much nicer if we were able to write code that describes
just the non-exceptional case (i.e. the “good” case), and only check for
the errors at the point where we can handle them (e.g. by checking for a
different file, or by showing an alert to the user). We would then be
able to write the same function much more concisely (using the
hypothetical calls open_ex, read_ex, and
atoi_ex):
int readAnIntFromFile_ex(const char* filename) {
FileHandle hdl = open_ex(filename, O_RDONLY);
char buf[64];
size_t len = read_ex(hdl, buf, 64);
return atoi_ex(buf, len);
}Then we could perhaps handle the error conditions in the caller:
int doCommand() {
int tries = 0;
char filename[64] = "/path/to/my/file";
size_t len = strlen(filename);
while (true) {
itoa(tries, filename + len, 10);
try {
int val = readAnIntFromFile_ex(filename);
// success! lets do stuff with val
return doStuff(val);
} catch (const FileNotFoundException& e) {
// too bad, can't find file
showAlertMessage("Cannot find a suitable file");
return;
} catch (...) { // catch all other exceptions
// try again with next file
++tries;
}
}
}With appropriately written open_ex,
read_ex, and atoi_ex, we would be able to
write code like the above, where all the code for handling those error
conditions only need to be written where we are able to handle those
errors (e.g. by retrying or warning the user). Importantly, there may be
arbitrarily many stack frames between the error handlers and the
function call that actually causes the error, and the errors must still
be propagated out “automagically”.
This style of error handling is known as exceptions,
and they are a feature of many commonly-used programming languages. The
code that raises the error (e.g. in atoi_ex) is said to
throw an exception, and the handlers (i.e. the
catch blocks above) are said to catch them
— perhaps called as such since control flow abruptly jumps to code
seemingly far away from the throw site.
There are various concerns when designing an exception handling in a programming language, and various languages have taken slightly different approaches.
Some of the more important concerns are:
new/delete or
malloc/free), but there are various other
resources, including file handles (open/close
or fopen/fclose), mutexes
(pthread_mutex_init/pthread_mutex_destroy),
and locks
(pthread_mutex_lock/pthread_mutex_unlock).
While an exception is thrown out of a stack frame, we want all resources
to be released, so that the code does not leak those resources — our
first example in this lecture shows how easy it is to accidentally leak
resources when an error occurs.Keep these concerns in mind as we go through the rest of this lecture.
We’ve already shown you how to catch exceptions in the previous section. Here’s the relevant code once again:
try {
int val = readAnIntFromFile_ex(filename);
// success! lets do stuff with val
return doStuff(val);
} catch (const FileNotFoundException& e) {
// too bad, can't find file
showAlertMessage("Cannot find a suitable file");
return;
} catch (...) { // catch all other exceptions
// try again with next file
++tries;
}This is known as a try-block. Try-blocks may nest in one
another, just like stack frames when we call a function. The meaning of
a try-block is fairly straightforward: The section enclosed by
try { ... } is executed under normal (i.e. non-exceptional)
circumstances, but some code in there might throw exceptions (in this
case, readAnIntFromFile_ex, and perhaps
doStuff, might throw exceptions). When an exception is
thrown (say from readAnIntFromFile_ex), control jumps to
the enclosing catch clause (i.e. of the nearest try-block),
or exception handler, that catches the type of exception that is being
thrown.
For example, if a FileNotFoundException is thrown,
control goes to the exception handler that calls
showAlertMessage, but if any other type of exception is
thrown, control goes to the catch(...) clause (i.e. the
catch-all handler).
If we did not write the catch-all handler, a thrown exception that is
not a FileNotFoundException will get thrown out of this
try-block to the next enclosing try-block (which could be one or more
stack frames away), and will continue to be propagated until a suitable
exception handler is found.
Let’s take a look at the other side — throwing an exception.
To illustrate, we will implement the read_ex function in
the previous example, which is a wrapper for the read
system call, but will throw a ReadException if the read
fails. Let’s first implement ReadException. In C++, any
type can be thrown, so we’ll create a simple class that contains a
message string.
class ReadException {
std::string m_message;
public:
ReadException(std::string message) : m_message(std::move(message)) {}
const std::string& message() const { return m_message; }
};ReadException implementation
Then we can write read_ex like this:
size_t read_ex(int fd, char* buf, size_t len) {
ssize_t len_read = read(fd, buf, len);
if (len_read < 0) {
throw ReadException("Cannot read: "s + strerror(errno));
}
return len_read;
}read_ex implementation`
It’s a C++ idiom to throw an exception by value, and catch the exception by (usually const) reference, just like in our examples above.
To understand why this ideal, we need to understand how the exception object gets passed through the throwing mechanism.
Every thread created in C++ keeps aside a small buffer in thread-local storage. When an exception is thrown, the exception is copy-initialized into the buffer (using copy construction, move construction, or guaranteed copy elision, depending on the value category of the value being thrown). The stack is then unwound (i.e. traversed) as the exception handling mechanism looks for the nearest suitable exception handler. When the handler is found, the argument of the handler is initialized from the exception in the buffer.
Storing the exception in the buffer is necessary because anything
left on the stack will be lost as we unwind the stack, removing stack
frames as necessary until we get to an appropriate exception handler.
Throwing by value (as opposed to by pointer) ensures that all the
information required by the exception is owned by the exception object
itself. Throwing specifically by prvalue (i.e. constructing and
immediately throwing the exception), like in our read_ex
example, is very common, and due to guaranteed copy elision, the
exception object is constructed in-place inside the buffer. (Note that
you can’t throw an exception by reference since copy initialization will
still occur from a reference, so we get a copy of the actual value
(rather than a reference) in the buffer.) If really intending to throw a
pointer, care should be taken to ensure that the object being pointed to
is still alive after unwinding the stack.
Since the exception object is now in the buffer, catching be reference gives us a reference to that buffer. This is ideal, since we do not make any unnecessay copies of the exception object. If we had instead caught the exception by value, then the exception object would be copied into a local variable.
std::exception and the C++ exception
hierarchyWhile you can throw any object, C++ defines
std::exception and a number of its subclasses for common
exception types (see the C++ Reference link in this section’s header),
and by convention, you should throw an exception that derives (perhaps
indirectly) from std::exception.
Having inheritance hierarchies are useful because an exception handler for a base class will handle exceptions for any derived class. For example:
try {
if (makeSomeSystemCall() == -1) {
throw std::system_error(errno, std::system_category{});
}
} catch (const std::runtime_error& ex) {
/* we will catch `std::system_error` here since it is a subclass of `std::runtime_error` */
return;
} catch (const std::exception& ex) {
/* catches `std::exception`s that are not `std::runtime_error`s */
return;
}A try-block with multiple exception handlers (as in the example
above) will cause exception handling to check them in order and pick the
first one that matches, and so the handler for
const std::exception& will only receive
non-std::runtime_error std::exceptions.
There two main groups of exceptions that derive from
std::exception — std::logic_error and
std::runtime_error.
Logic errors are generally things could have been checked explicitly
by your code. For example, the std::stoi family of functions throw
std::invalid_argument if the given string is not an
integer, and std::out_of_range if the integer that the
string represents is too large. We could have instead written code to
ascertain perhaps that the string contains at most 9 characters, all of
which must be between 0 and 9 inclusive, in
order to guarantee that the std::stoi does not throw:
std::string val = /* stuff */
if (val.empty() || val.size() > 9 || std::find_if_not(val.begin(), val.end(), [](char c) {
return '0' <= c && c <= '9';
}) == val.end()) {
std::cout << "Cannot convert to integer" << std::endl;
} else {
int val = std::stoi(val);
std::cout << "Converted to integer: " << val << std::endl;
}Runtime errors, on the other hand, usually represent errors caused by the environment (e.g. the operating system or network). These errors are generally beyond the scope of the program (i.e. your code can’t usually fix them).
Note that these are just guidelines — there may be exceptions that don’t cleanly fit in one type or the other.
Our ReadException class is quite clearly a runtime error
(rather than a logic error), since the error is caused by the operating
system. We would usually then have ReadException extend
from std::runtime_error:
class ReadException : public std::runtime_error {
std::string m_message;
public:
ReadException(const std::string& what_arg) : std::runtime_error(what_arg) {}
ReadException(const char* what_arg) : std::runtime_error(what_arg) {}
};ReadException inheriting from `std::runtime_error
what_arg is a string that is stored in the
std::exception base class, and an exception handler can
obtain that string by calling ex.what().
Another acceptable way (in C++11) would be to have
read_ex throw std::system_error, which is
meant to encapsulate a OS-level error code.
Remember how in the very first code snippet (without exceptions), we
said that readAnIntFromFile will leave the file open if the
read system call produces an error? Those with a keen eye
for detail might also have noticed that in the
readAnIntFromFile_ex code snippet, our open_ex
function returns a custom FileHandle type instead of a
plain int (which presumably is an RAII object that closes
the handle when it is destructed). These hint at something else that
happens during stack unwinding. When an exception is thrown, stack
frames are popped off the stack until the exception handler is reached.
But every time a stack frame is removed, local variables in that stack
frame are destructed (by calling their destructors, if any).
For example, if our FileHandle looked like this:
class FileHandle {
int fd;
public:
FileHandle(int fd) : fd(fd) {}
~FileHandle() {
close(fd);
}
}Then the destructor, which closes the file, will be called whether control leaves the function normally or via an exception.
(Note: In practice, we might instead write a class File
that also encapsulates operations on the file, such as read and writing
the contents of the file.)
Why is this behaviour “acceptable”? As we have talked about in
previous lectures, destructors are usually used to enforce ownership of
resources. These resources then are no longer useful once the object is
destroyed, whether or not it was able to accomplish the task
successfully. This kind of behaviour is also used in programming
languages without RAII. In such languages, the language usually
specifies a finally clause that is executed whether control
leaves normally or via an exception, and this is where resources are
usually released.
Destructors are unique pieces of code that can be called while the stack is being unwound. This raises the question about thrown an exception from a destructor. What happens if the destructor code throws an exception that goes out of the destructor?
In C++, you can’t have two exceptions being thrown at once. The
standard instead says that if the destructor throws an exception during
stack unwinding, std::terminate will be called.
std::terminate calls std::abort (which
terminates the program abnormally) by default, though it is possible to
install a handler, usually to perform cleanup operations (it is
generally impossible to gracefully recover from
std::terminate, as it usually indicates a logic bug).
In practice, you should almost never throw an exception from the
destructor, since the destructor might have been called due to another
exception. The standard also encourages this by making destructors
noexcept (i.e. they can’t throw exceptions) unless any of
their bases classes or member fields have a destructor that is not
noexcept.
noexceptThe noexcept keyword has two purposes — it is both a
specifier and an operator.
noexcept
specifierWriting noexcept after a function declaration says that
exceptions will not be thrown out of the function. More precisely, if
any exception from within the function attempts to unwind the stack past
this function’s stack frame, then unwinding will stop and
std::terminate will be called instead.
noexcept is part of the function type since C++17, just
like const for member functions.
It is written like this:
int doStuff(int x) noexcept {
/* stuff */
}Member functions can also be noexcept, and where it is also const,
the const comes before the noexcept:
struct Point {
double hypot() const noexcept {
/* stuff */
}
};Note that this does not mean that no exceptions can be thrown within
the function — it is perfectly legal to throw exceptions within a
function declared noexcept, as long as all exceptions are
handled so they don’t leave the function. In other words,
noexcept describes the function call boundary, and not the
contents of the function.
Why do we want to do this? Making a function noexcept
serves two purposes:
The main drawback is the verbosity of writing noexcept
on every such function call.
In practice, programmers tend to pay more attention to adding
noexcept for general-purpose library code used by many
parts of the program, but may omit to write them for more high-level
functions that are not general-purpose.
noexcept(bool)It’s possible to make the noexcept-ness of a function depend on any
compile-time expression, by writing noexcept(expr) where
expr is any expression that can be evaluated to a boolean
at compilation time. noexcept(true) is equivalent to
noexcept, while noexcept(false) is equivalent
to not writing any noexcept specifier at all (i.e. the function may
throw exceptions).
It’s possible to pass any constant expression, but often we want the
noexcept-ness of the function to depend on the noexcept-ness of certain
expressions within the function. For example, the standard provides a
type trait std::is_nothrow_copy_constructible_v<T>
which evaluates to true if T’s copy
constructor is noexcept, and false otherwise. We’ll cover
the details of
std::is_nothrow_copy_constructible_v<T> and other
type traits in a separate lecture, but for now just know that
std::is_nothrow_copy_constructible_v<T> evaluates to
a compile-time constant, either true or false,
depending on the type T. We would then write
noexcept(std::is_nothrow_copy_constructible_v<T>),
for example:
template <typename T>
int doStuff(T x) noexcept(std::is_nothrow_copy_constructible_v<T>) {
/* stuff */
// perhaps we want to do copy construction of T here
T y = x;
}noexcept
operatorIn the previous example, we made the noexcept-ness of a function
depend on whether T’s copy constructor is noexcept. But
what if we wanted it to depend on something more complicated? Perhaps we
wrote a.val() == b.val() in the function, and this
expression may or may not be noexcept. How would we check that every
part of this expression is noexcept?
C++ provides a convenient way to do this — the noexcept
operator. The noexcept operator takes in an
expression, and produces true if that expression is
noexcept, and false otherwise. This is a compile-time
operation — the given expression is never evaluated, and the value
produced by the noexcept operator depends on whether every operation in
the expression is specified as noexcept. Note that this is
not about whether an exception is actually thrown; instead it is about
whether all parts of the function are declared as noexcept or not.
We could write something like this (not that the expression
a.val() == b.val() is never actually evaluated):
bool is_noexcept = noexcept(a.val() == b.val());noexcept operator
Since this is a compile-time operation, its value is known at compilation time, and so we can use it anywhere a compile-time constant is expected:
// noexcept expression in a non-type template parameter
std::array<int, noexcept(a.val() == b.val()) ? 10 : 20> arr;
// noexcept expression assigned to constexpr variable (a variable whose value is known at compile time)
constexpr bool cond = noexcept(a.val() == b.val());
// A constexpr variable can later be used anywhere a compile-time constant is expected
std::array<int, 15 + cond> arr2;
// no need to capture a constexpr variable
// (unless you have a buggy compiler: https://stackoverflow.com/questions/42610429/must-constexpr-expressions-be-captured-by-a-lambda-in-c)
std::partition(v.begin(), v.end(), [](int x) { return x % 2 == (cond ? 0 : 1); });
constexpr int new_val = cond ? 1 : 2;noexcept operator in places where a compile-time constant
is expected
noexcept(noexcept(...))You might have guessed that the main use case of the
noexcept operator is to determine if the function
performing that expression should be marked as noexcept. In other words,
you might want to write a function like this:
void f(const Stuff& a, const Stuff& b) noexcept(noexcept(a.val() == b.val())) {
/* do stuff that includes the expression `a.val() == b.val()`
}noexcept(noexcept(...)) example
It looks repetitive, but the two noexcepts have
different meanings — the first noexcept is a specifier,
while the second noexcept is an operator.
Exception safety, or exception guarantee, is a property of a function. It tells us what we can assume about the state of the objects potentially modified by the function. We usually see this when taking about member functions of a class.
Consider the NullableOwnPtr example from Lecture 6. We
want to implement operator=(const T&), so that we can
do assignment from T, similar to
std::optional. In other words, we want something like this
to work, assuming that MyClass is copy constructible:
struct MyClass { /* class definition */ };
NullableOwnPtr<MyClass> nop = /* expression */;
MyClass new_stuff = /* expression */;
// We want this to work and set `nop` to contain a copy of `new_stuff`:
nop = new_stuff;Here’s one way to implement this operator=:
template <typename T>
struct NullableOwnPtr {
private:
T* m_ptr;
public:
/* member functions go here */
};// The assignment operator from `T`
NullableOwnPtr& operator=(const T& val) {
auto copy = NullableOwnPtr{val};
this->swap(copy);
return *this;
}operator=(const T&) in the easiest way
But is this the best we can do?
It isn’t, because we’re making a new heap allocation and then
deleting the old one. If the NullableOwnPtr already
contains some value, then we could have just copied the new value into
the existing allocation, without making any additional allocations.
Something like this:
// The assignment operator from `T`
NullableOwnPtr& operator=(const T& val) {
if (this->m_ptr) {
// use the old heap allocation
this->m_ptr->~T(); // destroy the object
new (this->m_ptr) T{val}; // copy construct the new object into the existing heap allocation
} else {
// create a new heap allocation
this->m_ptr = new T{val};
}
return *this;
}operator=(const T&)
And we now have more efficient code.
This is all good if nothing here throws exceptions, because the entire function is guaranteed to be executed till completion.
The problem however comes because we don’t know what T
is. Since we don’t know anything about T, we can’t be sure
that T’s member functions don’t throw exceptions. In this
case, we’re concerned about the copy constructor of T,
which is invoked in the line new (this->m_ptr) T{val} to
copy the object. What would happen if T’s copy constructor
threw an exception there?
Well, it would be really bad. This is because we’ve destructed the old object but didn’t put a new object in its place, so there wouldn’t actually be a valid object in the heap allocation. This invokes undefined behaviour and would likely lead to a crash when we subsequently attempt to use or destruct the invalid object.
In this example, if T had a copy assigment operator, we
could have written this instead:
// The assignment operator from `T`
NullableOwnPtr& operator=(const T& val) {
if (this->m_ptr) {
// use the old heap allocation
*(this->m_ptr) = val; // copy assign the new object into the existing heap allocation
} else {
// create a new heap allocation
this->m_ptr = new T{val};
}
return *this;
}operator=(const T&),
using copy assignment of T
The exception safety of this code would then depend on what happens
when the copy assignment of T throws an exception (or in
other words, the exception safety of copy assigning T) —
does it leave the assignee in its old state? Or does it leave it in an
arbitrary state?
So now with the example above, we have adequate motivation to formalise the levels of exception safety of a function (or informally, what happens to the objects being modified when an exception is thrown).
There are four levels of exception safety, listed from the strongest guarantee to the weakest guarantee:
noexcept.)Functions that do not throw are the easiest to use, since we do not need to consider any additional code paths. If possible, functions should not throw exceptions.
If we write a function that can throw exceptions, we should really only consider making it satisfy either the strong or basic exception guarantee. This is because having no exception guarantee means that it’s impossible to properly recover from an exception. If a function has no exception guarantee, we can’t use any of the modified objects anymore (this also means we can’t even destroy them, because the destructor is allowed to assume that the object is in a valid state). Between the strong and basic exception guarantees, the strong exception guarantee is preferred, but in some cases it is difficult or impossible to give your function the strong exception guarantee, in which case we settle for the basic exception guarantee.
push_back in SimpleVectorRemember that saying that an object is in a valid state is
saying that the invariants of an object are satisfied, which means that
you can still call functions (with no preconditions) on it. For example,
in our SimpleVector example (member fields reproduced
below), a valid SimpleVector is one where
m_buffer points to an array of exactly m_size
ElemTy elements.
struct SimpleVector {
private:
ElemTy* m_buffer;
size_t m_size;
};SimpleVector member fields
So if we say that a function operating on a SimpleVector
satisfies the basic exception guarantee, we mean that even if the
function throws an exception, m_buffer must still point to
an array of exactly m_size ElemTy elements,
AND no resources are leaked.
Recall this push_back member function in
SimpleVector (slightly modified to get rid of the moves for
now):
void push_back(const ElemTy& element) {
ElemTy* new_buffer = new ElemTy[m_size + 1];
for (size_t i = 0; i < m_size; i++) {
new_buffer[i] = m_buffer[i];
}
new_buffer[m_size] = element;
m_size += 1;
delete[] m_buffer;
m_buffer = new_buffer;
}push_back member function in SimpleVector from
a previous lecture
Does this function satisfy the basic exception guarantee?
Let’s analyse the code and pick out the operations that might throw exceptions:
void push_back(const ElemTy& element) {
ElemTy* new_buffer = new ElemTy[m_size + 1]; // <-- value-initializing `ElemTy` calls the default constructor if it exists, which might throw
for (size_t i = 0; i < m_size; i++) {
new_buffer[i] = m_buffer[i]; // <-- copy assignment of `ElemTy` might throw
}
new_buffer[m_size] = element; // <-- copy assignment of `ElemTy`
m_size += 1;
delete[] m_buffer;
m_buffer = new_buffer;
}push_back member function in SimpleVector from
a previous lecture, annotated with potential exception sites
If any of those places throw an exception, will the vector remain in
a valid state? Notice that m_size and m_buffer
(as well as the data pointed to by m_buffer) will only be
modified after we’ve passed all the code that might throw exceptions.
This means that if an exception is thrown, the vector is guaranteed to
be unmodified.
However, this code will leak memory if an exception is thrown from
the copy assignment of ElemTy, because
new_buffer won’t be freed, so this code doesn’t even
satisfy the basic exception guarantee.
An easy way to remedy this problem is to add a try-block:
void push_back(const ElemTy& element) {
ElemTy* new_buffer = new ElemTy[m_size + 1]; // <-- if default construction throws, the buffer will be freed automatically
try {
for (size_t i = 0; i < m_size; i++) {
new_buffer[i] = m_buffer[i];
}
new_buffer[m_size] = element;
} catch (...) {
delete[] new_buffer;
throw; // <-- re-throws the original exception
}
m_size += 1;
delete[] m_buffer;
m_buffer = new_buffer;
}push_back with strong exception guarantee using a try-block
Since C++ has RAII instead of finally clauses, we can
rewrite this in a more idiomatic way using
std::unique_ptr:
void push_back(const ElemTy& element) {
std::unique_ptr<ElemTy[]> new_buffer(new ElemTy[m_size + 1]); // note: for more idiomatic code, use `std::make_unique<ElemTy[]>(m_size + 1)` instead
for (size_t i = 0; i < m_size; i++) {
new_buffer[i] = m_buffer[i];
}
new_buffer[m_size] = element;
m_size += 1;
delete[] m_buffer;
m_buffer = new_buffer.release(); // <-- takes the raw pointer out of the `std::unique_ptr`
}push_back with strong exception guarantee using
std::unique_ptr
And now our push_back member function satisfies the
strong exception guarantee.
std::move and
the strong exception guaranteeWhy did we use a version of push_back that performs copy
assignment instead of move assignment? This is because move assignment
is more nuanced.
Move assignment modifies the object being moved from, which means
that by the time an exception is thrown, the contents of the old buffer
might have been modified. This means that if we wrote
push_back like this…
void push_back(const ElemTy& element) {
std::unique_ptr<ElemTy[]> new_buffer(new ElemTy[m_size + 1]);
for (size_t i = 0; i < m_size; i++) {
new_buffer[i] = std::move(m_buffer[i]); // <-- move assignment (modifies `m_buffer[i]`)
}
new_buffer[m_size] = element; // <-- copy assignment
m_size += 1;
delete[] m_buffer;
m_buffer = new_buffer.release();
}push_back with basic exception guarantee
… it would only satisfy the basic exception guarantee (but not the strong exception guarantee).
What could we do to make it satisfy the strong exception guarantee? The main problem here is if move assignment or copy assignment throws, then the earlier iterations of the loop might have already modified the elements in the old buffer.
Class authors may want copy assignment or copy construction to throw,
since they may need to acquire resources (e.g. heap memory, like
std::string, or locks, like std::lock_guard).
However, should move assignment throw? Or perhaps, are there reasonable
situations where you might want move assignment or move construction to
throw? Since moving an object “steals” the resources of the moved-from
object, there should not be a need to acquire new resources. (I have not
come across a situation where you would actually want move construction
or move assignment to throw, and if you can think of something, I’d be
happy to talk about it after the lecture.)
So to simplify things, let’s assume that moves never throw. Is there
now a better way to do things so that push_back satisfies
the strong exception guarantee?
We could do the copy assignment before all the move assignments, so that by the time we get to the moves, we know that the operation must succeed:
void push_back(const ElemTy& element) {
std::unique_ptr<ElemTy[]> new_buffer(new ElemTy[m_size + 1]);
new_buffer[m_size] = element; // <-- copy assignment
for (size_t i = 0; i < m_size; i++) {
new_buffer[i] = std::move(m_buffer[i]); // <-- move assignment (modifies `m_buffer[i]`)
}
m_size += 1;
delete[] m_buffer;
m_buffer = new_buffer.release();
}push_back with strong exception guarantee,
assuming moves don’t throw
In C++, move constructors and move assignment operators are normal functions that may be customised in any way, including throwing an exception. However, throwing an exception is unexpected in almost all situations, and most moves simply perform a memberwise move and reset the state of the moved-from object to something similar to the default-constructed state (if necessary). Furthermore, the moved-from object is most of the time destructed immediately after its state is moved from it.
This makes some other programming languages (notably Rust) settle on
simpler (though perhaps more rigid) move semantics. In those languages,
moving an object is equivalent to a bitwise copy
(i.e. memcpy), and the original object no longer exists
after a move (i.e. the destructor should not be called). While more
rigid, most classes that want to be movable can be written to satisfy
such semantics, and those classes that can’t satisfy such semantics
(e.g. due to internal references) can implement some arbitrary member
function to perform the atypical move. Memcpy moves, in this case, are
more “pure” in the sense that it feels like there’s really only one
object in existence, and we’re just moving it around in memory. (C++
move semantics are more like creating a new object and stealing the
resources of the old one.)
The ease of reasoning about memcpy moves has led some to propose a “trivial relocatability” trait in C++, where classes
can opt in to this trait to declare that calling the move constructor
followed by destructing the moved-from object is equivalent to a bitwise
copy. Library implementors can then optimise containers or wrappers of
trivially relocatable types to actually use memcpy to move
them, even if these types are not trivially copyable.
std::move_if_noexcept and
std::vector::push_back’ exception guaranteesstd::vector::push_back guarantees the strong exception
guarantee whether or not moves can throw exceptions. This means that if
moves might throw exceptions (i.e. the move constructor and move
assignment operator are not noexcept), then
push_back copies instead of moves the existing elements to
the new buffer.
Because of this, as you design a class, you should tag move
constructors and move assignment operators with noexcept
whenever possible, so as to get performance benefits when instances of
your class are placed in containers like std::vector.
The C++ standard library helpfully provides
std::move_if_noexcept — it is equivalent to
std::move if move construction is noexcept,
and equivalent to a copy otherwise. (In other words,
std::move_if_noexcept converts the given reference to an
rvalue reference if move construction is noexcept, and a
const lvalue reference otherwise.) This utility function can then be
used in container classes such as std::vector to always
provide the strong exception guarantee but perform optimisations when
possible.
In general, member functions of containers of the standard library satisfy the strong exception guarantee when adding an element, and satisfy the nofail exception guarantee when removing an element, unless it is impossible to perform.
Some situations where it is impossible to make the strong exception
guarantee are: - Inserting to the middle of a std::vector
or std::deque using std::insert or
std::emplace - In-place construction of a value into a
std::optional using std::emplace
These situations generally arise because of the need to perform some modifying operation on the original data structure before the new element can be constructed in its desired location.
Furthermore, moves and swaps of standard library containers are
noexcept (apart from std::array), which allows
them to be used efficiently in other standard library containers.
Thus far, exceptions have been running on magic — we’ve simply assumed stack unwinding works, and it is possible to traverse the stack, calling destructors and exiting stack frames on the way, and find a matching exception handler. If you think stack unwinding is “trivial to implement”, think again.
How does the stack unwinding mechanism know which destructors to run and which catch block can handle the exception? What happens if the exception is being thrown out of several layers of function calls? It isn’t immediately clear how to traverse the stack, since the objects on the stack do not have any type or function information encoded with it.
We’ll start by describing a “simple” way to implement exception handling. It is a hypothetical exception handling mechanism that isn’t used on any platform as far as I know.
Notice that just like the call stack, we have a conceptual stack of exception handlers (i.e. the catch clause of a try block), each nested inside the previous one. This stack of exception handlers spans the entire call hierarchy of the program, and there may be multiple exception handlers within the same function. When control enters a try block, an exception handler is pushed onto this stack, and when control leaves, the exception handler is removed. For example:
Since a try-block with multiple catch clauses can be rewritten as nested try-blocks without changing the semantics of the program, we can extend this concept to describe catch clauses with multiple try-blocks:
Each “catch info” structure would contain a pointer to the exception handler code (think of this as a function synthesised by the compiler), as well as type information describing the type of exception this handler accepts:
When an exception is thrown, this stack of exception handlers is traversed to arrive at the nearest matching exception handler:
But there are two problems here:
rbp and rsp on x64) has to be restored to the
stack frame in which the catch clause is located.The second problem can be fixed easily. Notice that during stack unwinding, calling the destructor of an object is equivalent to a catch-all handler that always rethrows the exception:
catch (...) {
obj.~Obj();
throw;
}Restoring the base pointer and stack pointer is slightly more complex, but it can also be done by installing a catch-all handler that rethrows (as part of the prologue of each function call):
catch (...) {
asm(
"mov rsp rbp" // restore the stack pointer
"pop rbp" // restore the base pointer
);
throw;
}(Note that this ignores restoring callee-saved registers, but that’s trivial to add to the handler.)
Putting everything together, we get the following example:
At this point, we have a workable exception handling mechanism. However, having two separate stacks is rather wasteful, and we would rather combine them into just one. (This will be further motivated later where we make some optimisations to push less blocks onto the exception handling stack.)
It’s possible to combine the two stacks by converting the exception handling stack into a linked list embedded in the program stack:
If you think about this exception handling mechanism carefully, you might realise that we’ve glossed over what the “handled type” stores, and how it could possibly work with the exception hierarchy.
If we don’t need an inheritance hierarchy for our exceptions, then we
can just assign each exception type a unique identifier (chosen at
compilation time), such as the one chosen by std::type_info. Then the “handled type”
field in the catch info stores this identifier, which can be checked for
equality. If we only had single inheritance, we could generate a table
at compile time that maps each type identifier to its parent. Multiple
inheritance is somewhat more complicated, and it will be covered in
Lecture 10.
And we now have a decent exception handling mechanism.
The next two subsections describe how to augment this sections to improve the efficiency of the exception handling mechanism.
The first thing one should notice is that these catch info structs take up a sizeable amount of space on the stack. This is because you need one of them for every non-trivial destructor and every catch block you have.
We want to reduce the amount of space we need, and the amount of operations we need to do in the non-exceptional case. Within the same stack frame, can we somehow collapse the structs into just a single one?
It turns out we can! With some tracking of which part of the function is currently being executed (using just a single integer), it’s possible to figure out which exception handlers need to be run.
Let’s take a look a this function, for example:
std::vector<int> f(const Database& db) {
std::vector<int> result;
for (size_t i = 0; i != 10; ++i) {
try {
// db.query(i) might throw IndexNotFoundException or ConnectionException (which will be thrown to the caller)
int val = db.query(i);
result.push_back(val);
} catch (const IndexNotFoundException& ex) {
result.push_back(-1);
}
}
return res;
}Going by the simple exception handling scheme described earlier, we would push and pop catch info structs at the following locations:
std::vector<int> f(const Database& db) { // <-- push catch info to return from function
std::vector<int> result;
// <-- push catch info to destruct `result`
for (size_t i = 0; i != 10; ++i) {
try { // <-- push catch info to catch `IndexNotFoundException`
int val = db.query(i);
result.push_back(val);
} catch (const IndexNotFoundException& ex) {
result.push_back(-1);
} // <-- pop catch info to catch `IndexNotFoundException`
}
return res;
// ^-- performed while returning:
// - pop catch info to destruct `result`
// - pop catch info to return from function
}Notice that this creates code regions where each code region is associated with a certain state of the stack of exception handlers (highlighted using different colours):
std::vector<int> f(const Database& db) { // <-- push catch info to return from function
std::vector<int> result;
// <-- push catch info to destruct `result`
for (size_t i = 0; i != 10; ++i) {
try { // <-- push catch info to catch `IndexNotFoundException`
int val = db.query(i);
result.push_back(val);
} catch (const IndexNotFoundException& ex) {
result.push_back(-1);
} // <-- pop catch info to catch `IndexNotFoundException`
}
return res;
// ^-- performed while returning:
// - pop catch info to destruct `result`
// - pop catch info to return from function
}
Observe that the code regions are regions over the code of the function, which is something that can be resolved statically at compilation time. It does not matter that we’re doing some kind of loop here, or any form of runtime control flow. This is because the state of the exception handler stack depends only on which region of code threw the exception.
We hence need only to determine which code region the exception was
thrown from. To do so, we first associate each code region with a fixed
integer. Then, we synthesise a new int variable, and at
every point where control flow can enter the code region, we set this
variable to the associated integer of the code region. This is the same
code as above, but with the synthesised variable indicating the current
code region:
std::vector<int> f(const Database& db) { // <-- push catch info to return from function
int region = 0; // <--
std::vector<int> result;
region = 1; // <--
for (size_t i = 0; i != 10; ++i) {
try {
region = 2; // <--
int val = db.query(i);
result.push_back(val);
region = 1; // <--
} catch (const IndexNotFoundException& ex) {
result.push_back(-1);
}
}
return res;
}Now, whenever an exception is thrown, we read the value of the
region variable, and use some kind of lookup table to
determine the list of handlers to check. Exactly how this lookup table
is implemented varies, but this lookup table is always determinable
during compilation, and hence can be compiled into the executable
(usually in a read-only static memory region of the program). The code
that checks the lookup table and (based on the value of
region) invokes the exception handlers is known as the
personality routine of this function — each function that
performs operations that might throw exceptions will have a personality
routine.
Compared to the the previous section, this per-function handling mechanism is a fairly large performance boost. From pushing a struct onto the stack every time control flow enters a new code region, we’ve simplified it to merely updating an integer (which is likely to reside in a register). Note that when we talk about performance here, it’s performance in the non-exceptional case (i.e. when no exceptions are thrown). In the exceptional case using a per-function handler is likely to be slower than our simple exception handling mechanism, because of the need to use the lookup table to find the correct exception handlers to invoke. However, since exceptions are assumed to occur only in “exceptional” situations, the performance of the exceptional case is taken to be a non-issue. Hence, we are willing to make a trade-off and improve performance in the non-exceptional case at the cost of poorer performance in the exceptional case.
Even though we’ve reduced the non-exceptional cost of exceptions to updating a single integer whenever we need to push or pop an exception handler, this is still a somewhat significant amount of work in the non-exceptional code path. Zero-cost exception handling takes this a step further — in the non-exceptional code path, we do not want to execute any additional code at all!
How is that even possible?
As evidenced by the use of the region variable from the
previous section, we only need to find some way to figure out
the current code region when an exception is thrown. The exact way
doesn’t matter, but we want to spend as little effort as possible in the
non-exceptional code path.
What else can be used to find which code region we are currently in?
The instruction pointer! Since the code regions are simply ranges of
instructions, having the current instruction pointer is sufficient
(given an appropriate lookup table) to determine the code region we are
currently in, and thus the list of exception handlers we need to check.
The personality routine would then take in the current instruction
pointer (instead of the region variable), and the lookup
table will then map instruction ranges to something equivalent to the
“catch info” struct we saw earlier.
While unwinding the stack, it is possible to unwind out of a function (i.e. throw an exception out of a function). It isn’t possible to directly specify in the lookup table which handlers to use in the parent function, since there may be many functions that could possibly call the current function.
However, since the return address of a function invocation is saved on the stack (so that we can return to the caller in non-exceptional execution), we can use the same return address to figure out the caller function, and hence obtain its personality routine. We can then invoke that personality routine, with the return address as the current instruction pointer.
Exceptions aren’t the only way to handle errors. C APIs return an error code to indicate a failure condition. There are a few other error handling mechanisms that have been explored by programming language designers in detail, which may be used in place of exceptions, depending on the kind of error we want to handle or the kind of software we are writing.
This section will briefly discuss two alternatives to exceptions that
are being explored in the C++ world — std::expected and
contracts.
std::expected (C++23)std::expected<T, E> is a discriminated union
(i.e. a union together with a flag that indicate which alternative it
holds, like a std::variant) of types T and
E. T is the “success” type and E
is the “failure” type. std::expected<T, E> is meant
to be the return type of a function that might fail. For example:
std::expected<size_t, std::string> read_ex(int fd, char* buf, size_t bufsz) {
ssize_t res = read(fd, buf, bufsz);
if (res < 0) {
// Return the failure alternative
return std::unexpected(strerror(errno));
} else {
// Return the success alternative (implicit conversion)
return res;
}
}
std::expected<int, std::string> readAnIntFromFile_ex(int fd) {
char buf[64];
if (auto res = read_ex(fd, buf, 64); !res) {
return std::unexpected(res.error());
}
/* parse the integer from the buffer */
}read_ex with std::expected
There are two main benefits (or drawbacks, depending on how you see
it) between std::expected and exceptions:
Firstly, std::expected forces the programmer to check
for failure at each call site (i.e. the if-statement that checks
!res). This reduces the likelihood of forgetting to handle
some exception (which would otherwise result in the program aborting),
which results in safer code. However, this is a significant increase in
verbosity at the call site as compared to using exceptions. The
verbosity however is mostly due to the C++ implementation of
std::expected — this a programming language concept isn’t
inherently verbose, as Rust’s
std::result::Result<T, E> implements this concept in
a much less verbose manner:
fn read_ex(fd: i32, buf: &mut [u8]) -> Result<usize, String> {
let res: isize = read(fd, buf);
if res < 0 {
Err(strerror(errno))
} else {
Ok(res as usize)
}
}
fn readAnIntFromFile_ex(fd: i32) -> Result<i32, String> {
let mut buf: [u8; 64];
let len: usize = read_ex(fd, &mut buf)?; // the '?' early-returns if there is an error
/* parse the integer from the buffer */
}Result<T, E> example
Secondly, for the cost of slightly larger return values and a branch
at each call site, the overhead of handling an error is essentially
eliminated. This means that we may use std::expected for
commonly-occuring error conditions, not just “exceptional” ones.
However, slightly larger return values may mean that the return value is
passed in memory instead of a register, which may incur a noticeable
slowdown. Sutter’s paper (see “Further reading” section) suggests using
an unused register for the discriminant (i.e. the flag), so that the
size of the returned object remains the same as with traditional
exceptions, but it is not clear if any of the major compiler vendors
will implement this.
Contracts, or design by contract, is a programming paradigm first seen in the Eiffel programming language. This section will focus on how contracts can be a replacement for certain kinds of exceptions.
As we have discussed earlier, std::logic_error is the
standard-provided base class for error conditions that could have been
checked by the caller before making the function call. We gave the
example of std::stoi, which throws something that inherits
from std::logic_error if the given string is not
interpretable as an integer.
However, who is responsible (the caller or callee) for the error when
the string is not interpretable as an integer? By having the callee
(std::stoi) check for this error condition and throw an
exception when in happens, we’re putting the responsibility on the
callee. But perhaps it’s the caller’s responsibility for the mess that
it passed to the callee? The key idea of contracts is to pin the blame
squarely on the caller for handing the callee a bad input, and to
provide the language support necessary for formalising what the callee
demands of the caller (this is the contract between the caller
and the callee). You may have heard of preconditions, which
typically written in comments on the function declaration. Contracts, as
a language feature, allows the programmer to write these preconditions
in code, and have the compiler (optionally) assert them.
For example, we could create a version of std::stoi that
demands that all characters in the string are between '0'
and '9':
int my_stoi(const std::string& s)
[[ pre: std::all_of(s.begin(), s.end(), [](char c) { return '0' <= c && c <= '9'; }) ]]
{
int x = 0;
for (char c : s) {
x *= 10;
x += c - '0';
}
return x;
}A good contracts implementation should also provide support for postconditions, which are the guarantees that the callee makes about its return value and the final state of its arguments (for arguments that allow modification to objects not owned by the callee, such as pointers and references).
Depending on the compilation mode, the preconditions and postconditions behave in one of these three ways: - Assertion: Code will be emitted to check preconditions when entering the function and postconditions when leaving the function. This is useful when debugging code. - Ignored: The preconditions and postconditions are ignored. - Assumption: The compiler will assume that the preconditions and postconditions are true, in the sense that it is allowed to perform optimisations that assume those conditions (i.e. it is undefined behaviour if preconditions or postconditions are not satisfied).
Contracts was originally slated for C++20, but now has been delayed indefinitely, supposedly due to disagreements about the three ways (above) one can compile the preconditions and postconditions.
There is a great paper on zero-overhead deterministic exceptions by Herb Sutter that discusses different error handling mechanisms and their benefits and drawbacks.
© 7 July 2022, Bernard Teo Zhi Yi, All Rights Reserved
^