10. Something we can test, Error handling

Part of CS:2820 Object Oriented Software Development Notes, Spring 2021
by Douglas W. Jones
THE UNIVERSITY OF IOWA Department of Computer Science

 

Where were we?

Something Testable

The code we have reads a file describing a road network, so our initial test can be code that reads this file and then writes it out. If the program is correct, the output should resemble the input.

Resemble, not be exactly the same as. Why? Because the tools in class Scanner squeeze out extra spaces and because, depending on how the members of class LinkedList are handled, the order of roads and intersections may not be the same.

Every Java object has a toString() method that converts that object to a textual representation as a character string. The default method creates a near-useless representation, but we can easily override the default. For example, here is an appropriate toString() method for our class Road:

    public String toString() {
        return (
            source.name + " " +
            destination.name + " " +
            travelTime
        );
    }

Assuming that we write similar code for class Intersection, the main program can be modified to call writeNetwork() after it calls readNetwork(). The obvious thing to do is to make writeNetwork() be a static method of RoadNetwork, just like readNetwork().

If each class maintains a privat list of all class members, we face the problem in writeNetwork() of how to iterate over all roads. To solve this, we add a static iterator() method to each class that returns the iterator for that class. For example:

    /** Allow outsiders to iterate over all roads
     * @return textual name and travel time of the road
     */
    public static Iterator  iterator() {
        return allRoads.iterator();
    }

In the context of the above, the writeNetwork() code will look something like this:

    /** Print out the road network from the data structure
     */
    static void writeNetwork() {
        for (Iterator i = Intersection.iterator(); i.hasNext();) {
            System.out.println( "Intersection " + i.next().toString );
        }
        {
            Iterator  i = Road.iterator();
            while (i.hasNext()) System.out.println( "road " + i.next() );
        }
    }

The above code illustrates two ways of doing exactly the same thing. It is quite likely that the Java compiler generates exactly the same J-code for both of these because all forms of the Java for loop are exactly equivalet to versions that just use a while loop. Programming language designers sometimes refer to the for loop as "syntactic sugar" because it does not really change the expressiveness of the language, it just provides an alternative way to write something that the language can already do without it.

Also notice that when + is used for string concatenation in Java, it will automatically look for the toString() method of its right-hand operand when that operand is not already of class String.

If the list of all roads was a public component of class Road we could have written this:

        For (Road r: Road.allRoads) {
            System.out.println( "road " + r );
        }

This notation is even more compact, but it is simply syntactic sugar for this while loop:

        {
            Iterator  i = Road.allRoads.iterator();
            while (i.hasNext()) {
                road r = i.next();
                System.out.println( "road " + r.toString() );
            }
        }

The disadvantage of this is that it exposes the list to outsiders. This means that code outside class Road is no able to know that we used a linked list of raods for the collection, and the code is able to perform arbitrary deletions or reorderings to the list. This violates the general rule that no software component should have access to more information than it needs to do its job.

As a general rule, the for-loop construct in Java is always syntactic sugar. Every Java for loop is shorthand for an equivalent while loop. Consider this elementary for loop that iterates over integers:

for(int i=0 ; i < 10 ; i++) {
    doSomethingWith( i );
}

This can be rewritten as follows, and in fact, the Java compiler would generate exactly the same code from the above as it generates from this long-winded rewrite:

{
    int i = 0;
    while (i < 10) {
        doSomethingWith( i );
        i++;
    }
}

Again, we wrapped the while loop in an extra set of braces in order to make the loop control variable i visible only inside the loop and not elsewhere in the program.

Errors

There are some obvious ways to deal with errors! We could throw an exception, for example, but Java demands that you write handlers for exceptions, so this doesn't get us anywhere.

An easy alternative is to call System.exit( 1 ). This terminates the program rather violently. By convention, the integer parameter to exit() should be zero if the program is terminating normally, while it should be nonzero in case of abnormal termination. The exit is violent, in the sense that all kinds of things are abandoned -- finalization on objects is ignored, for example. The convention that the parameter should be zero for success and nonzero for failure is a standard inherited from Unix and Linux; it only really matters when programs are called from elaborate shell scripts or Perl scripts.

