The (B)Leading Edge: Looking Back at Exception Handling

Jack W. Reeves


As I write this on the eve of 2003, I realize that I have been writing "The (B)Leading Edge" for over six years. With a break in 2000 while the column disappeared along with C++ Report before it came back in its present incarnation (thanks again to CUJ), I have written 37 columns. That doesn't seem like a lot compared to many of the other writers in the C++ community, but many of my columns have been rather long — a lot of people (including my editors) would probably say too long. The bottom line is that I feel like I have written a lot — whether I actually have or not. And for the first time in many years, I really do not feel like I have a topic that I am enthusiastic enough about to actually sit down, research properly, and write up.

It seems rather humorous when I look back at that first column. I had meant to just write an article, but when I submitted it to C++ Report, then editor Doug Schmidt said it was too long. I offered to break it up into two or three sections, but Doug offered me a column instead. At first I was petrified. I got writers block even thinking about it. But then I thought of something else I might discuss, and then I had a third idea, and then another, and finally I realized that there was no reason not to write the column. Now, it seems that I have finally run out of ideas.

Oh, when I look at my notes, there are numerous things I have jotted down over the years as possible columns, but the most recent date on those notes is over three years old. Most of the ideas have either already been addressed by other writers or seem rather dated and uninteresting in comparison to the topics now being discussed.

One problem is that I have tried hard to focus "The (B)Leading Edge" on how to use Standard C++ and its library in the real world. As I said in the beginning, I wasn't interested in offering a tutorial, nor was I interested in exploring obscure features of the language/library just for the sake of exploring features. Instead, I really believed that features many people might consider obscure and needlessly complex were in fact useful — if used appropriately — in real world programming situations that most programmers would encounter sooner or later. That is still my focus and while there are parts of the library that I have not explored (valarrays being an obvious one), and probably features of the language that I haven't used, nothing that I am currently working on gives me any need to use those features.

Another aspect is that I never wanted "The (B)Leading Edge" to be a "C++ gotchas" collection. I wrote a number of such columns when I felt that the issues were important. In particular, a number of columns focused on problems with undefined behavior in various areas. This was and is an issue that I see daily in my work, so I know it is a problem. Yet I also understand the problems and needs of language and library implementers, so I have tried to point out the problem areas and at the same time offer reasonable explanations for their existence when and where I could. Unfortunately, in the last few months I have encountered a number of gotchas that have left me shaking my head. Some of them I have tracked down (at least to my own satisfaction) as being errors in compilers. Some I honestly believe are errors in library implementations. Others seem to be possibly legitimate differences in interpretation of some part of the Standard. Finally, a few seem to just be plain quirks in the language itself. All of them have annoyed and frustrated me because they forced me to rewrite what seemed to be perfectly good code for no good reason. There were no "lessons learned" in the process however, other than the timeless one of it always takes longer and is harder than I expected.

Finally, there is the fact that in 2003 the C++ Standard will be opened for review and revision. Part of me really wants to be involved in that process. I still think C++ is one of the most powerful and useful programming languages that has ever been developed, and I really think I could make some contribution. On the other hand, the cynic in me wonders. For numerous personal and practical reasons, I have found it harder and harder to keep up with the leading edge of the C++ development community of the last couple of years. Maybe I am getting burned out. Or maybe I just need to step back and get focused again.

In any case, I have decided to wrap up "The (B)Leading Edge" and put it in mothballs for awhile. Before I do that, however, I thought I would go back over some of my past columns and see what, if any, lessons might be worth carrying forward into the "next version" of C++. So let's go all the way back to the beginning and look at exceptions.

My first column [1] was titled "Coping with Exceptions," my second [2] "Guidelines for Throwing Exceptions," and the third [3] "Guidelines for Using Exception Specifications." When I finished, I remarked that I thought it would be a good idea to give exceptions a period of benign neglect to let them soak into the C++ user community. I have certainly done that. During that time, I had hoped to get quite a bit of user feedback on which of my suggestions worked and which did not. I have received far less feedback than I expected. At first, I wasn't sure if that was because nobody cared, because most people agreed and didn't see any point in saying so, or because most people disagreed too thoroughly to see any point in saying so. Since most of the other experts whom I have seen address this subject have offered guidelines similar to my own, I have come to the conclusion that it is probably more a case of one and two rather than three.

