Previous Up Next

Chapter 9 Exceptions

As mentioned in section 3.4, exceptions and exception handlers are a popular mechanism for error reporting and early exit from functions and methods in programming languages such as C++, Java, Python, and OCaml. This mechanism is presented as two language constructs:

This way of handling exceptions is called structured exception handling, to distinguish it from earlier, unstructured exception handling mechanisms that resemble goto jumps, such as signal handlers in C or the ON ERROR GOTO directive in Basic.

Structured exception handling appears in the MacLisp dialect of Lisp in 1972, with the THROW, CATCH, and UNWIND-PROTECT constructs. However, it is a famous paper by Goodenough (1975) that popularized the approach, showing how it addressed several problems with earlier, unstructured mechanisms for error handling. CLU, in 1975, was the first language explicitly designed with structured exception handling, followed by LCF ML (the ancestor of SML, OCaml, and other strict functional languages) in 1978, Ada in 1980, C++ in 1990, Java in 1995, and others since.

9.1 Programming with exceptions

Reporting and handling errors.Here are some OCaml examples that illustrate the use of exceptions. The primary use is to report errors, or more generally situations in which a function cannot produce a meaningful result of its return type. Consider the quadratic equation solver again:

exception No_solution let quadratic (a: float) (b: float) (c: float) : float * float = let d = b *. b -. 4. *. a *. c in if d < 0.0 then raise No_solution; let d = sqrt d in ((-. b +. d) /. (2. *. a), (-. b -. d) /. (2. *. a))

If there are no real solutions, quadratic raises the No_solution exception. This case can be handled by catching this exception:

try let (x1, x2) = quadratic a b c in printf "Solutions: %g %g\n" x1 x2 with No_solution -> printf "No real solutions\n"

There are other ways of reporting errors. The quadratic function could return a value of an option type, (float * float) option, where None means “no solution” and Some(x1, x2) means “x1 and x2 are solutions”. More fancifully, it could take two continuations, one for the success case and one for the error case, as illustrated in section 7.4. There is no consensus that one approach is better than the other: returning option types avoids the problems with uncaught exceptions described in section 9.4, but can be more verbose and more expensive (in terms of memory allocations) than raising exceptions.

Early exits.Exceptions can implement many types of “early exit” constructs. For example, the break and continue statements of C-like languages can be replaced by local exceptions:

let exception Break in let exception Continue in try for i = lo to hi do try ... raise Break ... raise Continue ... with Continue -> () done with Break -> ()

In the body of the for loop, raise Continue aborts the current iteration and starts the next iteration, and raise Break terminates the entire loop. More generally, exceptions that are raised and handled in the same function correspond closely to the multi-level exits discussed in chapters 1 and 2.

Exceptions can also be used to prematurely exit nested calls to a recursive function. Consider computing the product of a list of integers. As soon as we find a zero in the list, we can return zero as the result. Here is one way to do this:

let list_product l = let exception Zero in let rec product = function | [] -> 1 | 0 :: _ -> raise Zero | n :: l -> n * product l in try product l with Zero -> 0

If we find a zero in the list, no integer multiplication has yet been performed: the multiplications are all pending on the return of a recursive call to product, and are skipped when the Zero exception is raised.

Returning special results.Here is an example (courtesy of G. Huet) where exceptions are used to support sharing memory between the result and the argument of a function call, instead of allocating fresh memory every time. In this approach, any function of type τ → τ (for some type τ) can raise the Unchanged exception to tell its caller that its result is equal to its argument, and therefore the argument can be reused. The following run combinator turns a function written in this style back into a pure, exception-free function:

let run fn arg = try fn arg with Unchanged -> arg

However, the Unchanged exception can be used by sharing combinators to reuse the memory allocated to represent pairs, lists, or other data structures. Consider the following pair_map function, which applies the functions f and g to the two components of a pair:

let pair_map (f: 'a -> 'a) (g: 'b -> 'b) (p: 'a * 'b) : 'a * 'b = try let x = f (fst p) in (x, run g (snd p)) with Unchanged -> let y = g (snd p) in (fst p, y)

