Advanced Methods in Programming Languages (201-2-4411-01)
Notes Week 8 - Michael Elhadad
Exceptions in C++
The mechanism of exceptions in C++ uses the following syntactic forms:
try {
...body...
} catch ( exceptionTypeSpecification1 exceptionVar1 ) {
...handler...
} catch ( exceptionTypeSpecification2 exceptionVar2 ) {
...handler...
} ...
catch ( exceptionTypeSpecificationN exceptionVarN ) {
...handler...
}
To raise an exception:
throw exceptionExpression;
There are 2 special syntactic variants used to propagate an exception and to
catch any exception value:
// Catch any exception - irrespective of type
catch (...) {
handler body;
}
// Only valid from within a handler:
// Rethrows the current exception
throw;
The syntax of the catch clause exception specification is almost the same
as the syntax used for declaring function parameters:
catch (E e) ...
catch (E& e) ...
catch (E* e) ...
catch (const E& e) ...
catch (const E *e) ...
What happens actually when throwing exceptions is different from what
happens when passing arguments. This is because when an exception is
raised, control does not return to the throwing site, and therefore,
certain post-operations that can be performed when passing an argument to
a function cannot be performed when throwing an exception. For example:
void f() {
Exception e;
g(e);
throw e;
}
void g(Exception& e) {
// do something with e
}
void h() {
try {
f();
catch (Exception& e) {
// do something with e
}
}
When invoking g(e), no copy of e is performed. e is passed by reference.
The object e is allocated on the stack inside f, a reference to e is passed
to g, which uses it, then when g returns, it returns in the context of f.
When f completes, e is de-allocated and the e destructor is invoked.
In contrast, when the exception e is raised, in the dynamic extent of h, the
catch clause of h will capture the exception e. A copy of e will still be
performed, because when the exception unwinds the stack, the local variable e
will be destroyed and de-allocated. Therefore, when the catch clause is
reached, the original e object does not exist anymore. C++ specifies that
an object thrown is always copied.
Exception Specifications
In addition to try/catch and throw, the following syntax is used in C++ to
declare that a function may only throw a certain list of exceptions:
class C {
int m(int p) throws ();
int n(int q) throws (exception, ioexception);
};
If a method is declared with a throw specifier, and the method lets another
exception exit from its scope, the C++ runtime engine will translate the
un-specified exception to a special exception called "unexpected".
There are some issues which make the use of exception specifiers not convenient
(discussed in the references). One form, however, is particularly important,
especially for documentation purposes:
class C {
~C() throws(); // nothrow warranty
};
The nothrow warranty indicates that the programmer has verified that the
function cannot ever throw any exception. It is important to insure that
destructors implement the nothrow warranty in general (the motivation
is discussed in the references).
Why Use Exceptions
Without exceptions, error conditions must be verified in many locations:
- Where the error could occur
- In any place that invokes any function in which an error may occur
The effect is that error handling becomes mixed at all levels of the computation,
in a style similar to:
const int OK = 1;
const int ERROR1 = -1;
const int ERROR2 = -2;
int f() {
int g = g();
if (g != OK) return g;
int h = h();
if (h != OK) return h;
// do something
...
return OK;
}
int g() {
if (testError) return ERROR1;
// do something
...
return OK;
}
int h() {
if (testError2) return ERROR2;
// do something
...
return OK;
}
Exception handling allows us to separate the code which deals with error,
the one that detects error, and the code that is not concerned with errors
in separate blocks.
It also eliminates the need for error constants (the int codes) and replaces
them with object-oriented constructs (exception classes).
When to Use Exceptions
Exceptions should be used in "exceptional conditions":
- They should not be raised for cases that are expected to happen on "legal" data
- They should be raised when an operation cannot find any reasonable way to deal
with a situation locally.
- They should be raised only when the try/catch block is not in the same context
as the "throw" location. (Otherwise, instead of a try/catch, use regular if
constructs to avoid the exception.)
How to Deal with Exceptions
In C++, one can deal with exceptions in 2 ways:
- Using try/catch
- Using "resource managers" -- that is, classes whose destructors "clean up" resources.
There are many advantages to using resource managers - and in general, one of the design
goals is to limit the usage of try/catch blocks which are difficult to manage.
Exception Safe Code
When writing code in the presence of objects that can throw exceptions, one must
take the following decisions:
- When invoking code that can potentially throw exceptions, should the caller
code deal with the exception or let it propagate upwards to another caller.
- If the exception is caught, the caller should "clean up" resources it has
allocated and decide whether to restart the invocation (retry), or abort
by propagating again the exception (throw) or another exception (exception
translation).
When deciding whether it is possible to retry a computation after an exception
has been thrown, the caller side must rely on commitments provided by the
called objects. Such commitments are classified as "exception safety warranties".
There exist several classifications of such warranties. The most frequently used
classification is provided in
Exception Safety in STLport,
by Dave Abrahams:
- Basic Guarantee: Even in the presence of exceptions thrown by the callee, the
caller does not leak resources. This implies that the caller object will remain
in a destructible and usable state even if an exception is thrown.
- The Strong Guarantee: If an exception is thrown, the operation has no
effects on the object. This corresponds to a "commit or rollback"
semantics for the operations on the object.
- Nothrow Guarantee: The operation will not emit an exception and any
circumstances.
An Exception-Safe Stack Class
To illustrate Exception Safety issues, we will inspect a Stack class and make
it exception safe. The Stack class is implemented as a template class because
stack operations do not depend on the semantics of the objects stored in the
stack. Exception-safety, however, does require exception-safety commitments
from the stored type. The job of the stack programmer is to minimize the
required commitments on the stored type. The discussion is derived from an
article by Cargill (1994) and by Sutter (see references).
The Stack Interface
Consider the following stack interface:
template <class T> class Stack {
public:
Stack();
~Stack();
Stack(const Stack&); // Copy constructor
Stack& operator=(const Stack&); // Assignment operator
// Accessors
size_t Size() const;
// Mutators
void Push(const T&);
T Pop(); // If empty throws exception
};
An unsafe Implementation
Consider the following implementation:
template <class T> class Stack {
public:
Stack();
~Stack();
Stack(const Stack&); // Copy constructor
Stack& operator=(const Stack&); // Assignment operator
// Accessors
size_t Size() const;
// Mutators
void Push(const T&);
T Pop(); // If empty throws exception
private:
T* v_; // Pointer to a memory area big enough for
size_t vsize_; // vsize_ T's
size_t vused_; // Number of T's actually in use
};
// Default constructor
template<class T>
Stack<T>::Stack() : v_(0), vsize_(10), vused_(0) {
v_ = new T[vsize_];
}
What happens if the new[] operator throws an exception?
The operator new[] works in 2 stages:
- It allocates enough memory for vsize_ T objects on the heap.
- It then invokes the default constructor of each T object vsize_ times.
An exception can be thrown because:
- There is not enough room in the heap. a bad_alloc exception is thrown.
- One of the vsize_ constructors throws an exception.
This constructor is exception neutral and exception safe: there is no reason
to catch the exception thrown because if it happens we do not leak resources.
This is why:
- If a bad_alloc exception is thrown by new[], no memory is allocated (this is
a guarantee provided by new[] in C++), so there is no reason to clean up.
The constructor exits and the Stack object is never constructed.
- If one of the T constructors throws an exception, any of the T objects that
were properly constructed will be properly destroyed, one by one, and finally
operator delete[] will be invoked to release the memory allocated by new[].
Note however, that we impose here the requirement that the T destructor, ~T(),
does not throw exceptions, otherwise, the clean up of the constructed T objects
might not end up well. We therefore have a requirement on T that ~T() implements
the nothrow() guarantee.
- If any exception is thrown, we are left in a consistent state: there is no object
Stack ever constructed, so we do not need to worry about leaving it in a "safe state"
because there is no state.
Note that the following version is also exception safe:
// Default constructor
template<class T>
Stack<T>::Stack() : v_(new T[10]), vsize_(10), vused_(0) {
}
But note that this is only possible because there is only one
resource acquisition expression in the initialization list, and
there is no dependency among the different initializers.
Destructor
template<class T> Stack<T>::~Stack() throws() {
delete[] v_;
}
The statement delete[] cannot throw because we imposed the requirement
that ~T() does not throw. In addition, in C++ we know that delete[] does
not throw.
Copy
The following "naive" code is not exception safe:
template <class T> Stack<T>::Stack(const Stack<T>& other) {
v_ = new T[other.vsize_]; // can throw
vsize_ = other.vsize_;
vused_ = other.vused_;
for (int i = 0; i < vused_; ++i) {
v_[i] = other.v_[i]; // can throw
}
}
If the new[] operator throws, nothing bad happens. The exception is propagated
as it was in the default constructor case.
If, however, during the loop, one of the copy assignment throws, then we have
a memory leak: the memory allocated by new[] is not released, and the T's are
not destroyed, because the delete[] operator is not applied to v_.
A similar problem arises in the copy assignment. To avoid this problem, we introduce
an auxiliary function which is responsible to manage memory allocation:
template <class T> T* NewCopy(const T* src, size_t srcsize, size_t destsize) {
assert( destsize >= srcsize);
T* dest = new T[destsize];
try {
copy(src, src+srcsize, dest);
} catch (...) {
delete[] dest; // nothrow
throw;
}
return dest;
}
NewCopy can throw, but it does not leak - it manages its resources and propagates
the exceptions (note the use of catch(...) and throw;).
Using this function we can rewrite the copy constructor in an exception safe manner:
template <class T> Stack<T>::Stack(const Stack<T>& other) :
v_( NewCopy( other.v_, other.vsize_, other.vsize_ ),
vsize_( other.vsize_ ),
vused_( other.vused_ ) {
}
Similarly, the copy assignment is:
template <class T> Stack<T>::operator=(const Stack<T>& other) {
if (this != &other) {
T* v_new = NewCopy( other.v_, other.vsize_, other.vsize_ );
delete[] v_; // no throw
v_ = v_new;
vsize_ = other.vsize_;
vused_ = other.vused_;
}
return *this; // safe - no copy
}
This illustrates a general principle of exception-safe code: do all the work on
the side in temporary variables allocated on the stack, then when all resources
are safely acquired, swap with object members and update the object state with
only nothrow operations.
Mutation Operations
The push operation must re-allocate enough memory if the stack is full:
template <class T> void Stack<T>::Push(const T& t) {
if (vused_ == vsize_) {
T* v_new = NewCopy( v_, vsize_, vsize_*2+1 ); // Can throw
delete[] v_; // no throw
v_ = v_new; // acquire new resource
vsize_ = vsize_*2+1;
}
v_[vused_] = t; // Can throw
++vused_;
}
If NewCopy throws, it takes care of its resources and does not leak, and the
stack object is not modified. So this path of execution is strongly safe.
If NewCopy completes, we acquire the new vector using nothrow operations only.
When the if block completes, the stack is in a consistent state and no resource
is outstanding.
The copy assignment operator used to copy the argument into the stack can throw.
In this case, if we required that the T operator= is weakly safe, nothing wrong
happens to the stack: vused_ is not updated, the slot vused_ remains in deletable
state and no resources are leaked (because T::operator= is weakly safe).
The pop operation is more problematic: The naive implementation is not safe:
template <class T> T Stack<T>::Pop() {
if (vused_ == 0) {
throw "Empty stack";
}
T t(v_[vused_]); // Can throw
--vused_;
return t; // Can throw
}
The problem is that because the signature of Pop() requires to return
a T element by value, the return statement can invoke a copy constructor.
In this case, the copy operation can throw. If the return throws, the
stack state has already been updated -- and the last element is not
accessible anymore. The Pop() operation does not succeed and no try/catch
operation can restore the state of the stack in a way that the popped element
become accessible again. That is, the way the signature of the Pop() operation
is provided, there is no way to make it exception safe.
The solution is to modify the signature and to introduce (as is done in STL)
two operations - one for each aspect of the work Pop() was supposed to do:
- T& top(); // Return a reference to the top element - throws if empty
- void pop(); // Make top element not accessible anymore - no return
Note that it is safe to have top() return a reference to the top element without
copying it, because by definition the object remains safely in the stack (this
was no true for the previous T Pop() operation which removed the object from
the stack and therefore could not return a reference to it). It is now the
responsibility of the programmer to ensure that this reference remains valid,
and to copy it if needed (which can be done in a safe or protected manner).
Note also that a Push() operation can make the top() reference invalid (because
it can move the objects in the v_ vector to another location). This must be
documented in the Push() and Top() interfaces.
Requirements on Contained Type T
The stack presented here is strongly exception-safe as long as T satisfies
the following requirements:
- T has a default constructor (can throw).
- T has a weakly safe copy constructor.
- T has a weakly safe assignment operator.
- ~T does not throw.
Note that Stack was made exception-safe while using a single try/catch
statement - and even that one could be removed by using an auto_ptr.
We can reduce the requirements on T if we notice that the default constructor
and assignment operator are used because we have define v_ as an array of T.
If instead we look at T as just a memory area and manage in-place object
construction, we can reduce the requirements on T.
Containers which are strongly exception-safe with minimal requirements on
the contained type T are provided in some STL implementations. The best
such implementation is available in STLPort,
an open-source implementation of STL with strong exception guarantees.
General Exception-Safety Rules
3 general exception-safety rules provided by Sutter can be summarized:
- Never allow an exception to escape from a destructor, or from an
overloaded operator delete() or operator delete[]().
- Always use the "resource acquisition is initialization" idiom to
isolate resource ownership and management.
- In each function, take all the code that might throw an exception
and do all the work safely off to the side. Only when you know
that the real work has succeeded, should you modify your program
state and clean up using nothrow operations only.
References
C++ Exceptions
-
Exception-Safe Class Design, Part 1: Copy Assignment,
H. Sutter, July 1999 (Definition of the three-level exception safety
guarantees, how to write an exception-safe copy assignment operator
using the P_impl idiom).
-
Simplify your Exception-Safe Code,
Andrei Alexandrescu and Petru Marginean,
C/C++ Users Journal, Dec 2000 (Introduces the ScopeGuard template
to inject resource clean up code without writing resource manager
classes - essential reading, extremely useful in practical programming.)
Note: The very popular ScopeGuard class has been since integrated into the Loki library.
-
ACID Programming,
H. Sutter, Sept 1999. (Comparison of Exception-safe code with
transaction management in databases.)
-
Pattern Languages for Handling C++ Resources in an Exception-Safe Way,
Harald M. Mueller, 2nd Usenix Conference on Object-Oriented Technologies (COOTS), 1996. (Classification of exception-safe techniques in C++).
-
How a C++ compiler implements exception handling,
Vishal Koshhar, Apr 2002 (This gives the "machine level" view
of exception handling in a Windows environment - very useful to
understand the cost and details of exception handling.)
-
Exception Safety and Exception Specifications: Are they Worth it?,
H. Sutter, June 2001 (Short answer: except as a documentation of the
no-throw guarantee, not many reasons to use throw specifications in C++.)
-
Constructor Failures,
H. Sutter, Jan 2000. (What happens when an exception is thrown inside
the body of a constructor, and what to do about it.)
-
Exception-Safe Function Calls,
H. Sutter, May 1999. (Make sure the order of execution of sub-expressions
is understood otherwise resource release is impossible.)
-
Uncaught Exceptions,
H. Sutter, Nov 1998. (When exceptions are not caught, what can be done
in last resort).
-
Code Complexity - Part II,
H. Sutter, Sep 1997.
Java Exceptions
-
Java Exceptions Tutorial (Sun)
-
Understanding Java Exceptions,
Chuck Allison, C/C++ Users Journal, April 2001.
Last modified Apr 27, 2003 / 02 Apr 2019
Michael Elhadad