📄 reeves.htm
字号:
Coping with Exceptions. By Jack Reeves This is an updated version of an article that appeared in the March 1996 issue of the C++ Report.Coping with Exceptionsby Jack W. ReevesIn April 1995 the ANSIISO C++ committee released the Committee Draft of the C++ Standard. This draft freezes the feature set of the language and the standard libraries. In the Standard, Chapter 15 - Exception Handling takes only 7 pages. Of all the language chapters (1-16), only Chapter 1 (General: 6 pages), Chapter 4 (Standard Conversions: 4 pages) and Chapter 6 (Statements: 7 pages) are less than or equal in length. One might conclude from this that exceptions are an easy concept to specify and that there are not a lot of subtle details that need to be understood. Looks can be deceiving.When I started to use exceptions, it rapidly became apparent that exceptions are much more difficult to use effectively than it first appears. In fact, the more I explored how exceptions can interact with non-exception handling code, the more convinced I became that exceptions may be the most difficult feature of C++ to use. I identified 3 reasons for this difficulty: In order to be effective, exception handling must pervade the entire application it can not be localized to a function or even a class. Exceptions are almost like asynchronous events they do not respect semi-colons and can appear from the middle of an expression. Exceptions can not be ignored or "put off" if not handled an exception will propagate up the call stack, eventually aborting the entire program.It is the first of these that is the real problem. When using other features of C++ such as classes, overloaded operators, virtual functions, or templates, it may be difficult to write the code correctly in the first place, but once written it is easy to use from then on. Exceptions go against this trend. Throwing an exception is easy; writing the code that uses the function that throws the exception is the hard part. Every function that can have an exception propagate out of it must be designed to propagate that exception correctly. As Tom Cargill says in Reference 1, "The really hard part of using exceptions is to write all the intervening code in such a way that an arbitrary exception can propagate from its throw site to its handler, arriving safely and without damaging other parts of the program along the way."Of course, it is (ii) and (iii) that make writing all that intervening code difficult. Consider the apparent asynchronous nature of exceptions (exceptions are not truly asynchronous events like a network driver interrupt, but from the outside, they can have almost the same effect). The following statement: while (*i++ = *j++) ;is a typical C idiom that has been carried over (with some major extensions) into C++. In fact, one can almost say that the Standard Template Library was designed around such idioms. In C++, i and j can be two different, user-defined iterator types. Assume that i iterates over a container of A objects, and j iterates over a container of B objects. Let's let a represent *i and b represent *j. Assignment will have been overridden if A is a user defined type, but it may not be defined specifically for the type represented by B, necessitating invocation of a user-defined conversion function. Another user-defined conversion function may have to be invoked to convert the result of the assignment statement into something that can be converted to type bool for the "while" condition. Any of these user defined operators or conversions might throw an exception or any of the functions that they invoke, and so on. If an exception occurs in this statement, the user may have no idea where it came from. More importantly, the order of evaluation of several parts of the above expression is unspecified. Therefore, if an exception appears, the user may have a hard time determining the current state of i, j, a, or b.Finally, as noted in (iii), exceptions can not be ignored. With older error reporting techniques, nothing but discipline forced the programmer to check the return value or error code. Even if you knew errors were possible, you did not have to check the return value immediately, just as long as it was checked before it mattered. With exceptions, this is no longer possible. You have to cope with an exception when and where it occurs, not when and where it is convenient for you. This is true even if you do not plan to actually handle the exception.So, I have concluded that using exceptions effectively is going to be difficult. Nevertheless, I have also considered the alternatives. Without exceptions it is impossible to return an error from a constructor or an overloaded operator. This situation has lead to several ad-hoc schemes. A couple of the more popular ones are: Set an internal error flag. An operator! function or a conversion to bool or void* is often provided to simplify testing the state of the object. Such a scheme can be used to tell if a constructor (or an operator) succeeded or failed, but it does not tell anything about why the failure occurred. For that, a member function that returns the value of an error code is usually provided. Two-stage construction. In this scheme, the actual object constructor only initializes memory. A second "initialization" function is called after an object is 'constructed' to do resource allocation and any other complex operations that might encounter errors. This scheme does provide a means to return error indications to the user, but the user has to know about the need to initialize the object before it can be used. It does not work with overload operators.Since there is no standardization for any of this, it is difficult to combine libraries that use slightly different techniques. It also is virtually impossible to use such classes to instantiate general purpose templates like those that are now part of the Standard C++ Library. The truth is: without exceptions, a lot of the features that make C++ so powerful constructors, overloaded operators, and templates are either not as robust as we need them to be, or simply can not be used in situations where good error handling is required.What follows are some goals and guidelines that I have put together on how to cope with exceptions. The "goals" are general, high level concepts. Meeting a "goal" may involve re-writing a function or major changes to a class library. The "guidelines" are more specific, lower level concepts that can often be applied to help meet the goals. As with most such offerings, every user will have to evaluate their own circumstances and determine which of these goals and guidelines are applicable to their situation. In many cases, I have found that exceptions are already approaching the status of religion. Under the circumstances, I try to offer reasonable explanations for my recommendations. Nevertheless, my ultimate goal is more reliable software, not religious discussions. If other approaches work for you, by all means use them. I would like to hear from readers who have applied these and other guidelines on real projects successfully or not. The more collective knowledge we can build on this topic, the better off we will all be.Terminology and ConventionsIn the following, I often mention the "state of an object". In Reference 2, Dr. Harald Mller presents a more general discussion of the problems I discuss in goals I through III. In his article, Dr. Mller talks about "resources". I have decided to stick with the term "object" since that is the type of resource most C++ programmers are going to be concerned with most often (I do use the term "resource leak"). In Dr. Mller's article, he defines and discusses how resources can be in either "good" or "bad" states. While my concepts are similar, I prefer slightly different terminology than Dr. Mller. In simple terms, an object can be in one of the following states: Good - an object is in a good state when its member variables are consistent relative to each other and the object represents a valid data element of the abstract type that it is modeling. Bad - an object is in a bad state if it somehow becomes inconsistent internally or no longer represents a valid abstract data element. When a object is in a bad state, certain operations on that object will no longer work properly; other operations may continue to work. Most especially, according to my usage, when an object is in a "bad" state I expect the destructor to still work. In other words, an object in a bad state is not a good object, but its state is still well defined enough to allow the program to continue. Undefined - an object in an undefined state has been corrupted to the point where it can not even be safely destroyed. When an object is in an undefined state nothing is guaranteed to work, and something catastrophic is probably going to happen if any attempt is made to use the object (e.g. a process abort).My definitions correspond fairly closely with Dr. Mller's as follows: Dr. MllerMine goodgood bad (valid shut-down)bad bad (not a shut-down)undefinedI differ primarily with Dr. Mller over the question of what operations are valid when applied to an object that is not in a good state. In the real world of typical C++ software, an object in an inconsistent or bad state may still be quite usable and the program in which the object is embedded may continue to run correctly.For certain examples in the rest of this article, I use the same Stack template class as Dr. Mller uses in his paper. The original version of this template was presented by Tom Cargill in Reference 1. The interface for this class is shown in Listing 1. While the Stack class is fairly simple, it remains a good example of the difficulties inherent in using exceptions.A couple of brief notes about coding style: I prefer to use the constant symbol null to represent the null pointer in my code instead of the current convention of using 0. I ask the reader to assume the following declaration exists within the scope of all the code that follows: const long null = 0L; // see Item E25 for a discussion // of this approachI have adopted a commenting convention of //>x to indicate places in a function where exceptions are possible. Like any manual approach, this technique is error prone and very likely to miss something. It is better than nothing, however, and I have found it useful.The following table summarizes the goals and guidelines that are presented in the next section. Table: Goals and Guidelines I.When you propagate an exception, try to leave the object in the state it had when the function was entered. Make sure your const functions really are const. Perform exception prone operations early, and/or through temporaries. Watch out for side effects in expressions that might propagate exceptions.II.If you can not leave the object in the same state it was in when the function was entered, try to leave it in a good state. Either reinitialize the object, or mark it internally to indicate that it is no longer usable but might be recovered. III.If you can not leave the object in a "good" state, make sure the destructor will still work. Do not leave dangling pointers in your objects. Delete pointers through temporaries. IV.Avoid resource leaks. If you have raw data pointers as members, initialize them to null in the initializer list of your constructor(s), then do necessary allocations in the constructor body where a catch block can deal with potential resource leaks. V. Do not catch any exception you do not have to.Rewrite functions to preserve state, if possible. Always use a catch (...) block to cope with propagating exceptions. If you get stuck, call terminate(). VI. Do not hide exception information from other parts of the program that might need it. Always rethrow the exception caught in a catch ((...) block). Rethrow a different exception only to provide additional information or capability. Make sure one catch block does not hide another. VII. Never depend upon destructors for functionality in any situation where fault-tolerance is required. Do not throw exceptions from a destructor body. Do not arbitrarily handle all exceptions propagating from a destructor. VIII. Do not get too paranoid.Catching and Propagating ExceptionsWe must state some assumptions before we actually start to deal with exceptions. We will assume that an exception thrown by a lower level routine indicates that the routine failed. In a follow-on article [Reference 3], I present some goals and guidelines for throwing exceptions. While there is no way to ensure that exceptions are only used to indicate a failure condition, (and there are a couple of situations where they may not), it is probably safe to assume that most programmers are not going to accept the overhead inherent in an exception throw (see Items M12 and M15) if other mechanisms are available to indicate normal completion.We will also assume that exceptions are typically a rare occurrence in a well designed program. This means, in general, that the condition causing the error is not likely to be one that is easily anticipated and handled by the next higher level routine. The entire exception handling mechanism is designed to pass information from the point where an error is detected up the calling chain to a higher level of the program where enough information exists to be able to handle the error. In many cases, exceptions are going to propagate all the way to a human operator. Therefore, it is probably safe to say that the vast majority of code will simply propagate exceptions from lower level routines to higher level ones.It is this propagation of exceptions that causes the grief and anguish. The function that throws the exception knows what state it is in and it chooses when to throw the exception. The function that finally handles the exception remains in control it must necessarily branch into a separate exception handling path but in handling the exception it recovers from the error and continues. All of the intervening functions between the one that originally threw the exception and the one that finally handles it are a problem. When an exception propagates out of a function, it indicates that the function failed. It is as if the function threw the exception, but without any control over where the throw occurs. The propagating exception terminates the function, unwinds its stack, and continues on its way.If not anticipated and handled correctly, this propagation of an exception can leave dangling resources, bad or even undefined states, and in general cause more problems than the original error. Propagating exceptions destroy otherwise perfectly rational flow of control through a program. In a very real sense, exceptions are "goto's from hell". So how do we cope with them?Goal I. When you propagate an exception, try to leave the object in the state it had when the function was entered. This is the Golden Rule of exception handling. It is not going to do much good for a program to handle an exception if the program has gone into an invalid state as a result of the exception propagating to the point where it could be handled. Writing code that meets this goal is very difficult. In truth, it may often be impossible. Nevertheless, I put it first because it is the target that should always be the ultimate goal. How to go about this is a complicated subject, and there are more detailed articles on dealing with it [Reference 2]. I will only give a few simple guidelines here.Guideline 1. Make sure your const functions really are const. The first step in making sure objects stay in good states is determining which operations on the object actually change the state. The C++ language assists us in this regard by requiring member functions that can be applied to constant objects to be designated as const functions (see Item E21). As we all know however, const can be subverted, either by designating some members mutable (again, see Item E21), or with a const_cast (see Item M2). If your const functions really do not change the state of the object, then you can safely ignore them exceptions that propagate from them will not affect anything.On the other hand, if your const functions do change the state of the object, then you must treat your const functions like your non-const functions only more so. As a user, I would be very annoyed if I discovered that a const function could actually change the object into a bad state under some circumstances. If you discover such a function in your class, I would urge reconsidering whether it should be a const function. An example of this actually occurs in the Standard C++ Library. The basic_string function c_str() is a const function, but the specification is clear that an implementation can reallocate the internal character array if necessary to make room for the terminating NULL character. This could throw a bad_alloc exception. When a const function exhibits such behavior, it has numerous ramifications, most of them bad.Guideline 2. Perform exception prone operations early, and/or through temporaries. Consider the Stack::push function: template<class T>Stack<T>::push(T element){ if (top_ == nelems_) { T* new_buffer = new T[nelems_*= 2]; //>x1 for (int i = 0; i < top_; i++) new_buffer[i] = v_[i]; //>x2 delete[ ]v_; //>x3 v_ = new_buffer;}v_[top_++] = element; //>x4}Each of the comments indicates a statement that potentially can throw an exception. For example, the statement at x1 can throw a bad_alloc exception if there is not enough memory to satisfy the request. In this statement, the member variable nelems_ is increased, and then used as the size of the new request (a typical C idiom). If this request fails and throws an exception, it propagates out of push() leaving nelems_ set to the new value. The Stack object is now in an bad state.Besides doing memory allocation, statement x1 also uses T::T(). Similarly, statements x2 and x4 use T::operator=(), and x3 uses T::~T(). Each of these functions might throw an exception. For now I will ignore the possibility of exceptions from destructors, but x1, x2, and x4 are all potential problems. If we propagate an exception from one of these operations it will indicate that the push() operation failed, but in what state will we leave the Stack object?An exception caused by a failure in T::T() at x1 has the same effect as a bad_alloc exception. If we use a temporary variable for the new value of nelems_, then an exception from x1 can safely be allowed to propagate. We will assume that if T::T() throws an exception the runtime mechanism will destroy all the already constructed elements in the array and release the allocated memory. (Except in pathological cases, which are discussed below, the standard guarantees this behavior.)If one of the assignments in the loop at x2 fails, the internal state of the Stack object is still good, but we have a memory leak since the buffer pointed to by new_buffer will never be deleted. We will discuss resource leaks in more detail under Goal IV below.If the assignment at x4 fails, variable top_ will already have been incremented. In this case, the exception will indicate that the push() failed, but the internal state of the object will have been updated as though it succeeded. Again, we are in a bad state.In order to make sure that possible exceptions that propagate out of push() leave the object in its original state (and do not cause a memory leak), a number of modifications are necessary: Use a temporary for the new array size. Use a temporary object of the template class auto_array_ptr (see below) to create an automatic variable that manages the new buffer. After the new buffer is initialized, swap ownership of the new buffer between the auto_array_ptr object and the stack. Delay the increment of the top_ variable until after the new element is assigned to the stack buffer.Class auto_array_ptr is similar to the auto_ptr template class defined in the utilities section of the C++ Standard Library (See Items 9 and 28). An auto_ptr is an object that owns a pointer. The destructor for auto_ptr deletes the object pointed to. My definition of auto_ptr is shown in Listing 2. The header for auto_array_ptr is shown in Listing 3 (its definition is almost exactly the same as for auto_ptr except that it calls delete on its pointer instead of delete). Using an auto_array_ptr object will solve our memory leak by automatically deleting the array if an exception occurs. When the new array has been initialized with copies of the elements from the old buffer (an exception prone task), the ownership of the new buffer is assigned to the stack object. Lastly, we re-write the assignment at x4 so that we do not update the state of the object member top_ until after the potentially dangerous assignment has completed. All this gives us: template<class T>Stack<T>::push(const Telement){ if (top_ == nelems_) { size_t new_nelems = nelems_ * 2; auto_array_ptr<T> new_buffer = new T[new_nelems]; //>x1 for (int i = 0; i < top_; i++) new_buffer[i] = v_[i]; //>x2 v_ = new_buffer.reset(v_); //>x3 nelems_ = new_nelems; } v_[top_] = element; //>x4 top_++;}The statement at line 3 swaps ownership of the old buffer and the new buffer. After the statement, the auto_array_ptr object owns the old buffer and the Stack object owns the new buffer (via its member v_). The new_buffer object will then delete the old buffer when it is destroyed at the end of the block. I do it this way so that if an exception occurs when the old buffer is deleted, the stack object will still be in a good state.Even this simple example illustrates a key difficulty in coping with exceptions. When writing a function, it is necessary to decide which operations might cause exceptions and which operations are exception-safe. Exception specifications (which are discussed in a later article [Reference 4]) can help in this task, but they are not a complete answer (see Item M14). In particular, template programmers do not have access to the exception specifications of classes used to instantiate the template. (For a further discussion of this topic, consult Herb Sutter's article, "Exception-Safe Generic Containers"). Once you get into the mind set of expecting exceptions, the problem often becomes trying to determine which statements are NOT possible sources of exceptions. The only operations that can safely be assumed to never throw exceptions are the basic operations on the built-in data types. In the example above, the call to auto_array_ptr::reset() is safe because all it does is swap two built-in pointer types through a temporary.The auto_array_ptr object automatically deals with the possible exceptions in the loop at line x2. An exception prior to line 3 will now propagate out of the function without causing either a bad state or a memory leak. That leaves only the possible exception from the assignment at line x4 to be dealt with, which brings up Guideline 3.Guideline 3. Avoid side effects in expressions that might propagate exceptions. Side effects are a fact of life in C++. Some of them we can not see directly (and can not do anything about). Others we can see. In statement x4, whatever happens inside T::operator=() are of the former type, top_++ is one of the latter. As it was originally written, an exception from T::operator=() was guaranteed to leave us in a bad state (at least the state might have been even worse, i.e. undefined) because top_ would have been incremented before T::operator=() was called. Since T::operator=() might throw an exception, we must avoid the side effect to top_ by postponing the increment.Note that even with this change, we can not be sure what state the Stack object is in if an exception occurs at line x4 it depends upon what state T::operator=() leaves the object at v_[top_]. If an exception thrown by T::operator=() leaves the T object in a good state, then we can say that the Stack object itself is in a good state. If the assignment leaves the T object in a bad state, then we must consider the Stack object to also be in a bad state we can not expect another call to push() to work if the stack buffer now contains a bad object. (In this case, even though the stack itself is in a bad state, we can still pop() those objects that currently exist on the stack. In fact, as long as we do not try to push() an object into the slot that now contains the bad T subobject, we can still use our stack as if it were good, even though it isn't. This is often the case in real software systems.)In the final case, if an exception leaves the T object in an undefined state, then we must consider the Stack object to be in an undefined state. This means that it probably will not be possible to destroy the stack. While there is no way to tell what state the object at v_[top_] is in if an exception occurs at line 4, we want to make sure that if class T adheres to Goal I, then the stack object does also. For a slightly more complicated example consider the Stack::pop() function. In its original form: template<class T>T Stack<T>::pop(){ if (top_ == 0)
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -