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.
The simplist and perhaps most common mechanism for signalling 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 maintainence
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.
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.
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.
When an exception is generated, an exception object is thrown. The object is a structure containing the following fields:
The exception error code (Int_Type).
A brief description of the error (String_Type).
The filename containing the code that generated the exception
(String_Type).
The line number where the exception was thrown
(Int_Type).
The name of the currently executing function, or NULL if at top-level
(String_Type).
A text message that may provide more information about the exception
(String_Type).
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 object 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 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.
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
VariableUnitializedError
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.