Exception By Design (from Performance Computing - November 1998)
Previously, we discussed the use of exceptions in
Java (UNIX Review,
June 1997, p. 69). Exceptions indicate that an error has occurred, and
Java supports exceptions by separating the code handling the errors from
the main-line code. Part of this mechanism is centered around the method
in which the error occurs by throwing an instance of an
Exception class.
Exception classes can be defined to reflect different kinds
of errors. The code that handles the exception is determined by the first
catch clause in the chain of calling methods that matches the
type of the Exception object thrown. For example, if we call
a method that could generate two different types of Exception
objects, our code might look something like:
try {
int retVal = someMethod(someParam);
} catch (Type1Exception e){
//code that handles Type1Exception errors
} catch (Type2Exception e){
//code that handles Type2Exception errors
}
This is about as far into exceptions as most Java programmers go. But how
we structure our Exception types is as important as how we structure
other types in a system.
All Exception classes are derived from the class
java.lang.Exception, which in turn comes from the class
java.lang.Throwable. These classes define a number of useful
methods for Exception objects, including methods that allow
the code catching an Exception object to print out a stack trace
or get an error message. The base Exception class also defines
two constructors, one of which takes no arguments and the other which takes
a String argument. If the second of these is used, the
String argument gets returned as the value of the
getMessage() method on the object, and is included in the stack
trace provided by the Exception object.
Most Java programs (at least ones that I've seen) treat
Exception classes as handy ways of labeling errors and, perhaps,
displaying said error messages. These uses are not, in themselves, a bad
thing. Any way of giving more information when something goes wrong is a
plus. But these classes can have more value than that. To see how, let's
look at a simple example.
Suppose we want to construct a class that implements a simple first-in, first-out
queue. The queue will have a fixed length determined when it is first created.
We will store only values of type int (a more general version
stores values of type Object, but that isn't the point of this
discussion). The basic code for such a FIFO would look like:
package FifoExample;
public class Fifo{
private int[] values;
private int bottom, top;
private boolean empty = true;
public Fifo(int size){
values = new int[size];
bottom = top = size - 1;
}
public Fifo(){
values = new int[10];
bottom = top = 9;
}
public int get() throws FifoEmptyException{
if (empty)
throw (new FifoEmptyException("Fifo is
empty"));
int value = values[bottom];
bottom = decrement(bottom);
empty = (bottom == top);
return value;
}
public void put(int value)throws FifoFullException{
if (!empty && (top == bottom))
throw (new FifoFullException("Fifo is
full"));
values[top] = value;
top = decrement(top);
empty = false;
return;
}
private int decrement(int value){
value-;
if (value < 0)
value = values.length - 1;
return value;
}
public static void main (String[] args){
Fifo store = new Fifo();
try {
for (int i = 0; i<store.values.length;
i++){
store.put(i);
}
for (int i = 0; i < store.values.length;
i++){
System.out.println("value " + i + " is
" + store.get());
}
for (int i = 0; i <= store.values.length;
i++)
store.put(i);
} catch (FifoFullException e){
e.printStackTrace();
System.exit(1);
} catch (FifoEmptyException e){
e.printStackTrace();
System.exit(1);
}
}
}
The Fifo class is pretty basic, consisting of two constructors
(one to specify how large the Fifo object should be, and the
other to make one of a default size), a put() method that enables
putting something in the Fifo, and a get() method
that lets us take something out.
What is of interest are the two exceptions we have used in this class. If
the Fifo object is empty, the call to get() throws
a FifoEmptyException, and if the Fifo object is
full, a call to the put() method throws a
FifoFullException.
If we define these exceptions in the usual way, they look something like:
package FifoExample;
public class FifoEmptyException extends Exception{
public FifoEmptyException(){
super();
}
public FifoEmptyException(String message){
super(message);
}
}
package FifoExample;
public class FifoFullException extends Exception{
public FifoFullException(){
super();
}
public FifoFullException(String message){
super(message);
}
}
Our FIFO exceptions are part of the FifoExample package, but
both simply extend the base java.lang.Exception. We could label
either to describe the error (although we don't), but beyond the description,
there is not much to either exception.
Using exceptions essentially as error labels is progress over the C-style approach to error handling, in which "impossible" return values are used to signal an error. The exceptions we have defined let us use the type system to help distinguish between different kinds of errors, and with the Java exception mechanism, we can separate the code used to deal with the errors from our main-line code. Both help make our code more readable and our programs more reliable. But spending a little time on designing the exception classes in our software can improve it even more.
Notice that in our test code for the Fifo class (contained in
the main() method), we handle the two kinds of exception we
have defined in the same way. When either exception is caught, we simply
print a stack trace and exit the program. If we want to treat
both exceptions this way, there is good reason to suspect that our
Fifo class users also might want to treat them the same way.
We do this by writing our catch clauses to catch the common
superclass of the two exceptions. But in the way we have declared our exceptions,
the superclass in common is Exception. Catching this would catch
every user-level exception, not just the ones relating to the
Fifo class.
A better approach is defining a common superclass between the most general
Exception class and the particular exceptions thrown in our
Fifo class. Such a class could look like:
package FifoExample;
public class FifoException extends Exception{
public FifoException(){
super();
}
public FifoException(String message){
super(message);
}
}
We then define both FifoFullException and
FifoEmptyException to extend FifoException rather
than Exception. Our test code then can be simplified (always
a good thing) to have a single catch clause that looks like
catch (FifoException e){
e.printStackTrace();
System.exit(1);
}
which works the same as the two catch clauses in our previous
example.
In general, it is a good idea to think about the relations among the exception
classes in a package as carefully as we think about the relations between
the other classes in the package. It often makes sense to have a single base
class for all the exceptions in some particular package and then to derive
more specific exceptions off that base, rather than simply extending the
language-basic class of java.lang.Exception.
But if we will treat FifoFullException objects and
FifoEmptyException objects the same, why have exception classes
beyond the basic FifoException class? We can find out what happened
by looking at the stack trace, and if all that will happen is that we print
out the trace and exit, then why not simplify the number of types by having
a single exception for both Fifo errors?
There are times when this approach is the right one. Not all kinds of errors
need to have different classes of Exception objects to flag
them. But in this case, there is good reason to have a different kind of
Exception object generated by the different errors. While our
test code might not want to distinguish between the two kinds of errors,
real code that used our Fifo class might want to do different
things in the face of an empty queue and a full queue.
In particular, when faced with a full queue, a program using our
Fifo class might want to create a new Fifo object
with larger capacity than the existing one (and then copy over the contents
of the existing queue to the new one). But to do this, the program needs
to know the size of the full queue. This is easy in our test case (because
the test code is part of the Fifo object, it can make use of
the private fields to find the length of the queue). Other users cannot get
this information, since the information is a private state in
the Fifo object.
We could add a method to the Fifo class that lets anyone get
the size of the queue. But a better alternative is to add this information
so that it is available only when needed, and allow it to be retrieved directly
from the FifoFullException object.
Exception classes, after all, are real classes. They can extend their base classes by adding extra state and method calls. So when designing an exception class, we should think not only of that class as a way to signal an error, but also as the container for the information needed to recover from the error.
To provide this information, we need to redefine the
FifoFullException to look something like:
package FifoExample;
public class FifoFullException extends FifoException{
private int size;
private FifoFullException(){
super();
}
private FifoFullException(String message){
super(message);
}
public FifoFullException(String message, int size){
super(message);
this.size = size;
}
public int getCurrentSize(){
return size;
}
}
We have added the state that permits a response to the error we get from
trying to add a value to a full queue. We have included a constructor that
allows the size of the full queue to be handed into the exception object
during creation. We also have made the other two constructors
private, ensuring that no FifoFullException object
will be created without knowing the size of the current, full queue. Finally,
we put in a method that will return the size of the queue.
A user of a Fifo object now can get the information needed to
recover from trying to put something into a full queue. The
FifoFullException object returned includes the size of the full
queue, so a new queue can be created with the best size increase for the
user. The contents of the old queue can be drained and placed in the new
one, and new values can be added to the newly created larger queue.
All this occurred because we thought about the information needed for recovering
from an error, and made sure this information was included in the exception
generated by the error. We even could have helped the client more, by including
a method in the FifoFullException that would repair everything.
How to help the user is open to a number of design decisions. What is important is thinking about what response might be taken, and making sure the information is there to help in that response.
|
Copyright © : 1997 - 2005 |