04 - exception guarantees

Functions can be marked as noexcept to inform they will never throw. However, functions which potentially throw can still offer some guarantees. There is no syntax for it - it's simply documentation about function postconditions. Exception safety of standard library functions is documented on cppreference on a per-function basis.

4 levels of safety exist, each higher one being a superset of previous guarantees.

Nothrow exception guarantee

The highest level of all - the function simply never throws. Such functions are typically marked noexcept but there are some exceptions (pun intended):

  • Destructors are implicitly noexcept, there is no need to mark them as such

  • C++ standard library functions imported from the C standard library (C has no exceptions).

Note that it's still possible to break some C functions - some of them take function pointers as arguments. Make sure that callbacks given to C interfaces do not throw.

Strong exception guarantee

A function can throw, but if it throws the state of the program is rolled back to the state just before the function call. This approach is very common in the whole IT and of particular importance in networking and databases - each operation either succeeds or fails with rollback behavior. There is no partial success. A slightly outdated database is better than the one with inconsistent or corrupted information.

Many operations which do not inherently give strong guarantees can be made as such by performing a temporary copy. Edits on the copy can fail, but the state of the program remains unaffected. If edits succeed, the copy is swapped with the original object (swaps are noexcept) and the now-copy-of-old-state object is destroyed. Standard library containers take advantage of noexcept member functions when implementing their copy and move operations. If stored objects do not have noexcept copy and move operations, containers switch implementation to perform additional copies (reduced performance) in order to guard against possible failures.

Because providing strong exception guarantee is sometimes a safety/performance tradeoff, it has to be decided on a case-by-case basis.

Basic exception guarantee

A function can throw and if it throws the program remains in a valid but unspecified state (same guarantee as for moved-from objects). Data loss may occur but no resource leaks or memory corruption.

No exception guarantee

Basically undefined behavior when an exception occurs. Don't use exceptions in such places or if you have to - guard exception-unsafe code by cathing or terminating the program (termination is better than undefined behavior).

Example: once I was implementing a callback for data download performed by the curl library., specifically for WRITEFUNCTION option from the easy interface. The code looked as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <string>

struct request_result
{
	std::string data = {};
	bool is_error = false;
};

// This function is mine and libcurl calls it multiple times during the download.
// The received data is passed as an array of bytes: the classic "pointer + size" convention.
// nmemb is the size, the unused parameter is always 1 to match C standard library fwrite function.
// userdata parameter is mine too, it is set earlier in https://curl.se/libcurl/c/CURLOPT_WRITEDATA.html
std::size_t write_callback(char* data, std::size_t /* size */, std::size_t nmemb, void* userdata) noexcept
{
	// I have given an address of the request_result struct earlier, now I obtain it back.
	// curl uses void* because it doesn't understand external types but can take any address.
	auto& result = *reinterpret_cast<request_result*>(userdata);

	try {
		result.data.append(data, nmemb); // may throw on allocation failure
		return nmemb; // return number of bytes accepted
	}
	catch (const std::exception& e) {
		result.is_error = true;
		result.data = e.what();
	}
	catch (...) {
		result.is_error = true;
		result.data = "(unknown error)";
	}

	// If control flow gets here, it means that result.data threw an exception.
	// This means memory allocation failed and I want to stop the transfer.
	return 0; // error: accepted 0 bytes
}

The library is written in C but the callback function can be written in C++. libcurl will simply call it when needed. I have given the address of this function earlier, when setting up the transfer options. Obviously libcurl isn't prepared for the case where its C code calls a C++ function that in turn throws an exception. The exception would have to go through unprepared C code, likely resulting in undefined behavior because C code isn't compiled with support for exceptions.

To prevent this, I simply wrap the risky operation in the try block and immediately handle any possible exception. I can not exit this callback by exception, so I have written it to simply return 0; which informs libcurl I couldn't accept even 1 byte (this will cancel the download).

Recommendations

Aim for the highest level of exception guarantees when writing your own code. This means that you will usually end up with basic and strong guarantee.