If both f (fst p) and g (snd p) raise the Unchanged exception, pair_map raises Unchanged as well, meaning that the pair p can be reused. No new pair is allocated in this case. Otherwise, a new pair is allocated, sharing as much as possible with the original pair p: fst p is reused as first component if f raises Unchanged, and snd p is reused as second component if g raises Unchanged, thanks to the run combinator.

Here is the same technique applied to lists instead of pairs:

let rec list_map (f: 'a -> 'a) (l: 'a list) : 'a list = match l with | [] -> raise Unchanged | x :: l -> try let x' = f x in x' :: run (list_map f) l with Unchanged -> x :: list_map f l

If f raises Unchanged on every element of l, list_map raises Unchanged as well, signaling that the whole list l can be reused. Otherwise, a list is returned that reuses the longest possible suffix of the input list l. More precisely, assume that l is [x1; …; xn] and f raises Unchanged on xi, …, xn. Then, the recursive call of list_map f on the suffix l’ = [xi; …; xn] of l raises Unchanged, hence run (list_map f) l’ returns l’. Then, only i−1 new list cells are allocated to hold f x1, …, f xi−1.

The same amount of sharing can be achieved using functions with type τ → τ shared instead of exceptions, where the type shared is defined by

type 'a shared = Changed of 'a | Unchanged

However, this alternative approach causes short-lived allocations (of Changed results) that are not needed in the exception-based approach. In addition, significant rewriting is required to convert an ordinary function of type τ → τ to a sharing-preserving function of type τ → τ shared. In contrast, raise Unchanged statements can be added to an existing function incrementally.

9.2 Semantics of exceptions

Consider the FUN language from figure 5.4 extended with raise and a simple form of try:

    Expressions:  e  ::=  cx ∣ λ x . ee1 e2
         raise eraising an exception with value e
         try e1 with xe2handling an exception

Here, try e1 with xe2 catches all exceptions raised during the evaluation of e1; it binds the exception value to x and evaluates e2. It is e2’s responsibility to discriminate on x and decide whether to handle this exception or re-raise it with raise x. For example, if we use character strings as exception values, we can handle the exception named "E" and no others as follows:

try e1 with x → if x = "E" then e2 else raise x

In OCaml and SML, exception values belong to the extensible datatype exn, and each exception corresponds to a constructor of this type. Assuming FUN is extended with data constructors and pattern-matching, we can handle the exception E and no others as follows:

try e1 with x → match x with E → e2 ∣ _ → raise x

The operational semantics of try is neatly captured by the following two head-reduction rules:

     
(try v with x → e2) →εv         
(try raise v with x → e2) →εe2 [x := v]          

The first rule corresponds to an evaluation of the body of the try that raises no exception. The second rule corresponds to an evaluation that trivially raises an exception v. It must be complemented with head-reduction rules that propagate exceptions “upward”:

     
(raise ve →εraise v         
v′ (raise v) →εraise v         
if raise v then e2 else e3 →εraise v         
raise (raise v) →εraise v          

Reduction contexts must be extended to allow reductions in the body of a try and in the argument of a raise:

    Reduction contexts:  C  ::=  [ ] ∣ C ev Cif C then e1 else e2
         try C with xe2raise C

We can avoid the need for exception propagation rules by using try-free reduction subcontexts D, similar to the delimiter-free subcontexts used in section 8.6:

    Try-free reduction contexts:  D  ::=  [ ] ∣ D ev Dif D then e1 else e2
         raise D

Then, any subexpression that reduces to raise v by the exception propagation rules above can be written as D[raise v] for an appropriate try-free context D. Thus, the following two head-reduction rules are sufficient to express the semantics of try:

     
(try v with x → e2) →εv         
(try D[raise vwith x → e2) →εe2 [x := v]          

9.3 ERS and CPS transformations for exceptions

Another way to give semantics to exceptions is by translation to a pure functional language. We now show two translations, one using a sum type and the other using dual continuations.

The ERS transformation.  Instead of raising exceptions, functions can return values of the following sum type

type 'a res = V of 'a | E of exn

A function returns V v when it produces value v normally, and E x when it stops early on exception x. Here, exn stands for the type of exception values.

This exception-returning style (ERS) can be made systematic as a program transformation: the ERS transformation ℰ. The transform ℰ(e) of an expression e evaluates to a value of type res, which is either a V for normal termination or an E for abnormal termination on an exception.

     
ℰ(c) = V c         
ℰ(x) = V x         
ℰ(λ x . e) = V (λ x . ℰ(e))          
ℰ(e1 e2)= match ℰ(e1with E x → E x ∣ V f →           
     match ℰ(e2with E x → E x ∣ V v → fv         
ℰ(if e1 then e2 else e3)= match ℰ(e1with E x → E x ∣ V v →           
    if v then ℰ(e2else ℰ(e3)          
ℰ(raise e)= match ℰ(e1with E x → E x ∣ V v → E v         
ℰ(try e1 with x → e2)= match ℰ(e1with E x → ℰ(e2) ∣ V v → V v          

For all constructs except try, any E result produced by a sub-expression is immediately returned as the result of the expression, thus propagating the exception upward. For raise e, the normal value V v of e is turned into an exceptional value E v, thus materializing that an exception is raised. For try e1 with xe2, a normal value V v of e1 is returned unchanged as the value of the try, while an exceptional value E x causes e2 to be evaluated.

The double-barreled CPS transformation.  As shown by the example in section 7.4, functions written in continuation-passing style (CPS) can take two continuations corresponding to two ways of exiting the function: on success or on an error.

This technique can be generalized as a variant of the call-by-value CPS transformation from section 6.5 known as the double-barreled CPS transformation. The transform 𝒞2(e) of an expression e takes two continuations as arguments, one (written k below) that is applied to the value of e if e evaluates without raising an exception, and the other (written k′) that is called if e raises an exception. For the core language constructs, k is used as per the call-by-value CPS transformation 𝒞 of section 6.5, and k′ is propagated unchanged to sub-computations:

     
𝒞2(c) = λ k . λ k′ . k c         
𝒞2(x) = λ k . λ k′ . k x         
𝒞2(λ x . e) = λ k . λ k′ . k (λ x . 𝒞2(e))          
𝒞2(e1 e2)= λ k . λ k′ . 𝒞2(e1) (λ v1 .  𝒞2(e2) (λ v2 . v1 v2 k k′) k′) k′          
𝒞2(if e1 then e2 else e3)= λ k . λ k′ . 𝒞2(e1) (λ v1 .          
     if v1 then 𝒞2(e2k k′ else 𝒞2(e3k k′) k         

Raising an exception always invokes the second continuation k′, either with the exception value as argument, or because the expression e raised an exception:

     
𝒞2(raise e)= λ k . λ k′ .  𝒞2(ek′ k         

A trywith exception handler changes the error continuation k′ so that it invokes the specified handler:

     
𝒞2(try e1 with x → e2)= λ k . λ k′ . 𝒞2(e1k (λ x . 𝒞2(e2k k′)          

Isomorphisms between transformations.Consider an expression e of base type ι. We have the following types for its ERS transform ℰ(e), for its double-barreled CPS transform 𝒞2(e), and for the CPS transform of its ERS transform 𝒞(ℰ(e)):

     
ℰ(e) : ι + exn          
𝒞2(e) : ∀ α . (ι → α) → (exn → α) → α          
𝒞(ℰ(e)) : ∀ α . ((ι + exn) → α) → α          

We write ι + exn for the type ι res of ERS results. (See section 13.2 for a more general discussion of the effect of program transformations on types.)

The type of 𝒞2(e) is the type of the Church encoding for the type ι + exn. In this encoding, a value of type ι + exn is represented by its eliminator, which is a function that takes two functions, one to handle the ι case (type ι → α), the other to handle the exn case (type exn → α). In fact, 𝒞2(ek1 k2 behaves like

match ℰ(ewith V v → k1 v ∣ E x → k2 x

Similarly, the type (ι + exn) → α that appears as the type of the continuation in 𝒞(ℰ(e)) is isomorphic to the type (ι → α) × (exn → α). In other words, taking one continuation that expects arguments of the sum type ι + exn is equivalent to taking two continuations, one that expects ι arguments and the other that expects exn arguments. More precisely, 𝒞2(ek1 k2 behaves like

𝒞(ℰ(e)) (λ xmatch x with V v → k1 v ∣ E x → k2 x)

9.4 Checked exceptions

When programming with exceptions, a common mistake is to forget to handle an exception that the program can raise. If the program does raise this exception, it will terminate abruptly. Such uncaught exceptions are a common source of failure for programs written in OCaml, for example.

To mitigate this risk, some programming languages support checked exceptions:

The general idea behind checked exceptions is that the exceptions that a function can raise are part of its interface, just like the types of its arguments and results.

Checked exceptions in CLU.CLU, the language that popularized structured exception handling, requires exception declarations on every function, as the following example shows:

    sign = proc (x: int) returns(int) signals(zero, neg(int))
        if x < 0 then signal neg(x)
        elseif x = 0 then signal zero
        else return(x)
        end
    end sign

Any exception that escapes from a function must be declared in the signals clause of that function. Moreover, such an exception must be handled by the caller of the function: there is no automatic propagation of exceptions up the call stack until a handler is found; the caller must explicitly re-raise the exception if it wants to propagate it up.

Liskov and Snyder (1979) justify this approach to exceptions on software engineering grounds:

While it is appropriate for the caller to know about the exceptions signaled by the function (and these are part of the abstraction implemented by that function), the callee should know nothing about the exceptions signaled by functions used in the implementation of the invoked function.

The CLU approach to exceptions also supports an implementation based on multiple return points in the style of Fortran 77: for each declared exception, the caller passes an extra argument that is the code label for the handler of that exception in the caller. For example, the sign function above would take two additional arguments besides x, one label for the zero handler and one for the neg handler.

The CLU compiler does not statically check that all exceptions that can be raised by a function are declared in its signal clause. Instead, if a function raises an exception that is not declared in its signals clause, this exception is converted into a fatal error, aborting program execution. (In later versions of CLU, the exception is turned into a special, unchecked failure exception that propagates up the call stack.) As Liskov and Snyder (1979) explain, this allows programmers not to declare or handle exceptions that are “impossible by design”. They give the following example:

    if ~ stack$empty(s) then
        ...
        x := stack$pop(s)
        ...
    end

Since the code explicitly tests that the stack s is not empty, the pop operation cannot raise the “empty stack” exception. It would be counterproductive to force the programmer to declare “empty stack” as one of the exceptions that can be raised by the current function.

Checked exceptions in C++.Structured exceptions were added to C++ around 1990, using the now-familiar model of exceptions that propagate up the call stack until a handler is found. Functions and methods can optionally declare a set of exceptions that they (or the functions and methods they call) can raise:

int f(int x) throw(my_exception, some_other_exception) { ... if (x < 0) throw new my_exception(); ... }

The absence of a throw clause means that any exception can be raised.

As in CLU, the checking of exceptions is completely dynamic. There is no static verification of throw clauses. An exception that escapes a function and is not declared in the function’s throw clause is converted into a fatal error (a call to the std::unexpected library function).

int f(int x) throw(my_exception) { if (x < 0) throw new my_exception() return -x; } int g(int x) throw() { return f(x); }

This code does not produces an error at compile-time. However, executing g(-1) will trigger a call to std::unexpected.

Practical experience with C++ checked exceptions is generally negative: throw declarations do not add much to the safety of the program (aborting execution on std::unexpected is not inherently better than aborting on an uncaught exception) and add significant runtime costs (to check for exceptions that are not allowed to escape).

As a result, the 2011 revision of C++ deprecated throw clauses and introduced a simplified declaration, noexcept, to mark functions and methods that do not raise any exception. Enforcement of noexcept declarations is still dynamic, but the runtime cost is lower and optimization benefits are higher than those for full throw clauses. Finally, C++ 2017 removed throw clauses entirely, leaving only noexcept.

Checked exceptions in Java.Java, introduced in 1995, was the first mainstream programming language with statically-checked exceptions, following the “type and effect system” approach described in section 13.4.

Each method that can raise exceptions must have a throws clause that lists the classes of exceptions that can escape, whether they are raised by the method or by one of the methods it calls. (As a special case, exceptions that belong to the RuntimeException and Error classes are unchecked and do not need to be declared.) The absence of a throws clause means that no checked exception can escape the method.

The Java compiler checks that any exception raised by a method M or mentioned in the throws clauses of methods called by M is either handled in M or declared in the throws clause for M. Consider:

void writeList() throws IOException { PrintWriter out = new PrintWriter(new FileWriter(...)); ... out.close(); }

Since the FileWriter constructor and methods are declared as possibly throwing IOException in the Java standard library, the writeList method must mention IOException in its throws clause. Omitting the throws clause would result in a compile-time error. Declaring throws Exception, where Exception is a superclass of IOException, would be correct but less precise.

The only way to avoid declaring that an IOException can escape from the writeList method is to handle this exception within writeList:

void writeList() { try { PrintWriter out = new PrintWriter(new FileWriter(...)); ... out.close(); } catch (IOException e) { logger.error("I/O error: " + e.toString()); return; } }

In this case, no throws clause is necessary, and the compiler knows that no checked exception can escape writeList.

Experience with statically-checked exceptions.Java is the first mainstream language to provide static, compiler-enforced checking of exceptions. This provides useful safety guarantees: a program that compiles without a throws annotation on its main entry point will never crashes at runtime on an uncaught exception (unless it is an unchecked exception from the Error or RuntimeException classes). However, Java-style checked exceptions also raise programming and software engineering issues, as described below. Perhaps as a result, most languages introduced after Java use different approaches: C#, Scala (up to version 3), and Kotlin use unchecked exceptions; Rust and Go do not support exceptions and report errors through return values instead.

A practical problem with exception specifications is that they tend to “leak” through software abstraction layers. Consider a method m in one library A that calls method k from another library B. By default, any exception possibly raised by k must be declared in the throw clause of m. This includes standard exceptions but also exceptions declared in B, which then show up in the types of A’s method. This makes the API for A unstable: if B changes its exception behavior, or if B is later replaced by another library C, the throw clauses in the API of A will change. More pragmatically, it can also result in very long throws clauses when we combine several large libraries.

Alternatively, the method m of A can catch all exceptions that the call to method k of B can raise, and either re-raise an A exception or handle the B exceptions locally as best it can. Both approaches make the code of m larger and less clear. The second approach — sometimes called “Pokémon exception handling” because of the slogan “gotta catch ’em [exceptions] all” — often results in critical exceptions being ignored or mishandled.

Finally, statically-checked exceptions also make it difficult to define and reuse generic classes. Consider a generic array sort function:

interface MyComparator<A> { int compare(A x, A y); } interface MySort<A> { void sort(A[] arr, MyComparator<A> cmp); }

Since there are no throws declarations, the sort method cannot be used with a compare method that may raise an exception. To make sort more reusable, we need to parameterize both MyComparator and MySort interfaces over a class E of exceptions that may be raised by compare:

interface MyComparator<A, E extends Throwable> { int compare(A x, A y) throws E; } interface MySort<A, E extends Throwable> { void sort_array(A[] arr, MyComparator<A,E> cmp) throws E; }

The new version is more cumbersome to use and is not a complete replacement for the previous version: it cannot easily express the common case where compare does not throw an exception. Sections 13.5 and 13.6 describe other forms of type polymorphism with respect to exception declarations.

9.5 Exception capabilities

Tracking exceptions that can escape in function and method types is not the only way to statically ensure that all exceptions are handled and that the program cannot stop on an uncaught exception. Another way is to prohibit raising an exception if we are not in the (dynamic) scope of a handler for that exception. This alternative approach was introduced in the experimental language Effekt (Brachthäuser et al., 2020) and is now available as an option in Scala 3 (Odersky et al., 2021).

In this alternative approach, in order to raise the exception E, a function must have the capability to do so. This capability is a special, opaque value of type CanThrow[E] in Scala.

This special value is produced by try handlers. More precisely, the try e catch case E construct produces a CanThrow[E] capability and makes it available to the expression e. This capability is then propagated to the program points where exception E is raised using implicit function arguments, another advanced feature of Scala.

Exception capabilities and implicit arguments.  Consider the following Scala 3 function declaration:

def m(x : T) : U throws E

On the surface, it looks like a Java-style exception declaration “m can raise exception E”. However, the actual meaning of this declaration is “when m is called, it must be given the capability CanThrow[E], so that it can raise exception E”. In fact, the declaration above is syntactic sugar for

def m(x : T) (using CanThrow[E]): U

This says that m has an explicit argument of type T and an implicit argument of type CanThrow[E]. The function m can only be called from a context that contains a value of type CanThrow[E]. This value is then automatically passed to m. (In reality, CanThrow is an erased class, meaning that it has no representation at runtime. No value for the implicit argument is actually passed to m at runtime, but the static type checker makes sure that the implicit argument can be provided at each call site.)

Consider a larger program fragment:

def m(x : T) : U throws E = ... throw E ... def p(x : T) : V throws E = ... m(x) ... def q(x : T) : W = try p(x) catch case e : E => 0

Here, m receives a CanThrow[E] capability as an implicit argument, which it uses to raise the E exception. Without this capability, i.e. without the throws E clause, no value of type CanThrow[E] would be available in m, and the type checker would reject the throw E expression.

The function p calls m. Therefore, it needs a CanThrow[E] capability to pass to m, which it receives as an implicit argument, because of the throws E clause.

Finally, q calls p in the body of a try⁠...⁠catch case e : E construct. The try implicitly creates a value of type CanThrow[E] and makes it available during the evaluation of p(x). This value is the one that is passed to p as an implicit argument. No throws E clause is needed in the declaration of q, since the CanThrow[E] capability is created within q, not passed by the caller of q.

Interaction with higher-order functions.In the example above, the throws clauses look exactly like Java-style exception declarations; only the narrative is different, involving capabilities that are (conceptually) passed to functions instead of sets of exceptions that can escape. The novelty and interest of the capability-based approach emerges when we consider higher-order functions: functions that take other functions as arguments, which may or may not raise exceptions. Consider the higher-order function map, which applies the given function to each element of the given list:

class List[A] def map[B](f: A => B): List[B]

There is no throws clause attached to map or to its argument f. In the Java model for exception declarations, this would mean that map can only be applied to pure functions (functions that do not raise exceptions), and never raises an exception itself. In the Scala capability model, this declaration of map means that it does not needs any CanThrow capabilities, and therefore that map does not raise exceptions by itself. However, map can be applied to a function f that needs to raise exceptions, if the necessary capabilities are available at the point where map is applied:

def m(x : T) : U throws E = ... throw E ... def map_m(xs : list[T]) : list[U] throws E = xs.map(m)

In xs.map(m), m picks its implicit CanThrow[E] argument from the one passed to map_m. It can then be passed to map as a simple function of type T => U that has no implicit arguments.

The problem with escaping capabilities.  For the capability-based approach to be safe, CanThrow[E] capabilities must only be available within the dynamic scope of a handler for exception E. Otherwise, an exception E could be raised in a context where no handler is available, causing the program to stop on an uncaught exception. In the current Scala 3 implementation, CanThrow capabilities are first-class values, and it is easy to make them escape the trycatch constructs that generate these values, for example by hiding these capabilities inside functions. Consider:

def m(x : T) : Int throws E = ... throw E ... def escape(x : T) : Int = (try () => m(x) catch case e : E => 0) ()

The try block returns a function () => m(x) that captures a CanThrow[E] capability. Applying this function to () invokes m, which can raise E, aborting the program.

To address this issue and to prevent capabilities from escaping the try blocks that generate them, Brachthäuser et al. (2020) treat capabilities as second-class values, limiting the ways in which they can be used. Unfortunately, they also need to treat functions as second-class values, limiting their usefulness. Boruch-Gruszecki et al. (2023) investigate a more flexible approach where function types are enriched so as to track the capabilities that can be captured by functions.

9.6 Further reading

Whether, when, and how to use structured exception handling in programs is still an open debate. Stroustrup (2019) summarizes many of these questions in the context of C++. Monperrus et al. (2014) study how exception handling is used in a number of popular Java packages.


Previous Up Next