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 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.
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 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
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.