![]() | Monkeys at Keyboards: The Javanomicon © Michael James Heron | ||||
| Topic: Java Programming Level: 2 Version: delta | |||||
16 - Exception Handling | |||||
| Previous | Table of Contents | Next |
| Forum |
| Chapter Objectives |
By the end of this chapter, the reader will be able to:
|
Throughout the course of this book we've written a fairly substantial number of programs. The experience of writing a piece of code and having it execute is not new to us - in fact, by this point it is becoming little more than staid routine. Like an old married couple, we bravely suffer through the chore of intimacy, pretending to be excited and engaged when really we're simply ticking off another entry in the day's checklist of tasks to be completed. But I digress. Some of the programs we have written are so simple that it is very difficult to break them given the interface provided. Consider the first event driven applet we wrote way back in chapter four - we provided a button, and pressing that button echoed the text 'the button was just pressed' to the console. You could press that button from now until the end of time, and you'd be very unlikely to encounter an error caused by the code we wrote - the interface is just so restrictive that the user simply cannot break our program. However, most real world applications do not have an interface as restrictive as this. At some point, most applications require a level of user input that results in interactions that simply cannot be predicted. Consider how impossible it would be to write any of the following applications without allowing the user considerable leeway in the interactions they have with the interface:
In order to provide genuinely useful applications, we usually need to relax our interface restrictions. There is a problem with this - end users are usually awkward. They will enter text into a text field that was intended to contain nothing more than numbers. They will enter invalid values. They will activate components in the wrong order. They will ignore any careful warnings you put in place - in short, they'll run roughshod over any application you give them, and then blame you when it all goes wrong. Consider the bank account applet we wrote in chapter five - we provided a fairly restrictive interface in that we gave a single text field and a host of buttons. Even with this prescribed set of interactions, we could easily break the applet. If we enter a string of letters into the JTextField and then press 'set account', we'll see a long sequence of text appear in the console window. In Java parlance, an exception has occurred. We use the word thrown to describe what happens when a piece of code encounters an exception. In many of the applications and applets we have written, we may see exceptions as part of the normal course of execution. They occur frequently throughout the Java libraries. Some of these are very common. Some are very rare - some are critical, some are relatively unimportant. In all cases, they are used to denote that an exceptional circumstance has been encountered. In console based applications, an exception being thrown causes the whole application to terminate. In GUI applets and applications, this doesn't happen, but it is very likely that the internal state of variables will be have unexpected and inconsistent values. This is an unacceptable state of affairs for a program intended for someone to actually use - imagine if your spreadsheet crashed any time you entered the letter O when you meant the number 0. You'd be outraged! It is important to us as software developers that we write robust programs - it is important that our programs can deal with exceptional circumstances and provide meaningful error messages, or compensate as much as possible for badly formed input. This is the subject of this chapter.
We can make use of Java's powerful exception model to trap errors within code. We do this within what is called a try-catch block. This is a programming structure like an if statement or a for loop - it's part of the Java syntax. Exceptions occur frequently when working with Java - they are not difficult to trigger. Consider the following simple example:
This code would work perfectly well provided we store a number in the variable text - 1, 2, 1000, 2000 - none of these values would cause an exception to be thrown. However, if we were foolish enough to enter the string 'one', Java would throw an exception. Each exception has a particular name that indicates what unusual circumstance has been encountered - in this case, the exception is called NumberFormatException. The name of an exception and on what line of code it occurs is provided in the error trace displayed in the console:
The first line of the error message shows that an exception has been encountered, and that the exception is called java.lang.NumberFormatException (conventionally, we ignore the java.lang part - we'll talk about why we can do this in a later chapter when we discuss packages). The last line of this particular exception error message indicates the name of our code (ExceptionExample.java), and on what line the exception occurred (line 4). From this, we can conclude that the following line of code threw the exception:
There are other very common exceptions, such as ArrayIndexOutOfBoundsException, which is encountered when one tries to access an invalid element of an array:
Or the very common NullPointerException, which occurs when we try and call a method on an object that has not been instantiated:
This is a very common error - it is easy to forget to actually put something into the variable we have created, but we must do this before we make any method calls on it:
The name of an exception is meaningful, in that it indicates what has gone wrong. Alas, it is often not particularly helpful unless you already know the kind of things that may malfunction in the course of a Java program's lifetime. Sometimes the exception name is clear, sometimes it is deeply technical. Experience will familiarise you with many examples of both.
Understanding when an exception has occurred is only half of the battle - we must do something to ensure that it doesn't cause our applications to terminate or behave erratically. Certain pieces of code will throw exceptions - we use the try statement to indicate that a piece of code may very well go wrong. Each try statement must have a corresponding catch statement. The try block contains any code that may throw an exception. The catch block contains the code to be executed if a particular exception occurs. For the moment, we'll look at how to write a try-catch block that will deal with any exception:
Notice that our catch statement contains what looks like a parameter list, just like we'd have with a method. In fact, this is pretty much what it is - exceptions are objects, and when an exception is thrown the catch statement must explicitly provide an object reference. In this case, we can refer to the exception that was caught by the reference ex. In the example above, the parseInt call is definitely going to go wrong - in most cases, if we definitely know a piece of code is going to throw an exception then we would rewrite the code. However, consider if we were using parseInt on a piece of text we got out of a JTextField:
In this case, we wouldn't know in advance that something was going to go wrong - but the try statement doesn't go around code that will go wrong, it goes around code that may go wrong. If and when an exception occurs, execution of the code within the try block halts, and Java begins execution of the first matching catch statement (more what 'matching catch statement' means later in the chapter). Execution never returns to the try block - once all the statements in a catch block have been executed, then the thread of execution continues with the next statement after the try-catch structure. The code in a catch block is executed only if a matching exception is encountered. It is never executed otherwise:
Looking at the example above, let's consider the situation that someone enters the number 1 into the JTextField. The course of execution in this case would be to call parseInt on that input. It's a number so there is no problem. A message box then flashes up that says 'Hello', followed by one that says 'Finished'. If the user enters the text 'one' into the JTextField, then as soon as the parseInt method throws an exception, it starts executing the code within the catch block. The course of execution for this situation would be to flash up a message box that says 'A horrible error has occurred!', followed by another that says 'Finished!'.
Remember how we talked about the idea of inheritance in an earlier chapter? Inheritance is also used to build up exceptions- this means we can group exceptions together and deal with them by their general class. All exceptions somewhere along the line inherit the parent class Exception - we can use this to catch every exception in a general case:
However, we can also catch specific kinds of exceptions - our parseInt method throws a NumberFormatException, so we can catch that exception type specifically:
In this case, only a NumberFormatException will trigger the catch statement - no other kind of exception will. We can have multiple catch statements to deal with multiple kinds of exceptions - this is good practise because it means we can provide genuinely useful error messages to the user. Catching the general case Exception will ensure that your program does not crash, but you will be unable to easily indicate to the user what they did wrong based on the exception you caught. In some cases, it is simple to work this out from the context - but for more complex pieces of code, you should catch individual exceptions.
When stacking catch statements, the most general class of exception must come last. Java will look for the first matching catch statement, and execute the code that belongs to that catch statement only - it won't look for all matching catch statements. Consider if the structure above was re-ordered:
In this case, the first matching catch statement that Java sees is the general case Exception, and so it executes the code that goes with that and never gets to the NumberFormatException catch. Even though we have a structure in place for dealing with a NumberFormatException, any exception thrown by our try structure will result in the 'Something has gone wrong, but I really don't know what' error message.
The exceptions that are dealt with in a catch block are objects, but we usually have little cause to actually refer to them. They do have some useful methods that we can make use of for debugging purposes, but they should never be used in a finished application otherwise we end up with useless error messages like we discussed in chapter ten. One of the more useful methods is one called getMessage - this returns some descriptive text relating to the exception:
What we get when we run this program is the following output:
The error message in this case is the first line, which indicates what the contents of the string were when parseInt choked. There is also a method called printStackTrace of the Exception object that will dump the debugging information to the console - this is the same text that is displayed in the console when an uncaught exception is encountered:
In certain situations, it is very useful for us to be able to try and catch a piece of code, and then also deal with some general 'cleanup' code that must be executed regardless of whether an exception was encountered. Java provides us with a element to the try-catch block to allow for this, and it is called finally:
There are a number of situations where this kind of final code is useful - file IO is a particular example that we'll look at later in the book.
The exception model of dealing with errors is inherited from C++ - it has a number of benefits, the chief ones being:
We looked at how to deal with errors by group earlier when we talked about the general class Exception. There are other groups of exceptions you can choose to be interested in, but we won't discuss them in this chapter. However, this is a very powerful tool for ensuring that even if the unexpected occurs you have some relevant consistency handling code to deal with it. We'll talk about propagating errors up the call stack when we discuss the throw statement later in this chapter. All it means is that we don't have to deal with errors when we encounter them - we can pass them along to another method. Usually this is a pretty bad idea - if everyone and every method does this then no errors will be dealt with, but we'll see why it may be useful later. The real benefit as far as readability of code goes is that using exceptions allows us to separate out the error handling from the code we're actually interested in. A problem with other programming languages is that the error handling logic inevitably ends up dominating a program. Consider the following example:
When this application is executed, we get the following display:
The application is very simple - it takes the user's input for the number and tries to insert it into the myArray array at the index provided when they press the button. There are a number of places where this could go wrong. It could go wrong on parsing either of the integer values from the JTextField. It could go wrong if the index they attempt to access is greater than the size of the array. It could go wrong if they try and access a negative index. We need to add some error handling code to deal with all of these situations. To see if we're going to be able to parse an integer out of the string, we need to check and see if the first character of the string is between the Unicode values of 0 and 9:
As you can see, just to implement this check we need more code than we used to actually implement the functionality! Assuming that an integer can be parsed out of both, we then need to check and see if the index is valid - we can do this in a single check:
And we're done. Or at least, we're sort of done. Even this level of complexity doesn't safe guard us completely from exceptions being thrown. The problem is that we've now obscured our simple functionality with the code required to ensure it works. Exceptions allow us to group the error handling code segments together, and keep them away from the core code. This makes a program much easier to read.
It also allows us to ignore the trivia of the error and concentrate on what went wrong. Our check for the valid index would have to be changed any time we resized the array - fine if you're checking one index, but if you have a more complex check for a more complex data structure, then that's just asking for trouble.
If exceptions are so great, why don't we just whack a great big try and catch block around all our code? Well, that would work, although we wouldn't be able to give useful error messages. However, the big drawback with exceptions is that they are computationally expensive. This is not a problem if we are very careful with where we use them and what we catch, but if we use them as a brute force tool we will end up with slow, lethargic code. In many cases, a simple check on an index before an array access will be a much better solution than implementing a try catch block. As in many cases, it is a problem of balancing contradictory desires. In this case, it is efficiency versus maintainability and readability. Different scenarios will require a different blend of these facets. Consider the benefits to be gained from exceptions - if you feel that your application is genuinely going to be improved by them then you should use them. Worrying about the performance when designing a piece of code is a bad idea - you should wait until you actually see a need for optimisation. Adding a single check on an index access isn't likely to make your code unreadable. On the other hand, adding a complex check to ensure the validity of text input probably will. Use your common sense as to when you should use one over the other. Java is very liberal in its use of exceptions - some would say too liberal - in that you don't have a choice in a lot of cases as to whether you deal with a problem with exceptions. In such situations, you really have to just suck it up and deal with it. Life's is pain, princess. Anyone who says otherwise is selling something.
Exceptions in Java fall into two rough categories: checked exceptions, which are dealt with at compile time, and unchecked exceptions which are dealt with at runtime. The exceptions we have encountered thus far are unchecked exceptions - we encounter them when the program is running and then have to provide a strategy of some sort for dealing with them. Java also has a number of checked exceptions - if you have a piece of code that will throw a checked exception, Java won't even let you compile the code until the exception has been dealt with. One example of this is when we create a keyboard reader as discussed in chapter Swinging A Home Run.
This will work fine, but as soon as we try to read something from the keyboard:
We end up with the following error:
The readLine method throws a checked exception that we must deal with before we can even compile our program:
Checked exceptions are those that are thrown when situations outwith the programmers control are likely to be encountered. There are many reasons why input and output are one of these, particularly in relation to external file IO.
One of the reasons so many exceptions are thrown in the Java libraries concerns the issue of prescience, or the lack of it. The people who developed these libraries don't actually know what you want to be done when an exception is encountered. If you try to parse an integer our of a piece of text, they don't know if you want to just use the value 0, or whether you want execution to halt, or whether you want something else to be done. Since they have no way of knowing this, they provide code that will throw an exception and let the person who called the code worry about what to do about it, with the assumption that they have more information regarding the situation and thus more of an idea as to how to proceed. The call stack of a piece of code is the list of all the methods involved in its being called. If we have a method main which calls a method bing which calls a method bong, then our call stack is as follows:
The stack part refers to how the data is stored - just like a stack of dishes, the last method called goes onto the top of the stack. When we take things off of the stack, the last method called comes off first. This is known as a LIFO structure (last in, first out):
If this error is not dealt with in bong, then the error will propagate up the call stack to the method that called bong - in this case, bing.
If there is no method in the call stack that provides a matching catch statement, then the exception terminates the application as usual. However, we can't use this kind of implicit propagation with checked exceptions. Consider if our bong method was:
Even though there is a try and catch block available on the call stack, this will still throw up an error message when we try to compile. In such cases, we must explicitly propagate the exception up the call stack by indicating that our method throws an exception:
In this way, Java ensures the responsibility for error checking is on the developer and not on the compiler. Even if we don't do anything in our catch statement, we have taken the decision to catch the error and do nothing, and Java is satisfied.
We can write our own exceptions in Java and use them to indicate particularly problematic situations in our own projects. In most cases, this is extravagant overkill and we won't look at this in depth. However, for very complex programs, or programs that are intended to be development kits for other people, it can be very useful to provide meaningful exceptions beyond those that Java provides. Like almost anything in Java, we do this by writing a class. In the case of an exception, we should extend the Exception class (or a specialisation of it) when we do this:
The only method we need to define is getMessage which should return some meaningful text about the error that occurred:
Java won't know what to do with this by default of course - we must ensure that our code throws the exception using a throw statement:
Now any developer who makes use of our punishSouls method may find that the ScreamsOfTinySoulsException is encountered during the execution of his or her program and will need to catch provide some code to deal with it:
Extending the Exception class wll create a checked exception - it will need to be dealt with before our programs will even compile. We can also extend from RuntimeException - this creates a less serious unchecked exception that can be dealt with on an optional, need-by-need basis.
In chapter three we briefly discussed the idea of robustness and what it means to good software design. Alas, we couldn't really spend much time delving into the subject because we hadn't covered the tools we need to ensure robustness in a Java program. Robustness is an issue that has very real ramifications for real world projects. Generally the kind of case studies that are used to show why it is important are either apocryphal (such as the famous story about the rocket-launching kangaroos) or fictional (such as the excellent case of the Killer Robot ethical case study). However, there are very real examples of software going back with disasterous consequences. In 1983, a medical system called Therac-25 was put into action. It was a radiation therapy system designed to target electron or X-Ray beams at cancerous tissue. Unfortunately, the system went wrong resulting in six documented cases of patients being subjected to dangerous amounts of radiation. In three cases, the exposure resulted in the death of the patient. The lesson is - there are real ramifications to computer errors. It's not just a scare tactic employed to make you think about the code you are writing. In November of 2004, a UK governmental department was trialling the Windows XP operating system on a small number of PCs... about seven. A patch was applied to the seven systems, but a glitch in the software caused the patch to be applied to 60,000 Windows 2000 boxes. The patch caused the systems to terminate and refuse to reboot. It took four days to bring the systems back to a point where they could actually be used. There are many problems in developing software - the main one is that software cannot be exhaustively tested, and the larger the system is the more difficult it is to ensure that all systems have been tested at all. The average size of a software project doubles every two years. For comparison, Windows XP contains some 45 million lines of code. Such massive projects are extremely difficult to test. We use two metrics to define how well a program functions... we've already talked about these briefly. The first is correctness, which determines how well a piece of software meets its requirements. The second is robustness, which determines how well a program can cope with expected and unexpected input and output. Software can be correct without being robust, and it can be robust without being correct. However, good software is both robust and correct. As has been indicated above, there can be serious ramifications for failing software. Generally when talking about the impact a failure can have, we discuss three kinds of system:
Properly developing mission and safety-critical systems is a complex and specialist task that is beyond the scope of this book. Formal languages such as Z (pronounced 'zed') use mathematical notation to model software projects. The mathematical constructs can be formally proven to be correct, and then they are translated into an actual programming language implementation. Ad hoc development is not appropriate for systems that have as a consequence of failure the death of one or more people. The question of what defines a software failure is sometimes quite complex, but the simplest definition is that a software failure is one in which the worst case scenario has a possibility of occuring. Everything else is just a software problem. If the problem with Therac-25 was that it occassionally wouldn't startup, then it would not be a safety-critical failure (although one would think it would flag up some worries regarding the robustness of the systems). Such a fault would be a problem. However, applying the wrong dosage of radiation has a potentially fatal effect and so that is properly defined as a failure. Why is it that robustness is so difficult to achieve in our projects? It's partly a consequence of the nature of software development, and partly a problem with the culture surrounding software systems. Imagine if your fridge exploded, destroying all the food contained within. You'd be well within your rights to expect some kind of compensation from the manufacturer. On the other hand, if your operating system malfunctions and deletes all of your delicious data, then you'd be expected to shrug and deal with it. Such an atmosphere does not put a lot of pressure on developers to make sure their code is correct, even if the climate regarding such things is slowly changing. Software is digital - it leaps from state to state without having to traverse the intermediate states. This distinguishes it as an entity from hardware, which must traverse gracefully between different states. As such, it's difficult to handle the transition. Factor in the fact that human error is inevitable, and the complexity of software as artificial entities... with that in mind, perhaps the surprising thing is that software works at all! The current metric is that there are on average about ten errors per thousand lines of code - some of these are major, some of these are minor. Such a rate of errors may be fine when you're talking about the timer on your alarm clock or the system in your washing machine. Such is the consequence of ad hoc development... mission critical and safety critical systems simply cannot allow such a high rate of errors. Another problem is in the fact that may developers have little or no formal training in software development techniques. This is partially a consequence of how difficult it is to get the buy-in needed for people to make the effort when learning the techniques. They are often dismissed as dull, intractable and irrelevent. Worse, even those who have some experience with the techniques often don't apply them for exactly the same reasons. Exception handling provides us with the tool we need to provide robustness within our programs. What we haven't yet discussed is a mechanism that lets us identify all the problem areas in any givesn piece of software. We'll discuss this next in the chapter when we talk about testing regimes.
Exception handling provides us with a powerful and flexible architecture for dealing with problems in our Java code. Many of the Java libraries use this as the primary method of error handling, and so a Java developer must be familiar with how they work, how they may be encountered, and how they can be dealt with.. Checked exceptions are common in the more complex parts of the Java libraries, and we'll be seeing them increasingly often as we proceed through the book. Several very interesting topics of discussion have had to have been postponed until this chapter since they involve the use of checked exceptions. Exceptions come at a cost, and that cost is primarily in terms of execution time. Executions are costly to try and catch, and so the developer must make a decision as to whether to deal with runtime exceptions with consistency checking code or whether to make use of the exception architecture. This is a decision that will have a different answer for different situations.
Identify the exceptions that may occur in the following extracts of code, and devise an appropriate strategy for dealing with them:
Further ReadingThe following table details further reading on the topic in this chapter, and also any external resources that you may find useful.
|
| Previous | Table of Contents | Next |
© 2004-2006 Michael James Heron