Java's Exceptions
This is a refresher on Java's exception mechanism for my students taking Data Structures with me.
I noticed that when we speak of exceptions, students immediately think of try/catch blocks, which are only one part of the story. In fact, when designing and implementing data structures, we will be more concerned with the other part of the story: how to signal an error condition by throwing an exception. The try/catch mechanism is how a client can handle that exception, but that is not our focus when we are implementing the data structure itself. So I will start with the throwing part of the story, and then cover the catching part at the end.
Signaling an Error Condition
Suppose we are implementing a list backed by an array. The interface offers a method to get the element at an index:
public interface List<T> {
T get(int index);
// add, remove, size, ...
}
The implementation keeps the elements in an array data, with an int size counting how many are in use. Now, what should get do when the index is invalid — negative, or past the end of the list? There are a few options, and most of them are bad.
One option is to assume the caller never passes a bad index. This is the precondition approach: the method documents "the index must be in range" and then trusts the caller.
public T get(int index) {
return data[index]; // assumes index is valid
}
The problem is that the method now has no way to report a bad index. It can only avoid the question. Every caller has to check the index before calling:
if (index >= 0 && index < list.size()) {
String value = list.get(index);
} else {
// now what? return null? a default? crash?
}
This check gets repeated in every caller, written slightly differently each time, and there is no good answer for the else branch — the interface has not said what should happen when the index is invalid. And if a caller forgets the check, the bad index reaches the backing array. Sometimes that produces a cryptic failure:
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 10
at ArrayList.get(ArrayList.java:42)
That message names an internal array index, not the problem the caller cares about. Even worse, if the backing array has extra capacity, an index that is past size but still inside data.length might not throw at all; it might return an unused slot instead. The method has failed to enforce the list's abstraction.
Another option, if this were a list of integers, is to return a special value to signal failure — an error code:
public int get(int index) {
if (index < 0 || index >= size) {
return -1; // error code
}
return data[index];
}
But -1 might be a perfectly valid element the list is storing, so the caller cannot tell an error from real data. And, as before, the caller has to remember to check every return value.
What we want is a way for the method itself to check the index and, when it is invalid, signal that clearly — without overloading the return value, and without relying on the caller to remember. That is what an exception is for.
An exception is an object that represents an error condition during program execution. It is a regular Java object, with a type and a place in a class hierarchy. When a method detects a condition it cannot proceed with, it can throw an exception: the method stops executing, and Java looks for code elsewhere that knows how to handle the situation.
Throwing (or Raising) an Exception
A method throws an exception when it detects an error condition it should not handle silently. The syntax is throw, followed by a new exception object:
public T get(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException(
"Index " + index + " out of bounds for size " + size);
}
return data[index];
}
Two things to notice. First, the check now lives inside the method, written once, instead of in every caller. Second, the message names the abstraction the caller understands — "size 5", not an internal array index. A message like "Index 7 out of bounds for size 3" tells the next developer exactly what happened; a bare exception with no message tells them nothing.
Java ships with many built-in exception types, and you should reach for them before inventing your own. A few common ones:
IndexOutOfBoundsException— an index is outside the valid range.IllegalArgumentException— a method received an argument it cannot accept.IllegalStateException— the object is in a state where the operation does not make sense.NullPointerException— a null reference was used where an object was required.
For example, if our list does not allow null elements, add can reject a null argument:
public void add(T element) {
if (element == null) {
throw new IllegalArgumentException("Cannot add null element to list");
}
// ... rest of implementation
}
IllegalArgumentException is a conventional choice when the offender is a bad argument. For a null argument specifically, many Java APIs use NullPointerException; the important point is to choose the exception type that says what contract was violated.
Moving the validation into the method changes the contract. Instead of "the index must be in range" (a rule the caller has to remember), the contract becomes "if the index is out of range, you get an IndexOutOfBoundsException." The method takes on the checking, every caller gets simpler, and the error points back at the call that caused it.
Propagating an Exception
When a method throws an exception and does not catch it, the exception does not disappear and the whole program does not necessarily stop immediately. The current method stops, and the exception travels up the call stack, one method at a time, until something catches it or it reaches the top.
Consider a chain of calls:
public void processAll(List<String> names, int index) {
analyze(names, index); // (3) exception passes through here
}
public void analyze(List<String> names, int index) {
processOne(names, index); // (2) and here
}
public void processOne(List<String> names, int index) {
String name = names.get(index); // (1) exception thrown here
}
If get throws an IndexOutOfBoundsException:
processOnehas notry/catch, so the exception leaves it and propagates up.analyzehas no handler either, so it keeps going up.processAlldoes not catch it, so it goes up again.- If
maindoes not catch it, the main thread terminates and prints the stack trace.
Each method on the stack gets a chance to handle the exception, and each one here declines. Where to catch is a design decision: catch it in the method that actually knows what to do about it.
The exception object carries the information you need to make that decision. You can ask it for its message, its type, and its stack trace:
catch (IndexOutOfBoundsException e) {
e.getMessage(); // "Index 5 out of bounds for size 3"
e.getClass().getSimpleName(); // "IndexOutOfBoundsException"
e.printStackTrace(); // where it was thrown, frame by frame
}
Catching an Exception
To handle an exception, wrap the risky code in a try block and handle the failure in a catch block:
public void process(List<String> names, int index) {
try {
String name = names.get(index);
System.out.println("Processing: " + name);
} catch (IndexOutOfBoundsException e) {
System.out.println("Error: " + e.getMessage());
}
System.out.println("This still runs.");
}
Once the exception is caught, if the catch block completes normally, the method continues after the try/catch.
A single try block can throw more than one kind of exception, and you can write one catch per type:
try {
names.add(element);
names.remove(index);
} catch (IndexOutOfBoundsException e) {
System.out.println("Index problem: " + e.getMessage());
} catch (IllegalArgumentException e) {
System.out.println("Bad argument: " + e.getMessage());
}
Java runs the first catch whose type matches. This means order matters: list specific exceptions before general ones. If a general catch comes first, it would handle everything the specific catch could handle, so Java rejects the specific catch below it as unreachable code.
try {
names.get(index);
} catch (IndexOutOfBoundsException e) { // specific first
// ...
} catch (RuntimeException e) { // general last
// ...
}
Sometimes there is cleanup that must happen whether or not an exception was thrown — closing a file, releasing a lock. That is what finally is for; in ordinary control flow, it runs before control leaves the try/catch/finally statement:
FileReader reader = null;
try {
reader = new FileReader(filename);
// read the file
} catch (IOException e) {
System.out.println("Error reading file: " + e.getMessage());
} finally {
if (reader != null) {
try { reader.close(); } catch (IOException e) { /* report */ }
}
}
For resources that implement AutoCloseable, modern Java offers try-with-resources, which calls close() for you at the end of the block and replaces most uses of finally.
Two habits are worth forming. Catch specific types, not the blanket Exception, so you can respond to each failure on its own terms. And do not swallow exceptions: a catch block that does nothing hides the bug and lets the program limp along as if nothing happened. You do not have to solve the problem in the catch block, but you should at least report it.
Java's Checked vs. Unchecked Exceptions
Every exception in Java is part of a class hierarchy rooted at Throwable:
Throwable
|
┌──────────────┴──────────────┐
Error Exception
(OutOfMemoryError) |
(StackOverflowError) ┌────────────┼────────────┐
RuntimeException IOException SQLException
| | |
(NullPointerException) ... ...
(IndexOutOfBoundsException)
(IllegalArgumentException)
Error covers system-level problems we usually cannot recover from, such as running out of memory; we do not normally catch these. The exceptions we usually write and handle are under Exception, and Exception splits into two camps.
An unchecked exception, in everyday discussion, is any class that extends RuntimeException. More precisely, Java's unchecked throwables are RuntimeException, Error, and their subclasses. The compiler does not force you to catch or declare unchecked throwables; you can let them propagate without writing anything. In data-structure code, unchecked exceptions usually signal a programming error — a null you forgot to check, an index off the end. The compiler cannot detect these for you, so it stays quiet and lets them surface at runtime.
A checked exception extends Exception but not RuntimeException. The compiler tracks these: if a method can throw one, you must either catch it or declare it on the method signature with throws. Checked exceptions usually signal a problem from outside the program — a missing file, a dropped network connection, an unreachable database.
// will not compile: FileReader's constructor can throw FileNotFoundException,
// which is a kind of IOException
public void read() {
FileReader file = new FileReader("data.txt");
}
// handle it locally
public void read() {
try (FileReader file = new FileReader("data.txt")) {
// read from file
} catch (IOException e) {
System.out.println("Could not read file: " + e.getMessage());
}
}
// or declare it and let the caller deal with it
public void read() throws IOException {
try (FileReader file = new FileReader("data.txt")) {
// read from file
}
}
The choice between them usually comes down to the source of the failure. Programming errors — a bad index, a null argument, a precondition violation — point back at the code, and we use unchecked exceptions for those. Failures from the environment — file, network, database — are conditions a correct program should anticipate, and checked exceptions are often used for those. Java forces you to handle or declare checked failures and stays quiet about unchecked bugs.
For the data structures in this course, we use unchecked (runtime) exceptions. An out-of-bounds index, or a remove on an empty list, is a programming error: the client called us incorrectly. That is exactly what unchecked exceptions are for.
Custom Exceptions
The built-in exceptions work, but they are generic:
throw new IllegalArgumentException("Invalid index");
throw new IllegalStateException("List is empty");
Sometimes a domain-specific exception reads more clearly, and the name alone tells the story in a stack trace. To write one, extend RuntimeException (since these are programming errors) and, optionally, carry structured data beyond the message. These are shown together for compactness; in a real Java project, each public exception class would usually live in its own file.
public class InvalidIndexException extends RuntimeException {
private final int index;
private final int size;
public InvalidIndexException(int index, int size) {
super("Index " + index + " out of bounds for size " + size);
this.index = index;
this.size = size;
}
public int getIndex() { return index; }
public int getSize() { return size; }
}
public class EmptyListException extends RuntimeException {
public EmptyListException(String operation) {
super("Cannot perform " + operation + " on empty list");
}
}
public class NullElementException extends RuntimeException {
public NullElementException() {
super("List does not allow null elements");
}
}
The message is for humans reading a stack trace; the getIndex() and getSize() accessors let a caller inspect what happened in code. Distinct types also let a caller respond to each failure on its own terms:
try {
String name = names.get(index);
} catch (InvalidIndexException e) {
System.out.println("Bad index " + e.getIndex() + " for size " + e.getSize());
} catch (EmptyListException e) {
System.out.println("List is empty: " + e.getMessage());
}
The caller branches on the kind of failure, not on the text of a message.
Code Contracts
Consider this simple abstraction:
public class CheckingAccount {
private double balance;
// Pre: amount >= 0
// Post: balance is increased by amount
public void deposit(double amount) {
// implementation omitted
}
// Pre: amount >= 0 && amount <= balance
// Post: balance is decreased by amount
public void withdraw(double amount) {
// implementation omitted
}
// Inv: balance >= 0
public double getBalance() {
return balance;
}
}
The "Pre" and "Post" comments are called preconditions and postconditions, respectively. They specify what must be true before a method is called (preconditions) and what will be true after the method is called (postconditions). The "Inv" comment is an invariant, which specifies a condition that must always be true for the object. We use this to describe our code contract: the obligations of the client and the guarantees of the implementation.
For example, the preconditions for deposit and withdraw specify what the client must ensure before calling these methods, and the postconditions specify what the implementation guarantees after the method is called. If the client violates the preconditions (e.g., by trying to deposit a negative amount or withdraw more than the balance), then the implementation is not obligated to fulfill the postconditions.
A contract written only as comments still relies on the client to honor it. A robust method goes further: it enforces its own preconditions and signals a violation with an exception, so a broken contract fails loudly instead of silently corrupting state.
Robust Methods
A robust method does not trust the caller to have checked its preconditions; it checks them itself, and it does so before it changes any state. Validate everything first, then do the work:
public void add(int index, T element) {
// validate first
if (element == null) {
throw new NullElementException();
}
if (index < 0 || index > size) {
throw new InvalidIndexException(index, size);
}
// then perform the operation
// ... implementation
}
The order matters. If a check fails, the method throws now, before any write, so the list is never left half-modified. A method that validates partway through its work can leave the object in an inconsistent state.
The other half is the message. When you throw, report both what went wrong and what you saw. Passing the offending index and the size produces "Index 7 out of bounds for size 4," which is far more useful than "invalid index."
This is the idea behind defensive programming: the data structure protects itself rather than trusting every caller to be careful. Validation lives in one place, inside the structure; the error surfaces immediately, at the call that caused it, instead of three operations later when some other method trips over the corrupted state; and every implementation of the same interface reports the same failure in the same way. The goal is an interface that is easy to use correctly and hard to use incorrectly.