Next Previous Contents

15. Error Handling

All non-trivial programs or scripts must be deal with the possibility of run-time errors. In fact, one sign of a seasoned programmer is that such a person pays particular attention to error handling. This chapter presents some techniques for handling errors using S-Lang. First the traditional method of using return values to indicate errors will be discussed. Then attention will turn to S-Lang's more powerful exception handling mechanisms.

15.1 Traditional Error Handling

The simplist and perhaps most common mechanism for signaling a failure or error in a function is for the function to return an error code, e.g.,

    define write_to_file (file, str)
    {
       variable fp = fopen (file, "w");
       if (fp == NULL)
         return -1;
       if (-1 == fputs (str, fp))
         return -1;
       if (-1 == fclose (fp))
         return -1;
       return 0;
    }
Here, the write_to_file function returns 0 if successful, or -1 upon failure. It is up to the calling routine to check the return value of write_to_file and act accordingly. For instance:
     if (-1 == write_to_file ("/tmp/foo", "bar"))
       {
          () = fprintf (stderr, "Write failed\n");
          exit (1);
       }

The main advantage of this technique is in its simplicity. The weakness in this approach is that the return value must be checked for every function that returns information in this way. A more subtle problem is that even minor changes to large programs can become unwieldy. To illustrate the latter aspect, consider the following function which is supposed to be so simple that it cannot fail:

     define simple_function ()
     {
         do_something_simple ();
         more_simple_stuff ();
     }
Since the functions called by simple_function are not supposed to fail, simple_function itself cannot fail and there is no return value for its callers to check:
     define simple ()
     {
         simple_function ();
         another_simple_function ();
     }
Now suppose that the function do_something_simple is changed in some way that could cause it to fail from time to time. Such a change could be the result of a bug-fix or some feature enhancement. In the traditional error handling approach, the function would need to be modified to return an error code. That error code would have to be checked by the calling routine simple_function and as a result, it can now fail and must return an error code. The obvious effect is that a tiny change in one function can be felt up the entire call chain. While making the appropriate changes for a small program can be a trivial task, for a large program this could be a major undertaking opening the possibility of introducing additional errors along the way. In a nutshell, this is a code maintenance issue. For this reason, a veteran programmer using this approach to error handling will consider such possibilities from the outset and allow for error codes the first time regardless of whether the functions can fail or not, e.g.,
     define simple_function ()
     {
         if (-1 == do_something_simple ())
           return -1;
         if (-1 == more_simple_stuff ())
           return -1;
         return 0;
     }
     define simple ()
     {
         if (-1 == simple_function ())
           return -1;
         if (-1 == another_simple_function ())
           return -1;
         return 0;
     }

Although latter code containing explicit checks for failure is more robust and more easily maintainable than the former, it is also less readable. Moreover, since return values are now checked the code will execute somewhat slower than the code that lacks such checks. There is also no clean separation of the error handling code from the other code. This can make it difficult to maintain if the error handling semantics of a function change. The next section discusses another approach to error handling that tries to address these issues.

15.2 Error Handling through Exceptions

This section describes S-Lang's exception model. The idea is that when a function encounters an error, instead of returning an error code, it simply gives up and throws an exception. This idea will be fleshed out in what follows.

Introduction to Exceptions

Consider the write_to_file function used in the previous section but adapted to throw an exception:

    define write_to_file (file, str)
    {
       variable fp = fopen (file, "w");
       if (fp == NULL)
         throw OpenError;
       if (-1 == fputs (str, fp))
         throw WriteError;
       if (-1 == fclose (fp))
         throw WriteError;
    }
Here the throw statement has been used to generate the appropriate exception, which in this case is either an OpenError exception or a WriteError exception. Since the function now returns nothing (no error code), it may be called as
     write_to_file ("/tmp/foo", "bar");
     next_statement;
As long as the write_to_file function encounters no errors, control passes from write_to_file to next_statement.