I think it is still an important topic however, so let's take another look at "Coping with Exceptions". In that column, I listed 10 guidelines for coping with exceptions. I reproduce them here (with permission from the author).

  1. When you propagate an exception, try to leave the object in the state it had when the function was entered.
  2. If you cannot leave the object in the same state it was in when the function was entered, try to leave it in a good state.
  3. If you cannot leave the object in a "good" state, make sure the destructor will still work.
  4. Avoid resource leaks.
  5. Do not catch any exception you do not have to.
  6. Do not hide exception information from other parts of the program that might need it.
  7. Unless you have a followed guideline 1, assume that you must destroy the object after any exception.
  8. Always catch an exception by reference.
  9. Never depend upon destructors for functionality in any situation where fault-tolerance is required.
  10. Do not get too paranoid.

In the article, I followed each of these guidelines with a number of suggestions about how to achieve the stated goal. I will not list any of those sub-points here. (Please see the article if you are interested.) Instead I will just look at what still makes sense, what I would say differently, and what hasn't worked out.

The first four of these were my attempt to categorize what the possible results of propagating an exception could be. (My attempt wasn't the first one.) These days, the generally accepted terminology (coined by Dave Abraham I believe) is based on the client's perspective:

The nothrow guarantee means that no exceptions will be propagated out of the function. Since that was outside the scope of my discussion, I will ignore it.

The basic guarantee says that no leaks have occurred and the object is in a consistent, but possibly unknown state. The basic guarantee essentially combines guidelines 2, 3 and 4 from above. I must admit that originally I had a small problem with the terminology of the basic guarantee. Using widely accepted definitions, the basic guarantee says that the object is still usable, although you may not know what state it is in. I always felt that there was something less than the basic guarantee, but still better than "undefined." I called this the destructible guarantee. Most people these days seem to feel that is the minimum guarantee that any well designed object should support and hence isn't worth being explicitly named. Furthermore, most experts seem to feel that obtaining the basic guarantee is not that difficult.

The strong guarantee is just guideline 1. So, if I were writing the guidelines today, I would simply make them:

Skipping to guideline 7, I noted that unless you have a strong guarantee you should plan on destroying the object. It seems that I could have done a little better with this one. If you have at least the basic guarantee, you can be sure that the object is still usable. You may not know what state it is in, but presumably you can ask the object itself to help you figure that out. Nevertheless, I think this is something that has to be addressed on a case-by-case basis. As general guidelines go, I still think it is a safer bet to plan on destroying anything you are not sure about.

Guideline 5 was really just an appeal to programmers not to clutter code with unnecessary try/catch blocks. Since most programmers are more than willing to not write code, that hasn't turned out to be the problem I was afraid it could become. Unfortunately, I am afraid that the primary sub-bullet of guideline 5 has been missed. In that, I suggested that code should be rewritten to cope with exceptions, rather than have a catch block do cleanup before rethrowing the exception. Unfortunately, I get the impression that an awful lot of code still just blithely ignores the possibility of exceptions altogether.

Guideline 6 is rather more interesting and is one of the real points of this column. My guideline was meant to ensure that an exception would propagate intact to the point where it could be correctly dealt with. To that end, my first sub-bullet was that you must always rethrow any exception caught by a catch(...) clause. This was just a special case of the more general guideline I had written as guideline 5c, which said "Do not 'handle' any exception that can not be 'fixed'." In this case, the term "handle" had the C++ meaning of catching an exception. (An exception is considered "handled" when the catch clause is entered.) My point was that if a routine did not have the context to restore the program to a correct state, then it should make sure the exception would propagate up the call stack to some routine that did have the context.

The problem in C++ is that if a catch clause is entered, then the language requires the programmer to explicitly rethrow the exception to have it continue propagating. This is in keeping with certain other languages, but I have concluded that this is too error prone. All too often, programmers forget to rethrow the exception, and it just gets swallowed instead of being correctly handled.

Suggested C++ change: instead of the current behavior, I would prefer that C++ require programmers to explicitly terminate a catch clause and have the clause implicitly rethrow the exception otherwise. It is interesting to note that this is the behavior if control reaches the end of a handler of a function-try block on a constructor or destructor, so it is not as if it is a totally new concept. We even already have an appropriate keyword to use — continue.

Guideline 8 is probably the only one that practically everybody follows, so I will skip it.

Guideline 9 is another can of worms — destructors and exceptions. Destructors and exceptions simply do not mix well — at least in the sense of destructors throwing exceptions. As I noted in the column, destructors can be thought of as auxiliary operations of the exception-handling mechanism itself, but even that was too weak. The fact is, when a destructor is invoked, it has to work. Except in certain special cases, the memory for the object will cease to exist after the destructor runs (the stack frame will go away, the memory will be restored to the heap, etc.). Even in the special cases, the memory is usually going to be reused for a different object. I have seen people come up with all kinds of elaborate schemes for "handling" an exception from a destructor as if somehow an exception thrown from a destructor would leave the object hanging around. It doesn't.

Beyond that, destructors are usually called when things are being cleaned up and/or torn down. There is often no rational ordering of such operations that would allow a handler to "put humpty dumpty back together again" if an exception occurred in one of the destructors. So, destructors should never throw exceptions. I discussed this guideline quite rationally, but basically I blew it on 9b. I suggested: "Do not arbitrarily handle all exceptions propagating from a destructor." My argument was that surrounding code should just stay out of the way. Unfortunately, that just isn't good enough. Guideline 9 is still correct, but the discussion needs to be restated.

The point that I was trying to make was that destructors should never throw exceptions, so if you expect to invoke operations that can throw exceptions in situations where they need to be handled, then you have to invoke the operations before you invoke the destructor. Examples might be committing a database transaction, flushing a communications buffer, etc. If you do such operations in a destructor, and they fail, whatever attempted recovery has to be done by the destructor itself. When it exits, the fat lady sings and the game is over.

This says that all destructors should meet the nothrow guarantee. Since nobody seems to disagree with this, why shouldn't we move it into the language itself.

Suggested C++ change: propagating an exception from a destructor should call terminate(). Note that this is already the case if the destructor was invoked as part of an exception stack unwind, so again this is not a brand new concept. Under the circumstances, I think it would actually make the language more consistent.

Let us wrap up with guideline 10. I actually had two reasons for writing that one although I discussed only one of them in the column. The one I discussed was that you could get yourself wrapped in a knot trying to do too much repair and recovery in cleaning up an exception's damage. In truth, I don't see anybody doing this. The unstated part of the guideline was that exceptions are really pretty rare events in good programs, so the best approach to exception handling is to write good, clean code and just be aware of the possibilities. Obviously, the degree of paranoia that seems appropriate will depend upon the application domain, but most of us don't have to have the nothrow guarantee or even the strong guarantee in most of our code. I still think this is perfectly valid. There are certain habits that programmers need to have when writing code in an exception-throwing environment, and these habits take some discipline, but not much. They actually make code easier to write and debug in the long run.

In conclusion, when I look back on my first column, I think it stands up pretty well. Some of the terminology changed — for the better, but most of the basic concepts were correct. I got only one sub-bullet completely wrong. In retrospect, the biggest problem is not the guidelines themselves, but the fact that most programmers still don't pay any more attention to the problems of exception propagation now than they did then. I have suggested a couple of changes to the C++ language that would make certain common errors more difficult to make, but I don't really expect any changes of that sort to be considered. The most important thing we can do is just try to encourage ourselves and other programmers to get in the habit of always doing things in an exception-safe way.

References

[1] Jack Reeves. "Using Exceptions Effectively: Part I — Coping With Exceptions," C++ Report, Mar/Apr 1996.

[2] Jack Reeves. "The (B)Leading Edge: Guidelines for Throwing Exceptions," C++ Report, May 1996.

[3] Jack Reeves, "The (B)Leading Edge: Guidelines for Using Exception Specifications," C++ Report, July 1996.

About the Author

Jack W. Reeves is an engineer and consultant specializing in object-oriented software design and implementation. His background includes Space Shuttle simulators, military CCCI systems, medical imaging systems, financial data systems, and numerous middleware and low-level libraries. He currently is living and working in Europe and can be contacted via jack_reeves@bleading-edge.com.