Introduction
Our previous four articles have all discussed a single problem: how to write an exception-safe handle class. It is now time for us to take a few steps back and note what we've learned so that our new knowledge will be more readily available to us the next time we want to use it.One observation is that handles can take any of several different forms, and how one goes about making a handle exception safe differs greatly depending on what kind of handle it is. Another observation is that we can abstract part of the work of a handle class into a separate class that deals with reference counting, and that doing so either simplifies or complicates the task of achieving exception safety, depending on the design of that class.
Throughout these articles, we never took the time to clarify completely what exception safety is. The reason for this neglect is that exception safety is easy to define in connection with handles; a more general definition is more difficult. Accordingly, it seems appropriate to begin by saying in general terms just what exception safety is, and then to review our earlier articles in light of that description.
What Is Exception Safety?
In a naive way, exception safety is trivial to define: a program is exception safe if nothing bad happens when it throws an exception. Trouble starts only when we try to define just what we mean by "nothing bad happens." It should be obvious that causing the program to crash would fall under the general category of "something bad," but what about lesser forms of misbehavior? For that matter, how can it make sense to analyze an entire program at once? Don't we need to be able to analyze its parts and then deduce its overall behavior from the behavior of those parts? Otherwise, if we were to change any part of the program, we would have to analyze the whole program again. If we understand how the parts work together, we need only analyze the interactions between the parts we change.An important step toward understanding exception safety is to realize that a program usually catches an exception in a significantly different part of the code from the part that throws it. Indeed, that fact is a major motivation for having exceptions in the language: they let one part of a program say "I can't cope with this situation" without the author of that part having to say which other part of the program will cope with the situation. Accordingly, much of the complexity of dealing with exceptions comes from the need to deal with the relationship between the part of the program that throws an exception and the part that catches it.
To make this discussion more concrete, consider part of an imaginary program:
try { compute(); } catch (...) { recover(); }The author of the recover function has to figure out how to recover from any kind of exception that the compute function might throw. In order to do so, the author needs to know what work the compute function might have done that the recover function might have to undo. Only with that knowledge is it possible to write the recover function -- if it is possible to do so at all.
Because recover's definition depends so strongly on the behavior of compute, it is useful for the author of a function such as recover to have access to an accurate description of the relevant behavior of functions, such as compute, that might potentially throw exceptions.
From the viewpoint of the author of recover, the ideal behavior for compute is for it never to throw an exception at all. In that case, recover is never executed, so its definition is irrelevant. When part of a program is known never to throw an exception, it is common to refer to that part of the program as meeting (or offering) the nothrow guarantee.
Of course, the mere fact that we are catching an exception in this imaginary program suggests that we already know that compute does not offer the nothrow guarantee. The next best case is called the strong guarantee, and we can describe it this way: if it throws an exception, the state afterwards is as if nothing had happened. In other words, if we say that compute meets the strong guarantee, what we are saying is that whenever compute throws an exception, we can pretend that that exception was thrown immediately before we called compute in the first place.
Functions that offer the strong guarantee are useful because they greatly simplify exception handling in the functions that call them. For example, suppose that compute had two sub-computations named compute1 and compute2. Then we might start defining compute by writing something like this:
// Not exception safe void compute() { compute1(); compute2(); }If neither compute1 nor compute2 offers any exception-safety guarantees, it is hard to see what guarantees compute might offer. However, suppose that compute1 and compute2 both offer the strong guarantee. Then there are three possibilities during execution of our compute function:
- The compute1 and compute2 functions both complete without throwing an exception. In this case, compute completes successfully, and we have no more work to do.
- An exception occurs during execution of compute1. In this case, because compute1 offers the strong guarantee, the effect will be as if the exception were thrown just before calling compute1, which means that our compute function will propagate that exception harmlessly.
- The compute1 function completes without throwing an exception, but an exception occurs during compute2. This is the difficult case, because we must undo whatever compute1 did. We don't need to undo what compute2 did, because compute2 undid it for us before throwing its exception.
// This version of compute meets the strong guarantee // provided that compute1 and compute2 do so void compute() { compute1(); try { compute2(); } catch(...) { undo1(); throw; } }It is not always possible -- or even desirable -- for a program to offer the strong guarantee, even if its constituent parts do so. For example, consider the standard-library copy algorithm, typically implemented along the following lines:
template <class In, class Out> Out copy(In begin, In end, Out dest) { while (begin != end) *dest++ = *begin++; return dest; }Suppose that our iterators (begin, end, and dest) all offer the strong guarantee, as do whatever elements we are copying. Is it possible for copy to offer the strong guarantee as well?
In general, the answer must be no. Consider what happens if copy is copying two or more elements, and the attempt to copy the second element throws an exception. The first element has already been copied, so the first element of the destination has already been overwritten. There is no way to recover that element's former value, short of saving it somewhere.
In other words, for copy to offer the strong guarantee, it would be necessary to save the value of every element that is to be overwritten, in case it is necessary to restore it after an exception. The overhead would be at least a factor of two in the most optimistic case. Perhaps that overhead is worthwhile to a program that needs the strong guarantee, but not every program needs the strong guarantee.
For this reason, among others, some programs offer only the weak guarantee, which, roughly speaking, is that if the program throws an exception, it leaves its data structures in a state from which they can be safely cleaned up. For example, in the case of the copy function, the weak guarantee would be that every element of the sequence that was to be copied was either successfully copied or not copied at all -- except for the particular element that was being copied when the exception occurred. The state of that element would depend, of course, on the exception guarantee offered by that element's assignment operator; if the element type offers only the weak guarantee, it is possible for that element to be in an intermediate state.
Why is the weak guarantee useful? The main reason is that it is good enough for data structures in temporary storage. For example, consider the following function:
template <class Iter> std::vector<typename std::iterator_traits<Iter>::value_type> sort_copy(Iter begin, Iter end) { std::vector <typename std::iterator_traits<Iter>::value_type> v(begin, end); std::sort(v.begin(), v.end()); return v; }This function takes two iterators, named begin and end, and returns a vector of sorted values of the corresponding type. (Recall that if Iter is an iterator type,
std::iterator_traits<Iter>::value_typeis the type of the elements to which such iterators refer.) The sort_copy function works by creating a temporary vector, named v, which holds a copy of the elements in the range that begin and end denote. It then sorts the elements of the vector and returns a copy of the sorted vector.
Any of these operations can potentially throw an exception. Moreover, the sort library function does not offer the strong guarantee. How could it? To do so, it would have to remember the original contents of whatever it was sorting -- a requirement that would obviate any reason for sorting in place. Nevertheless, our sort_copy function does offer the strong guarantee, provided only that the elements being sorted offer the weak guarantee. The reason is that the program puts all of its auxiliary data in local variables. Therefore, if it throws an exception, all of those variables are destroyed before any other part of the program has a chance to catch that exception, and the effect is as if the function itself were never called in the first place.
There is an even weaker kind of guarantee that we have not seen discussed much in practice: a program might guarantee only that if it throws an exception, it won't crash. In particular, it might leak resources or leave data structures sitting around that might cause the program to crash if they are touched. Obviously, such a weak guarantee is only marginally useful -- indeed, it's marginal enough that we don't know of a widely accepted name for it -- but it is worth noting that it can be more useful for a program to leak resources in exceptional situations than for it to crash.
Kinds of Handles
Now that we know what we mean when we talk about exception safety, let's revisit the various kinds of handles that we discussed.We note first that we care only about two specific exception-safety properties of the objects to which our handles are attached. The first property is that destroying an object had better not throw an exception. This property is universally important, because if it is impossible to destroy an object without something going wrong, it is hard to understand how any program that deals with such objects can avoid leaking resources. The second property is that creating a copy of an object must offer the strong guarantee: copying an object either succeeds or fails, and if it fails, the effect is as if nothing had happened. If our objects have these two properties, we don't care about other aspects of their behavior, because the handles don't ask the objects to do anything else.
In the first article in this series, we developed a kind of handle with objects that were always in one-to-one correspondence with the handle objects to which they were attached. In other words, copying a handle always copied the corresponding object, and destroying a handle always destroyed the corresponding object. We found that it was almost trivial to make such a handle offer the strong guarantee; the only difficulty was in assignment, which had to deallocate the old value of the left-hand side and make a copy of the right-hand side. Because this operation has two separate sub-computations, we had to be sure that an exception during the second one wouldn't cause trouble. Once we did so, we found it was possible for our handles to offer the strong guarantee.
Our second article introduced reference counting, including a counter directly in each object, and arranging for the handle objects to manipulate that counter as needed. We found, rather to our surprise, that all of our exception-safety problems vanished. However, perhaps we should not have been surprised, because in this version of our handle class, we had arranged that copying a handle never copies the corresponding object. We have already noted that for exception-safety purposes, we care only about copying and destroying the objects to which our handles are attached; if we rule out copying, and we know that destroying an object never throws an exception, we have avoided all of our exception-safety problems. Indeed, when we reintroduced the possibility of copying, we again had to do some work to make the handle class offer the strong guarantee.
Our third article introduced the idea of non-intrusive reference counts, making the counter a separate object from both the handle and the object to which the handle is attached. Non-intrusive reference counting is a nice idea in general, because it opens the possibility of attaching handles to classes whose authors didn't think of handles in the first place. However, if the reference count is a separate object, we have to cater to the new possibility that allocating the reference count itself might throw an exception. We found that under some circumstances it might be necessary to reflect this possibility back to our caller, effectively forcing our caller to allow for exceptions that would have been impossible if we were using intrusive reference counts.
Finally, our fourth article did away with reference counting entirely, relying instead on putting the handles themselves into a doubly linked list. This linked-list strategy may seem counterintuitive, but it works -- for several reasons:
- By making the elements of the linked list part of each handle object, we avoided any new possibilities of throwing exceptions, as well as avoiding any changes to the objects to which we attach our handles.
- We took pains to ensure that the operations on the pointers themselves could never throw any exceptions.
- Although we sometimes needed to know the value of the reference count, it turns out that all we needed to know about the value was whether or not it was 1. The corresponding test with pointers is to see whether a handle object points to itself.
In other words, by using linked lists, we were able to make the reference-tracking part of our handle class offer the nothrow guarantee, which meant that keeping track of references did not complicate the rest of our handle design.
Conclusion
Exception safety is a vast subject, and these articles have only scratched the surface. However, one idea has pervaded these articles that is so important that it is worth mentioning again: most problems with exception safety have to do with undoing changes to the program's state that happen before throwing an exception. This idea has several implications:
- If you can arrange for state changes to be undone automatically, you don't have to worry about what happens in case of an exception. For example, if you have a function, such as our earlier sort_copy example, that deals only with local variables, then that function's caller doesn't need to worry about cleaning those variables up after an exception.
- If you must change the state of your program in a context that might throw
an exception, the safest way to do so is to do everything that might throw
an exception first, and only then to change the state. So, for example, our
earlier articles had several examples along the following lines:
// unsafe delete p; p = q->clone();
where p and q are pointers, and where we want to delete the object to which p formerly pointed and then cause p to point to a copy of the object to which q points. Of course, this example is unsafe, because deleting p is a state change that is followed by an operation that might throw an exception. We can make it safe this way:// safe newp = q->clone(); delete p; p = newp;
Now, if the call to q->clone throws an exception, p still has its old value. - The second and subsequent state changes are more dangerous than the first. For example, even though our compute1 and compute2 functions offered the strong guarantee, we could not simply call them in sequence and maintain that guarantee. Instead, we had to go to extra trouble to maintain it.
More generally, the programs that seem to be the most successful at offering exception-safety guarantees tend to have relatively few try statements in them. Instead, they begin by doing things that might throw exceptions, and only then -- after all exceptions that might be thrown have been thrown -- do they start changing their data structures. This strategy, which we adopted in our handle examples, makes it much easier to convince one's self that exceptions will not cause unexpected trouble.
About the Authors
Andrew Koenig is a member of the Communication Software Research Department at AT&T's Shannon Laboratory, and the Project Editor of the C++ standards committee. A programmer for more than 30 years, 15 of them in C++, he has published more than 150 articles about C++ and speaks on the topic worldwide. He is the author of C Traps and Pitfalls and co-author of Ruminations on C++ and Accelerated C++Barbara E. Moo is an independent consultant with 20 years' experience in the software field. During her nearly 15 years at AT&T, she worked on one of the first commercial projects ever written in C++, managed the company's first C++ compiler project, and directed the development of AT&T's award-winning WorldNet Internet service business. She is co-author of Ruminations on C++ and Accelerated C++ and lectures worldwide.