CSE341: Exceptions (in ML)

Two approaches to error handling

Computations sometimes fail. How can a failing computation communicate its failure to its caller?

One answer is to return a union of several values --- a success case, and one or more failure cases. In C, people use the built-in option-ness of pointers (i.e., any pointer can be NULL) to signal an error:

FILE * filePointer = fopen(...);
if (filePointer == NULL) {
    ... /* handle failure to open a file */
}

In ML, we can use the built-in option type and use NONE to signal failure:

fun find pred nil = NONE
  | find pred (x::xs) =
    if pred x then SOME x else find pred xs;

case find (fn x => x > 0) someList of
    NONE   => ... (* handle error *)
  | SOME v => ... (* handle success *)

Or we can define our own datatype if the error condition is more complex than option.

However, this "return value checking" approach is unsatisfying:

Exceptions were invented as a way of factoring out error handling for "exceptional" cases, keeping them relatively separate from the "normal" code. ML was one of the earliest languages to include exceptions at the language level (if the language doesn't have exceptions, ad hoc operating system or library mechanisms can be used to achieve roughly equivalent effects). With exceptions, you might define a find function as follows:

- fun findOrRaise pred nil = raise Empty
    | findOrRaise pred (x::xs) =
      if pred x then x else findOrRaise pred xs;
val findOrRaise = fn : ('a -> bool) -> 'a list -> 'a

- findOrRaise (fn x => x > 0) [~1, ~2, ~3];
uncaught exception Empty
  raised at: stdIn:5.34-5.39

In the example above, Empty is a built-in exception constructor, and we simply allow the raised exception to propagate to top-level, where SML/NJ prints an error. However, users can define their own exceptions, and exceptions can be handled cleanly instead of simply causing the interpreter to print an error.

Notice that the call to findOrRaise does not explicitly deal with the fact that an exception might be raised. Every expression is implicitly possibly-exceptional; the programmer can choose whether or not to deal with an exception; and if the programmer chooses to deal with it, (s)he has considerable freedom in choosing where to deal with the exception. Hence, the programmer need not intermingle the normal-case code with the exceptional-case code.

ML exception types

To define an exception type in ML, we use an exception declaration, which has the form:

exception constructorName of arguments

The "of arguments" clause is optional. Note the similarity to a case in a datatype declaration. In fact, technically, what this declaration does is add an additional case to the built-in exn type:

- exception Foo of int;
exception Foo of int
- Foo;
val it = fn : int -> exn

exn is Standard ML's only built-in extensible data type --- new cases can be added to it externally from its initial declaration.

Exception values and constructors are first-class. They may be stored in data structures, bound to names, etc.

Raising and handling exceptions

Previously, we learned that (excluding infinite loops) expressions proceed until they evaluate to a value. With exceptions, expression evaluation may terminate normally --- leading to a value --- or it may terminate exceptionally --- with a raise expression, which invokes the exception handling mechanism. In the latter case, ML unwinds the stack until it reaches a handler.

raise: syntax and semantics

Syntax: Exception raising has the syntax

raise exnExpr

where exnExpr is some expression that evaluates to the exception to be raised. For example:

raise Foo(3);

val fooThree = Foo(3);
raise fooThree;

Dynamic semantics: Evaluate exnExpr to an exception value V. When an exception is raised, V propagates "outwards and upwards", to enclosing expressions and callers higher in the call stack, until either

Static semantics:

handle: syntax and semantics

handle expressions have the form

expr handle match

where expr is some expr and match is one or more pattern rules, similar to the rules of a case expression. For example:

(findOrRaise (fn x => x > 0) [~1, ~2, ~3])
    handle Empty => ~1;

Dynamic semantics: First, attempt to evaluate expr. If it evaluates normally to a value V, then return V. If it raises an exception E, then attempt to match E against each of the patterns in match successively. If any of the patterns matches E, then evaluate the body of the corresponding rule and return it. If none of the patterns matches, then raise E again.

Static semantics:

Examples

Consider the following exception and function definitions:

exception Foo of int;
exception Bar of string;

fun foo f x y =
    if f(x) > f(y) then
        raise Foo 3
    else if f(y) > f(x) then
        raise Bar "hi"
    else
        1.0;

(* Note: String.size gives the length of a string. *)
fun doFooWithHandle x y =
    (foo String.size x y)
    handle Foo i => Real.fromInt i
         | Bar s => Real.fromInt (size s);

Why do the following expressions give the results they do?

- doFooWithHandle "hello" "goodbye";
val it = 2.0 : real
- doFooWithHandle "a" "a";
val it = 1.0 : real
- doFooWithHandle "****" "**";
val it = 3.0 : real

Note that handle does not need to be in the caller of the function that raises the exception --- it may be inside the same scope...

val i = (1, 2, raise Empty)
          handle Empty => (4, 5, 6);
val i = (4,5,6) : int * int * int

...or, more usefully perhaps, arbitrarily far up the call stack:

- fun a () = raise Empty;
val a = fn : unit -> 'a
- fun b aBool = if aBool then "hi" else a();
val b = fn : bool -> string
- fun c aBool = b aBool;
val c = fn : bool -> string
- fun d () = (c true,
              (c false) handle Empty => "bye");
val d = fn : unit -> string * string
- d();
val it = ("hi","bye") : string * string

Here, the exception propagates from the raising point in the body of a, on up the call stack to the place in d where it is handled.