- The Harpist:
-
Below is a letter from Ken Hagen that I have annotated with a few comments as I read it. I hope that you will read plenty of smileys into my notes as what he says is useful, and he has much in common with many other programmers. I think the move to writing code in an exception handling environment is even bigger than the move to OO. I think that the Standards Committees have let us down by lacking the courage to support exceptions with a good exception specification (ES) scheme.
I think that many writers let us down with naïve exception handling code. I am disappointed to see how many gurus take such a negative attitude to the concept of ES and static analysis. We need static analysers and we need to write code that can benefit from them. It is a little chicken and egg problem.
- Ken Hagen:
-
I was interested to follow The Harpist and Detlef Vollmann as they exchanged views on exception specifications. However, my own impression is that too many authors are now advocating widespread exception usage where it is not appropriate. As I understand it, exceptions are supposed to be a superior alternative to return codes for two reasons.
It is easy to separate the point of detection from the point of handling. So easy, in fact, that exceptions can travel huge distances up the stack with little or no effort on the part of the programmer.
- TH:
-
The language semantics for stack unwinding clean up automatically, eliminating one of the larger costs of propagating errors in a traditional scheme. Therefore, a single catch point can service many points of failure.
While I agree that a single catch could service many points of failure, generally the failure should be handled at the earliest opportunity. What I am seeking is a way to make this likely by using ES to limit the normal propagation. Static checking of ES should allow early detection of late handling.
- KH:
-
I propose to argue that both of these are wrong in the majority of situations, and that... The separation of detection from handling leaves different levels of the program coupled. This is poor practice, and anything that encourages it is suspect.
- TH:
-
Au contraire, the idea is to reduce coupling. When a disk is full, my output routines can detect the problem but I have absolutely no idea as to what the user (client programmer for my library) wants to do about that. Exceptions provide as much decoupling as is possible. Agreed that it does not decouple the two, but the process of writing a file is inherently coupled to the output stream doing the job. What should not happen is that the process of writing a stream need know why it is doing it.
- KH:
-
The automatic unwinding is useless in the vast majority of cases, where side effects (not resources) are what we want to clean up. Line by line clean-up is usually needed, making a single catch point impossible.
- TH:
-
Excuse me, you are the one specifying a single catch point. What I expect is that resources are released between the point of detection (throw point) and the appropriate catch point. Imagine that my program has acquired the use of a serial port. Imagine also that it is a long running program that is robust and fault-tolerant. In other words the program may run for days and knows how to recover from problems. How do you propose to ensure that the serial port is released if some exception is raised after acquisition of the serial port and is caught at a stage when the serial port should have been released? Note that to the best of my knowledge, Java cannot handle this problem because it has no way to force the running of finalize methods
- KH:
-
I will then argue that making functions safe for exceptions to propagate through them is much harder than writing no-throw versions, and so I conclude that:
Exceptions should be avoided wherever possible and throw() appended to every function prototype. Library interfaces should never use them.
- TH:
-
Oh, and how exactly do you propose that the string ctor report that it has been unable to acquire sufficient memory to complete its task? Of course the programmer needs to catch that problem as soon as it occurs but there is absolutely nothing that the library implementor can do except shout loudly (i.e. throw std::bad_alloc()
- KH:
-
Separation first. It is best to handle errors as close to the point of failure as possible. (People can disagree over how close that is, mind you.) Exceptions don't prevent this, but I think most of us would feel something was wrong if try / catch blocks were as common as if/else in a section of code, and it is easy to write a few centralised try / catch blocks and throw exceptions over long distances. The result is that exceptions favour separation of detection from handling, and centralising all handling in a few places. There are two problems with this
- TH:
-
That is a matter of education. How many C programmers ever test the return value of malloc() . Some do and some do sometimes but most do not. Putting ES in your code highlights lazy programmers who simply cannot be bothered to catch problems early because their exception specifications will grow out of control.
- KH:
-
Some or all of the context of the problem may be lost as part of the stack unwinding process. Even the location of the error may be lost if functions make heavy use of common exception classes.
The remaining context is only preserved if either the exception carries a lot of information which the handler understands, or the handler has enough knowledge of the implementation to deduce the throw point and its context.
Considering the first of these, taking appropriate action (as distinct from simply printing "Operation failed" and running away) becomes proportionately more difficult as the catch point retreats from the throw point. It is easy to attach an error message to almost any exception, but most errors are not best handled by telling the user about the problem. Usually, we need an alternative program flow. Perhaps we try a different algorithm, slower but cheaper or safer. Perhaps we try a different operation, as when we fail to find an important file and fall back on a "last known good", or built-in defaults.
- TH:
-
I think you miss the point. There are two possible places for catch clauses, immediately after the suspect operation. In other words there is a visible throw in the try block. This simply bundles problems in one place in your block of code. The second sane place is in the code that called the function that threw the exception. Sometimes a greater distance is acceptable but if we use ES those case become visible to all concerned. It is the omission of an ES that causes the problem.
- KH:
-
Let us overcome this first problem by filling our exception object with a lot of descriptive data, or by simply knowing enough about the implementation of the functions we're calling that we can deduce what happened. This is a coupling between the detection and the handling; one has to know about the other. Over short distances, as might commonly occur if we were passing return codes around, this probably isn't a problem. Over longer distances it is the sort of coupling between two levels of abstraction that we like to avoid.
- TH:
-
What about Francis' favourite EndOfProgram exception? How else would you propose that we terminate a program correctly in an environment where clean-up is needed?
- KH:
-
I conclude that most exceptions are best confined to small areas of code where all the functions are designed and implemented together. That way, the context is pretty obvious and coupling isn't much of a problem anyway. This restriction precisely fits the areas of the language where exceptions are absolutely necessary: constructors, destructors and overloaded operators that cannot return an error code. If these throw an exception, we should catch it immediately.
- TH:
-
Hang about, what is this belief in error codes? Every bit of my experience says these are a menace because programmers ignore them with the result that programs can go completely wild. However I entirely agree with you on the subject of writing code that handles exceptions as soon as possible. Doing anything else is silly.
- KH:
-
Unwinding next. There is an idiom by the name of "resource acquisition is initialisation". The idea is that the constructor grabs the resource, and the destructor releases it. Its first selling point is that since destructors are always called, even when unwinding, we never leak resources. The second is that since destructors are always called in reverse order of construction, the resource releasing generally occurs in precisely the right order. The natural unwinding of C++ is precisely what we need, so we don't have to add anything.
Sadly, if the destructor experiences an error (say, in committing changes to a file) we are stuffed. (It cannot throw an exception, because exceptions during unwinding are fatal program errors.) Also, resource acquisition is the only case where the natural unwinding of C++ is what we need. A much more common kind of "unwinding" is carefully unpicking all the side effects that we accumulated before we ran into trouble. Most of the interesting behaviour of a program lies in its side effects and it is extremely common to have a sequence of operations directed to some larger goal.
- TH:
-
What you are talking about is commit or rollback and exceptions actually help with that. What a destructor must do is catch any exceptions and do what it can with them. What it must not (in general) do is propagate any exception. There are a few rare cases where it might be acceptable to allow a destructor to throw an exception if no other is being processed because something could be done to fix the problem before making another attempt to destroy the object. However this case is so rare that I have yet to see an example of it in live code.
CreateNewRecord() CopyHeaderOver() for (int record=0; record<10; ++record) CopyBody(record); SubmitNewRecord() for (int record=0; record<10; ++record) DeleteRecord(record);
- KH:
-
Each of these operations may have some side effect, and will require some sort of "Undo" if an error occurs. Obviously we only want to undo the things we've done, so we'll be watching for success or failure at each step. Such fine-grain error detection can be based on either try / catch or if / else constructions, but it will be line-by-line and heavily nested in either case. It doesn't matter how you catch the error.
Except that it does. Consider the following example from "Guru of the Week" ( http://www.cntc.com/resources/gotw020.html ).
String EvaluateSalaryAndReturnName ( Employee e ) { if( e.Title() == "CEO" || e.Salary() > 100000 ) { cout << e.First() << " " << e.Last() << " is overpaid" << endl; } return e.First() + " " + e.Last(); }
There are 23 paths through this code. If we can specify " throw() " on all our functions, then we can ignore 20 of them and just worry about 3. (The " if " test can go either way, and it contains a logical or so the second expression may never be evaluated.) Guess which version is easier to verify and maintain! The real problem is that most of these paths are implicit. Implicit actions, such as automatically invoking a constructor when we declare a variable, are a Good Thing when they do what we want. Here they are threatening to bypass the declared flow of control. This isn't so good.
Most of the operations in this example either won't throw or have no side effects worth undoing, or both. However, if they did, and we attempted full backtracking, we would need line by line testing and would quickly arrive at a 40 line function. For robust software, we ought to write that 40 liner. The ease with which we can reduce it to 4 lines and a bucket of exceptions should not tempt us into doing so. The longer version is clearer, and sooner or later clarity will translate into quality. Long term side effects, not short term resource usage, is the point of most programs, so the promise of "automatic housekeeping" just doesn't pan out.
- TH:
-
No, that is not the way to do commit or rollback. I think that like many of us you have yet to develop a style of C++ programming that works well with exceptions. You may need to rollback manually but that is itself dangerous. What happens if something goes wrong during the rollback?
- KH:
-
My conclusion from this is that exceptions are bad psychology (encouraging people to give up early and leave the problem for the caller to deal with) and bad design (because return codes only couple to the immediate caller, but exceptions couple over much larger distances). Furthermore, because error codes are explicit, they are easier to read and omissions are easier to detect. So when would I ever use an exception?
- TH:
-
If this is true, error codes are even worse psychology.
- KH:
-
Only when I have to. Constructors, destructors and most overloaded operators cannot return errors in any normal sense. I normally keep "risky" operations out of these kinds of function, but sometimes it is a better compromise to throw and catch exceptions. Even in these cases, I would prefer to catch the exception locally and either handle it or return a conventional error code. Similarly I might decide that a small cluster of (usually private) functions are best expressed by blindly hoping for the best and catching all errors in a single catch in the caller. This happens when the number of possible failures (or the call stack depth) is large and the number of side effects to be unpicked is small. Again, I would still want to catch the exception at the closest reasonable level and turn it into an error code.
- TH:
-
You really have more faith in error codes than I do. Even worse, the handling of error codes cannot be statically checked by any known or imaginable analysis tool.
- KH:
-
Either way, I remain unconvinced that a major public interface (such as for a 3rd party library) should ever threaten to throw an exception. A return code is cheaper at run-time, more portable to non C++ clients or special C++ environments (such as Microsoft's COM), and does not require every caller to be exception aware. Which brings me to the standard language and library, which have 4 exceptions each. Fortunately, there are realistic alternatives to some of them.
- TH:
-
I ask again; exactly how do you propose to manage resource failures? The writer of an operator >> () for a class has absolutely no chance of knowing what you want to do if the operation fails. Aborting a program because an istream has failed seems massively over the top to me, but how else do you propose to ensure that the program does not ignore this problem. An error return (even if possible - you cannot have these for operators) does not work. Even worse, in multi-threaded code error flags (essentially static in many cases) are an unmitigated disaster.
- KH:
-
dynamic_cast throws bad_cast , but only if you use a reference. Casting a reference when you cannot prove the cast will succeed is a design error on a par with dereferencing a pointer which may be NULL.
- TH:
-
Really? I know many people who would disagree. Though I prefer using a pointer and checking for a null pointer, that is hardly different from using a catch. It really depends on context. The design error is neither checking for null nor catching bad_cast()
- KH:
-
typeid throws bad_typeid if you give it a NULL pointer. I have no sympathy here either.
bad_exception is available to automatically map exceptions, which are not in the function's specification. You'd be better off specifying things properly at compile time.
out_of_range causes me the most amusement. It seems that the new-fangled "safe" containers are so difficult to use that we can't be expected to know whether the index is within range until we try it. Er, why not check the size first, like we've been doing for the past 4 decades?
Lastly, we have ios_base::failure . In this case, ios_base::exceptions can be used to control the behaviour. Exceptions might actually be "in their element" for some forms of I/O. I can imagine reading configuration information which is in plain text format for flexibility, but which nevertheless should be syntactically perfect because it is part of the program rather than the user input. If we take the view that "all errors are fatal" for this data, then a single catch covering a multitude of input statements would be the smart way to handle it. For user input, I might still use an exception, but it would be one of my own with enough additional context information to indicate precisely where and what the error was.
Missing from this list are two other exceptions throw by bitset s. invalid_argument is thrown when a bitset is constructed from a string, which doesn't contain just ones and zeroes. Obviously it would take as long to check the argument as it does to construct the bitset, so at first I had some sympathy here. Then I thought why not use a member function that does return an error, rather than a cute constructor that can't. Then I noticed, there is no such member. Failing to provide this assignment is surely an omission. Secondly, the conversion from bitset to integer ( unsigned long ) runs the risk of the overflow_error exception. Since the maximum size of the bitset is part of its type, this strikes me as another problem that could be proven at compile time or checked at run-time. Sadly, there seems to be no easy way to determine the most significant non-zero bit, or indeed any group of bits beyond the first numeric_limits<unsigned long>::digits . This is surely another omission. I'm not impressed with this class. If I really needed bitsets longer than my longest integral type, I'd write my own.
Harder to deal with is operator new and bad_alloc . I would suggest you use the nothrow variant, and I expect you could create STL allocator s that use this strategy, but I also suspect that most implementations of the STL assume their allocations succeed.
It is a real shame that exception specifications are not enforced. I'd have far fewer qualms about using exceptions if they were as strongly typed as return values. At least then I would know what I had to worry about. The language urgently needs a way of writing templates that "pass-through" the exception specifications of their arguments. A way of modifying that list would be nice too, but I fear the latter is the enemy of the former, and we will get neither until someone has worked.
- TH:
-
It wasn't until I got to the last few of paragraphs that I finally realised that at heart you are a C programmer. For a long time I thought that the decision to have new throw bad_alloc was terrible because it broke so much existing code. More recently I have come to realise that it was that decision that powers the STL. Yes exceptions make heavy demands on our coding style. Coding with them requires an entirely different approach to coding without them. Few programmers and even fewer authors realise this.
There are clearly several places where there are no sensible options to using exceptions. For example both constructors and user-defined operators provide no alternatives. Surely the right answer is to teach programmers how to write good code in the context of exceptions. If only the rules of the language provided better support that should include using exception specifications and catching problems as soon as reasonable but no sooner. If programmers learnt to do the latter and the language gave good support for the former many exception specifiers would reduce to throw() .
C++ is nothing like C but until programmers realise that we will continue to get C idioms argued as being the correct solution to C++ coding problems.
Having said that, thanks for the viewpoint. Probably more people agree with you than agree with me.
Exception Discussion
By Ken Hagan, The Harpist
Overload, 6(29):, December 1998