Now consider what happens when the function encounters an error. For concreteness assume that the fopen function failed causing write_to_file to throw the OpenError exception. The write_to_file function will stop execution after executing the throw statement and return to its caller. Since no provision has been made to handle the exception, next_statement will not execute and control will pass to the previous caller on the call stack. This process will continue until the exception is either handled or until control reaches the top-level at which point the interpreter will terminate. This process is known as unwinding of the call stack.

An simple exception handler may be created through the use of a try-catch statement, such as

     try
      {
        write_to_file ("/tmp/foo", "bar");
      }
     catch OpenError:
      {
         message ("*** Warning: failed to open /tmp/foo.");
      }
     next_statement;
The above code works as follows: First the statement (or statements) inside the try-block are executed. As long as no exception occurs, once they have executed, control will pass on to next_statement, skipping the catch statement(s).

If an exception occurs while executing the statements in the try-block, any remaining statements in the block will be skipped and control will pass to the ``catch'' portion of the exception handler. This may consist of one or more catch statements and an optional finally statement. Each catch statement specifies a list of exceptions it will handle as well as the code that is to be excecuted when a matching exception is caught. If a matching catch statement is found for the exception, the exception will be cleared and the code associated with the catch statement will get executed. Control will then pass to next_statement (or first to the code in an optional finally block).

Catch-statements are tested against the exception in the order that they appear. Once a matching catch statement is found, the search will terminate. If no matching catch-statement is found, an optional finally block will be processed, and the call-stack will continue to unwind until either a matching exception handler is found or the interpreter terminates.

In the above example, an exception handler was established for the OpenError exception. The error handling code for this exception will cause a warning message to be displayed. Execution will resume at next_statement.

Now suppose that write_to_file successfully opened the file, but that for some reason, e.g., a full disk, the actual write operation failed. In such a case, write_to_file will throw a WriteError exception passing control to the caller. The file will remain on the disk but not fully written. An exception handler can be added for WriteError that removes the file:

     try
      {
        write_to_file ("/tmp/foo", "bar");
      }
     catch OpenError:
      {
         message ("*** Warning: failed to open /tmp/foo.");
      }
     catch WriteError:
      {
         () = remove ("/tmp/foo");
         message ("*** Warning: failed to write to /tmp/foo");
      }
     next_statement;
Here the exception handler for WriteError uses the remove intrinsic function to delete the file and then issues a warning message. Note that the remove intrinsic uses the traditional error handling mechanism--- in the above example its return status has been discarded.

Above it was assumed that failure to write to the file was not critical allowing a warning message to suffice upon failure. Now suppose that it is important for the file to be written but that it is still desirable for the file to be removed upon failure. In this scenario, next_statement should not get executed upon failure. This can be achieved as follows:

     try
      {
        write_to_file ("/tmp/foo", "bar");
      }
     catch WriteError:
      {
         () = remove ("/tmp/foo");
         throw WriteError;
      }
     next_statement;
Here the exception handler for WriteError removes the file and then re-throws the exception.

Obtaining information about the exception

When an exception is generated, an exception object is thrown. The object is a structure containing the following fields:

error

The exception error code (Int_Type).

descr

A brief description of the error (String_Type).

file

The filename containing the code that generated the exception (String_Type).

line

The line number where the exception was thrown (Int_Type).

function

The name of the currently executing function, or NULL if at top-level (String_Type).

message

A text message that may provide more information about the exception (String_Type).

object

A user-defined object.

If it is desired to have information about the exception, then an alternative form of the try statement must be used:

     try (e)
     {
        % try-block code
     }
     catch SomeException: { code ... }
If an exception occurs while executing the code in the try-block, then the variable e will be assigned the value of the exception object. As a simple example, suppose that the file foo.sl consists of:
     define invert_x (x)
     {
        if (x == 0)
          throw DivideByZeroError;
        return 1/x;
     }
