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:
option
).NULL
.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.
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.
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 semanticsSyntax: 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
handle
expression;
orStatic semantics:
raise
must have type exn
.Result type: the type raise
expressions have the type 'a
--- that is,
any type. The reason for this is that a
raise
expression must be usable at any type. For
example, consider the expression:
if p then raise Empty else "hi"
The raise Empty
expression must be unifiable
with whatever type the other branch of the if
has
--- in this case, string
. The fact that the raise
expression does not actually produce a value of type
string
(it terminates with an exceptional return
instead) doesn't matter --- we must assign some static
type to the raise
expression.
More generally, a raise
expression may appear
anywhere --- because a programmer may wish to raise an
error anywhere --- and therefore, it must have type
'a
.
handle
: syntax and semanticshandle
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:
expr
must
typecheck, and have type T. Each of the cases in
match
must have type T as well (or,
more precisely, it must be possible to unify the types of the
body expression and all of the handle rules to a type
T) --- this is because either the body expression, or
any of the handle cases, can be returned as the value of this
expression.handle
expression is T.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.