Of course, before calling exit(), we should output an error message! Consider the following rewrite of the start of the main method:

    public static void main(String[] args) {
        // open the input file from args[0]
        if (args.length < 1) {
            System.err.println( "Missing filename argument" );
            System.exit( 1 );
        } else if (args.length > 1) {
            System.err.println( "Unexpected extra arguments" );
            System.exit( 1 );
        } else ...

First note, instead of outputting error messages to System.out, the standard output stream, we have output the error messages to System.err. All processes on systems descended from UNIX have two default output streams, one for normal output, and the other for error messages. Linux and Windows both support this idea. System documentation usually refers to these streams as stdout and stderr, but in Java, they are called System.out and System.err.

This approach to dealing with errors leads us to rather verbose code, and it locks us in to using output streams and not, for example, popup notices or other error reporting mechanisms by distributing error reporting code all over the program. What if we want to add a GUI interface to this application? If we proceed as suggested above, we'll have to inspect the entire program to find all places where error messages are output and modify all of them to user our new GUI. With a GUI, we probably don't want to just output a message and then immediately kill the applicaton. If we did that, all the open windows attached to the application would close with a bang, including wherever the error message appeared. Instead, we want to open a popup window and have it hang there until the user has read it. We certainly don't want to duplicate all the code for that everywhere an error message is output.

What we need to do to avoid both of these problems is provide a centralized error handler, for example, something like this:

/** Error reporting framework
 */
class Errors {
    static void fatal( String: message ) {
        System.err.println( message );
        System.exit( 1 );
    }
}

Later, if someone wants to convert our program to use a GUI, this one method could be responsible for opening a popup window to report the error and then wait for the user acknowledgement. Later, we might also add non-fatal error dialogues, and this class is a natural place to put them. Our main method would now begin like this:

    public static void main(String[] args) {
        if (args.length < 1) {
            Errors.fatal( "Missing filename argument" );
        } else if (args.length > 1) {
            Errors.fatal( "Unexpected extra arguments" );
        } else try {
            readNetwork( new Scanner( new File( args[0] ) ) );
            writeNetwork( new Scanner( new File( args[0] ) ) );
        } catch (FileNotFoundException e) {
            Errors.fatal( "Can't open file '" + args[0] + "'" );
        }
    }

When it comes time to report less serious errors, we could add a warning() method to class Errors that works much the same as the fatal() method except that it does not exit.

We can call this, for example, in readNetwork():

    private static void readNetwork( Scanner sc ) {
        while (sc.hasNext()) {
            // until the input file is finished
            String command = sc.next();
            if (command == "intersection") {
                inters.add( new Intersection( sc, inters ) );
            } else if (command == "road") {
                roads.add( new Road( sc, inters ) );
            } else {
                // Bug: Should probably support comments
                Errors.warning(
                    "'"
                    + command
                    + "' not a road or intersection"
                );
                // Bug: Should skip to the next line
            }
        }
    }

What we have done here is build a new layer on top of the virtual machine hierarchy supporting our program. This layer sits on top of the Java library and below our main program. It extends our programming environment by adding a uniform error handling mechanism. As we further develop our program, we will add other supporting mechanisms to this virtual machine.

Static methods

In Java, all subroutines are described as methods (the term subroutine being the most generic of all names for chunks of executable code). Java methods come in three flavors, regular methods, static methods and constructors.

Regular methods — true methods — must be called relative to an object. For example, System.out is the name of an object, an output stream, and println() is a method of the stream class. The call System.out.println() calls the println() method in the context of the object System.out. True methods cannot be called except in the context of a particular object.

Static methods such as Errors.fatal() do not have an object as their context. The only variables they can operate on are their parameters and static variables -- that is, variables that are not part of an object. Prior to the invention of object-oriented programming, the term method was not used. The term subroutine comes from FORTRAN in the 1950s. The term procedure comes from Algol. In both languages, a function is a kind of procedure or subroutine that returns a value. All of these can be translated to Java as static functions.

Java constructors are a special case. To a large extent, they are analogous to static methods except that they also operate on newly allocated object that they implicitly return, without the need for a return statement.