and that the code is called using
     try (e)
     {
        y = invert_x (0);
     }
     catch DivideByZeroError:
     {
        vmessage ("Caught %s, generated by %s:%d\n",
                  e.descr, e.file, e.line);
        vmessage ("message: %s\nobject: %S\n",
                  e.message, e.object);
        y = 0;
     }
When this code is executed, it will generate the message:
     Caught Divide by Zero, generated by foo.sl:5
     message: Divide by Zero
     object: NULL
In this case, the value of the message field was assigned a default value. The reason that the object field is NULL is that no object was specified when the exception was generated. In order to throw an object, a more complex form of throw statement must be used:
throw exception-name [, message [, object ] ]
where the square brackets indicate optional parameters

To illustrate this form, suppose that invert_x is modified to accept an array object:

    private define invert_x(x)
    {
       variable i = where (x == 0);
       if (length (i))
         throw DivideByZeroError,
               "Array contains elements that are zero", i;
       return 1/x;
    }
In this case, the message field of the exception will contain the string "Array contains elements that are zero" and the object field will be set to the indices of the zero elements.

The finally block

The full form of the try-catch statement obeys the following syntax:

try [(opt-e)] { try-block-statements } catch Exception-List-1: { catch-block-1-statements } . . catch Exception-List-N: { catch-block-N-statements } [ finally { finally-block-statements } ]
Here an exception-list is simply a list of exceptions such as:
    catch OSError, RunTimeError:
The last clause of a try-statement is the finally-block, which is optional and is introduced using the finally keyword. If the try-statement contains no catch-clauses, then it must specify a finally-clause, otherwise a syntax error will result.

If the finally-clause is present, then its corresponding statements will be executed regardless of whether an exception occurs. If an exception occurs while executing the statements in the try-block, then the finally-block will execute after the code in any of the catch-blocks. The finally-clause is useful for freeing any resources (file handles, etc) allocated by the try-block regardless of whether an exception has occurred.

Creating new exceptions: the Exception Hierarchy

The following table gives the class hierarchy for the built-in exceptions.

   AnyError
      OSError
         MallocError
         ImportError
      ParseError
         SyntaxError
         DuplicateDefinitionError
         UndefinedNameError
      RunTimeError
         InvalidParmError
         TypeMismatchError
         UserBreakError
         StackError
            StackOverflowError
            StackUnderflowError
         ReadOnlyError
         VariableUninitializedError
         NumArgsError
         IndexError
         UsageError
         ApplicationError
         InternalError
         NotImplementedError
         LimitExceededError
         MathError
            DivideByZeroError
            ArithOverflowError
            ArithUnderflowError
            DomainError
         IOError
            WriteError
            ReadError
            OpenError
         DataError
         UnicodeError
         InvalidUTF8Error
         UnknownError

The above table shows that the root class of all exceptions is AnyError. This means that a catch block for AnyError will catch any exception. The OSError, ParseError, and RunTimeError exceptions are subclasses of the AnyError class. Subclasses of OSError include MallocError, and ImportError. Hence a handler for the OSError exception will catch MallocError but not ParseError since the latter is not a subclass of OSError.

The user may extend this tree with new exceptions using the new_exception function. This function takes three arguments:

new_exception (exception-name, baseclass, description);
The exception-name is the name of the exception, baseclass represents the node in the exception hierarchy where it is to be placed, and description is a string that provides a brief description of the exception.

For example, suppose that you are writing some code that processes numbers stored in a binary format. In particular, assume that the format specifies that data be stored in a specific byte-order, e.g., in big-endian form. Then it might be useful to extend the DataError exception with EndianError. This is easily accomplished via

   new_exception ("EndianError", DataError, "Invalid byte-ordering");
This will create a new exception object called EndianError subclassed on DataError, and code that catches the DataError exception will additionally catch the EndianError exception.


Next Previous Contents