Exceptions and Error Handling

TADS 3 provides a "structured exception handling" mechanism, which lets the program to signal and handle unusual conditions in a structured manner. TADS 3 exceptions work very much like Java exceptions, so if you've used Java you'll find the TADS 3 exception mechanism familiar.

Conceptually, an exception is any unusual condition. Exceptions usually indicate errors, but they don't have to; they can also be used for a variety of other purposes, such as terminating a procedure early or recovering from a resource shortage. An exception is represented by an object; you can tell what kind of exception you have by looking at the class of the exception object. Exception objects are the same as any other objects, so you can create as many different types of exception classes as you need.

Exception handling has two components: "throwing" and "catching."

When something unusual occurs in your program that you wish to handle using the exception mechanism, you "throw an exception." This means that you create a new object to describe the unusual condition or error, then use the throw statement to throw the exception. The throw statement is a little like return or goto, in that it abruptly transfers execution somewhere else; hence any statement immediately following a throw is unreachable (unless, of course, it can be reached by a label or some other means that doesn't involve going through the throw statement).

Where does throw send execution? This is where "catching" comes in. When you throw an exception, the VM looks for an enclosing block of code protected within a try block. This search is done according to the "call chain" - the series of function and method calls that have been made so far to reach the current point in the code. The VM looks for the nearest enclosing try statement in the call chain - this might be in the current method, actually enclosing the code with the throw, or it might be in one of the callers. The VM searches outward from the current method.

When the VM finds the first enclosing try statement, it looks at the statement's catch clauses. If there's a catch for a superclass of the thrown exception, the VM transfers control to the code within that catch clause's handler block; otherwise, the VM skips that try and continues searching for the next enclosing try.

For each try statement that encloses the current code but doesn't define a catch block for the thrown exception, the VM checks to see if the try has an associated finally block, and executes the enclosed code before looking for an enclosing try block.

If the VM searches the entire call stack without finding any enclosing try block with a catch for the thrown exception, the VM terminates the program. If it has to do this, the VM checks the exceptionMessage property of the unhandled exception object, and displays the value of that property if it's a (single-quoted) string value. This at least lets the user see a message describing the error that forced the program to terminate.

A try statement looks like this:

try
{
  // some code that might throw an exception, or call
  // a function or method that might do so
}
catch (FirstExceptionClass exc1)
{
  // handle FirstExceptionClass exceptions
  // exc1 is a local with the thrown exception object
}
catch (SecondExceptionClass exc2)
{
  // handle SecondExceptionClass exceptions
  // exc2 is a local with the thrown exception object
}
finally
{
  // do some cleanup work – this gets called
  // whether an exception occurs or not
}

A try statement can have as many catch clauses as needed – it can even have no catch clauses at all. The finally clause is optional, but only one is allowed if it's present at all, and it must follow all of the catch clauses.

Each catch clause has a name following the name of the exception. The catch defines a new local variable with the given name – the variable is local to the code within the catch clause. When the exception is caught, the VM will store a reference to the thrown exception object in this variable; this is, of course, the same object that was used in the throw statement that threw the exception in the first place.

The VM searches for a catch clause that matches the exception class starting with the first catch associated with the try, and considers each catch in turn until it finds a match. A catch matches if the named class is a superclass of the exception behing handled. Because the catch clauses are tried in order, you can have one handler for a specific type of exception, and also have a later handler for a superclass of the first exception; the specific type will be handled by the first handler, since the VM will find that handler earlier than the more general handler. In such cases, only the first matching handler will be invoked.

If a finally clause is present, the VM will always execute the code contained within, no matter how control leaves the try block. If control leaves via an exception that isn't handled by any of the try statement's catch clauses, the VM will execute the finally code before it continues the search for the next enclosing try. If no exceptions occur, so control leaves the try block normally, the finally code is executed immediately after the last statement in the main try block. If an exception is thrown but one of the try statement's catch clauses catches the exception, the VM executes the finally code immediately after the last statement in the catch block (or, if control is transfered out of the catch block in some other way - goto, return, throw, etc. - the finally is executed just before that control transfer).

Note that the code in a finally clause will execute no matter how execution leaves the try block. This even includes goto, return, break, and continue statements. If the try block contains a return statement, the program will first calculate the value of the expression being returned (if any), then it will execute the finally code, and only then will control transfer back to the caller of the current function or method. (It's important that the return value is calculated first, because it counts as code that's protected by the try. If an exception is thrown while calculating that value, it'll be handled the same as any other exception thrown inside the try.) If you use goto, break, or continue within the try block to jump to a statement that's outside the try block, the program will execute the finally code just before jumping to the target statement.

Here's an example that illustrates how all of this works.

#include "tads.h"

class ResourceError: Exception;
class ParsingError: Exception;

main(args)
{
  a();
}

a()
{
  b(1);
  b(2);
  b(3);
}

b(x)
{
  "This is b(<<x>>)\n";
  try
  {
    c(x);
  }
  catch (Exception exc)
  {
    "b: Caught an exception: <<exc.exceptionMessage>>\n";
  }
  "Done with b(<<x>>)\n";
}

c(x)
{
  "This is c(<<x>>)\n";
  try
  {
    d(x);
  }
  catch(ParsingError perr)
  {
    "c: Caught a parsing error: <<perr.exceptionMessage>>\n";
  }
  finally
  {
    "In c's finally clause\n";
  }
  "Done with c(<<x>>)\n";
}

d(x)
{
  "This is d(<<x>>)\n";
  e(x);
  "Done with d(<<x>>)\n";
}

e(x)
{
  "This is e(<<x>>)\n";
  if (x == 1)
  {
    "Throwing resource error...\n";
    throw new ResourceError('some resource error');
  }
  else if (x == 2)
  {
    "Throwing parsing error...\n";
    throw new ParsingError('some parsing error');
  }
  "Done with e(<<x>>)\n";
}

When this program is run, it will show the following output:

This is b(1)
This is c(1)
This is d(1)
This is e(1)
Throwing resource error...
In c's finally clause
b:  Caught an exception:  some resource error
Done with b(1)
This is b(2)
This is c(2)
This is d(2)
This is e(2)
Throwing parsing error...
c:  Caught a parsing error:  some parsing error
In c's finally clause
Done with c(2)
Done with b(2)
This is b(3)
This is c(3)
This is d(3)
This is e(3)
Done with e(3)
Done with d(3)
In c's finally clause
Done with c(3)
Done with b(3)

This illustrates several aspects of exceptions.

First, note that function d() doesn't have any exception handlers (i.e., it has no try block). Since this function is not concerned with catching any exceptions that occur within itself or functions it calls, it doesn't need any exception handlers. This is one of the advantages of exceptions over using return codes to indicate errors: intermediate routines that don't care about exceptions don't need to include any code to check for them. When searching for a try block, the VM simply skips function d() if it's in the call chain, since it has no handlers.

Second, note that function c() only handles ParsingError exceptions. Since this function has no handlers for any other exception types, the VM skips past this function when trying to find a handler for the ResourceError exception. So, not only can a function ignore exceptions entirely, but it can selectively include handlers only for the specific exceptions it wants to handle, and ignore anything else.

Third, note that, once an exception is caught, it no longer disrupts the program's control flow. In other words, an exception isn't "re-thrown" after it's caught, unless you explicitly throw it again with another throw statement in the handler that caught it.

Handling VM Run-Time Errors

When your program does something illegal, such as trying to multiple two strings together or extracting an element from a list at an invalid index, a run-time error occurs. TADS 3 treats all run-time errors as ordinary exceptions; this means that you can handle run-time errors using the same try/catch mechanism that you use to handle your own exceptions.

The system library defines the RuntimeError class, which serves as the base class for all VM run-time errors.

When a VM run-time error occurs, the VM create and throws a new RuntimeError instance. The errno_ property of this object is set to the VM error number describing the error that occurred, and the exceptionMessage property is set to the VM error text for the error. You can inspect these properties directly, but if you just want to display the error message, you should call the displayException() method of the error object.