Oliver is a senior computer scientist at Adobe Systems. He can be reached at [email protected].
Exceptions are designed to relieve you of the need to check return codes or state variables after every function call to determine if an unexpected event has occurred. Used well, exceptions can reduce the number of lines of code devoted to error handling and reduce fragmentation of what remains. These accomplishments simplify programs, and simpler programs are more likely to be correct, to be completed on time, and to be maintainable.
Exceptions eliminate the need to check error codes by allowing the run-time environment to unravel the call stack until an exception handler is found. The same thing can be accomplished with judicious examination of return codes and a sufficient number of conditionals. Exceptions, however, automate the checking and unraveling for you, thus simplifying the problem and leaving only handling the exception. Exceptions do not simplify raising the error condition: Throwing an exception requires no more or no less work than setting an error code.
Error codes are opaque to compilers; they do not distinguish between a method call that succeeds and one that fails based on the domain of the return value. Exception- handling mechanisms provide to the compiler an additional level of syntactic information by identifying where exceptions can be thrown and where they can be caught. Although handling an exception remains up to you, exceptions raise a question as to whether a compiler can additionally improve error handling by making use of this new syntactic information.
Java provides a platform to examine this question as its designers chose to include two types of exceptions: Checked exceptions, that is, those that the compiler requires to be handled; and unchecked exceptions, those that the compiler does not. In Java, whether or not an exception is checked is determined by its type, and is therefore fixed at development time.
Checked-Exception Strategies
The notion that a compiler can help establish program correctness is appealing, as is any automated scheme to improve coding. (Building the tool into the compiler even guarantees it will be run.) Most likely because this seems promising, and certainly now as an established pattern of standard Java APIs, most Java exceptions are declared to be checked. This places an immediate burden on you: Where a checked exception may be thrown, the method must either handle that exception or declare that it is propagated. Propagating an exception moves the burden to the calling routine but leaves it with you. In practice, a variety of coding strategies are employed to ease this burden.
Suppress
The simplest mechanism for handling a checked exception is to suppress it. This strategy is often chosen when an exception can occur because of a method's implementation and not because of its function; see Listing One.
This also occurs in static initializers where checked exceptions are not permitted. The implementation suffers because it suppresses the exception entirely. The caller has no way to know that any further use of an instance of this class will almost certainly fail on account of the failed initialization. Worse yet, the failure may be subtle, and therefore hard to detect.
Bail Out
When suppressed exceptions occur, repercussions tend to pop up later during program execution and can be difficult to trace back to their source. This drawback can be overcome by bailing out when an unexpected exception occurs, as in Listing Two.
The "bail out" strategy may be appropriate for simple, standalone programs. Bailing out is unacceptable when the code is used as part of a library or when multiple programs are executing in a single JVM (for instance, in an application server). In such circumstances, the entire application can be brought down due to what may be, from the application's point of view, a recoverable error.
Propagate
Rather than bailing out, library clients would be better served if the original exception were propagated, allowing the client to determine how the exception should be handled. Listing Three simplifies the method implementation.
Whereas the earlier solutions removed the burden on the caller to handle IOException, every method that calls initialize() must now handle it. Generally, higher level methods must declare that they throw the union of all exceptions thrown by the underlying libraries. This is cumbersome as the sets grow in size beyond three exceptions. It is unmaintainable when new exceptions are introduced at lower levels due to the sheer number of method signatures that require change. Before long, throws clauses will account for more lines of code than will program logic.
Base Case
The exception hierarchy provides a means of shortening these long throws clauses, namely, declaring throws Exception, as in Listing Four. This catch-all phrase relieves you of any need to enumerate the exceptions being passed through as well as relieving the compilers of any ability to aid in constructing a correct program. You could argue that any mechanism intended to establish program correctness should disallow this behavior by requiring that the most specific exception type be given.
Wrap
To reduce the number of possible exceptions thrown by any library without resorting to the base-case throws clause, most Java APIs employ a wrapping scheme in which a single checked exception is possibly thrown from every public method. Should any other checked exception be thrown in the implementation of the library, it can be propagated by wrapping it in the public exception as demonstrated in Listing Five.
No matter how many different checked exceptions may be thrown in the course of executing initialize(), the caller needs to deal only with MyAPIException. Examples of this technique include RMI, with RemoteException, and JDBC, with SQLException. Thus, these checked exceptions become part of the interfaces exported by these libraries:
public class Connection {<br> ...<br> public Statement createStatement() throws SQLException;<br> ...<br> }</p>
Wrapping exceptions in a single type discards the classification inherent in the exception type hierarchy, but some information about the originating exception is preserved. JDBC uses an error code that can be examined using a switch statement, as in Listing Six. Often the originating exception itself is available from the wrapping exception and can be retrieved by recursively unwrapping (Listing Seven).
Wrapping is reasonable if the caller is known to never care about the root exception, but this is not a reasonable assumption for any library. Thus, the throws clause has been simplified at the expense of all of the raveling and unraveling code scattered everywhere.
Translate
Exceptions can also be handled by translating them back into error codes or funny return values. This is directly at odds with the rationale for an exception handling mechanism as given by the Java Language Specification, but an example appears in the java.io library. If java.io consistently threw exceptions when errors occurred, the code in Listing Eight should be commonplace.
In fact, you probably haven't seen this code, and there is no reason to handle IOException here. Although System.out.println() does call an underlying write() method that throws an IOException, it catches any IOExceptions and just sets an error flag. You can call PrintStream.checkError() periodically to determine if println() has failed. As the Java Language Specification states when arguing for exceptions, error codes are often ignored. This is why Listing Nine, which should be commonplace, also is not. The translate strategy is at best only slightly better than the suppress strategy.
Unchecked Exceptions
Although the preceding checked-exception strategies vary in details, none of them promote clean and correct exception handling. Each strategy requires additional code to suppress or to propagate checked exceptions that cannot be dealt with from the method in which they may be thrown. All of this code is written to convince the compiler that checked exceptions are being handled when in fact they are not. Simpler code could be written if IOException derived from RuntimeException and was therefore unchecked, as in Listing Ten. This code no longer contains any error-handling code whatsoever. This is as it should be because this method does not know how to deal with any errors that might arise.
Since the class has nothing to do with I/O, the caller concerns itself only with whether or not initialization succeeds. If an error occurs and the caller is prepared to handle this situation, a catch( Exception e ) suffices. Otherwise, the caller takes no action at all to cleanly propagate the original exception.
Conclusion
Checked exceptions are interesting because they purport to offer an improvement in error handling over error flags or funny return codes and a mechanism by which the compiler can help ensure program correctness. The argument that checked exceptions aid program correctness claims that requiring a calling method to handle each checked exception (even if by propagation) helps ensure that each exception is handled properly. In practice, propagation is the most common action for a calling method to take because most methods do not know how to properly handle exceptions. In requiring extra effort for the common case, checked exceptions encourage exception-handling strategies that strive to reduce the work required to propagate checked exceptions and not strategies that handle errors properly. In practice, checked exceptions are less likely than unchecked exceptions to be properly handled. The checks performed by the compiler actually have a detrimental effect.
Because Java provides both exception- handling schemes, the solution to improving error handling in Java is as simple as making all exceptions run-time exceptions. For convincing the compiler that one handles existing checked exceptions, select one of these strategies and remember that handling the exception properly is, as always, incumbent upon you.
DDJ
Listing One
// Strategy: Suppress // Initialization for a class that has nothing to do with I/O public void initialize() { try { // Load pre-computed values for this class. InputStream is = new FileInputStream( CACHE_FILE ); is.read( cache ); ... } catch( IOException ex ) { // Ignore: dont know what to do about this error, and // signature doesnt allow it to propagate. } } <H4><A NAME="l2"> Listing Two</H4> // Strategy: Bail Out // Initialization for a class that has nothing to do with I/O public void initialize() { try { // Load pre-computed values for this class. InputStream is = new FileInputStream( CACHE_FILE ); is.read( cache ); ... } catch( IOException ex ) { // After all, isnt this why printStackTrace() exists? ex.printStackTrace(); System.exit( 1 ); } }
Listing Three
// Strategy: Propagate // Initialization for a class that has nothing to do with I/O public void initialize() throws IOException { // Load pre-computed values for this class. InputStream is = new FileInputStream( CACHE_FILE ); is.read( cache ); ... }
Listing Four
// Strategy: Base Case // Initialization for a class that has nothing to do with I/O public void initialize() throws Exception { // Load pre-computed values for this class. InputStream is = new FileInputStream( CACHE_FILE ); is.read( cache ); ... }
Listing Five
// Strategy: Wrap // Initialization for a class that has nothing to do with I/O public void initialize() throws MyAPIException { // Load pre-computed values for this class. try { InputStream is = new FileInputStream( CACHE_FILE ); is.read( cache ); } catch( IOException ex ) { throw new MyAPIException( "Loading cache failed", ex ); } ... }
Listing Six
// Unwrapping by error code try { ... } catch( SQLException ex ) { // Big Ugly Switch Statements != Object Oriented Programming. switch( ex.getErrorCode()) { case 1: ... default: // Throw an exception, perhaps? } } ...
Listing Seven
// Recursively unwrapping try { ... } catch( MyAPIException ex ) { try { Throwable t = ex; while( ex != null ) { t = ex.getWrappedException(); ex = ( t instanceof MyAPIException ? (MyAPIException) t : null ); } throw t; } catch( UnderlyingException e ) { // according to the underlying exception type ... } ... }
Listing Eight
public static void main( String[] args ) { try { if( args.length < 1 ) { System.out.println( "usage: do [it]" ); System.exit( 1 ); } } catch( IOException ex ) { // System.out isnt working, so printStackTrace() isnt any good. System.exit( 1 ); } ... }
Listing Nine
public static void main( String[] args ) { ... System.out.print( "Current progress: " ); if( System.out.checkError()) { // Cant communicate with the user anymore System.exit( 1 ); } ... } }
Listing Ten
// Unchecked exceptions // Initialization for a class that has nothing to do with I/O public void initialize() { // Load pre-computed values for this class. InputStream is = new FileInputStream( CACHE_FILE ); is.read( cache ); ... }