After an
object throws an exception, it is generally desirable that the object still be
in a well-defined, usable state, even if the failure occurred in the midst of
performing an operation. This is especially true for checked exceptions, from
which the caller is expected to recover. Generally speaking, a failed method
invocation should leave the object in the state that it was in prior to the
invocation. A method with this property is said to be failure
atomic.
There are
several ways to achieve this effect. The simplest is to design immutable
objects (Item 15). If an object is immutable, failure atomicity is free. If an
operation fails, it may prevent a new object from getting created, but it will
never leave an existing object in an inconsistent state, because the state of
each object is consistent when it is created and can’t be modified thereafter.
For methods
that operate on mutable objects, the most common way to chieve failure
atomicity is to check parameters for validity before performing the operation (Item
38). This causes any exception to get thrown before object modification
commences. For example, consider the Stack.pop
method
in Item 6:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
If the initial
size check were eliminated, the method would still throw an exception when it
attempted to pop an element from an empty stack. It would, however, leave the
size field in an inconsistent (negative) state, causing any future method
invocations on the object to fail.
Additionally, the exception thrown by the pop method would be inappropriate to the
abstraction (Item 61).
A closely related
approach to achieving failure atomicity is to order the computation so that any
part that may fail takes place before any part that modifies the object. This
approach is a natural extension of the previous one when arguments cannot be
checked without performing a part of the computation. For example, consider the
case of TreeMap, whose
elements are sorted according to some ordering. In order to add an element to a
TreeMap, the element
must be of a type that can be compared using the TreeMap’s ordering. Attempting
to add an incorrectly
typed element
will naturally fail with a ClassCastException
as
a result of searching for the element in the tree, before the tree has been
modified in any way.
A third and
far less common approach to achieving failure atomicity is to write recovery
code that
intercepts a failure that occurs in the midst of an operation and causes the
object to roll back its state to the point before the operation began. This
approach is used mainly for durable (disk-based) data structures.
A final
approach to achieving failure atomicity is to perform the operation on a
temporary copy of the object and to replace the contents of the object with the
temporary copy once the operation is complete. This approach occurs naturally when
the computation can be performed more quickly once the data has been stored in
a temporary data structure. For example, Collections.sort
dumps
its input list into an array prior to sorting to reduce the cost of accessing
elements in
the inner loop
of the sort. This is done for performance, but as an added benefit, it ensures
that the input list will be untouched if the sort fails.
As a rule, any
generated exception that is part of a method’s specification should leave the
object in the same state it was in prior to the method invocation. Where this
rule is violated, the API documentation should clearly indicate what state the
object will be left in. Unfortunately, plenty of existing API documentation fails
to live up to this ideal.
Reference: Effective Java 2nd Edition by Joshua Bloch