4.11. Exceptions
Mainstream languages use exceptions to signal that an operation cannot complete
normally, and to transfer control from the point of failure to the code prepared to
handle it. Laurel models this directly: a procedure declares what it may throw with
throws, a throw statement raises a value, and try / catch / finally
handles it. Modelling exceptions here means each frontend does not have to
re-implement them.
Laurel imposes no root exception type, and does not require a thrown value to belong
to any particular hierarchy — a procedure may declare throws int and throw 3.
What Laurel provides instead is subtype-aware typing of the catch binding, so each
frontend uses its own hierarchy directly: Java's Throwable, Python's
BaseException, or JavaScript's convention of throwing an Error.
What a throwing procedure promises its callers — which inputs force a throw, and what may change on the way out — is a contract, and is covered under Verification - Continued.
4.11.1. Declaring and throwing
throws T in a procedure's signature says the procedure may finish by throwing a
T. In the body, throw e raises e and abandons the rest of the procedure.
composite Exception {}
composite ArithmeticException extends Exception {}
procedure div(a: int, b: int) returns (r: int)
throws (e: Exception)
opaque
{
if b == 0 then {
var ae: ArithmeticException := new ArithmeticException;
throw ae
};
r := a / b
};
throws is part of the signature — it changes what callers have to deal with — so
it sits with returns, before opaque.
4.11.2. Catch or declare
Laurel enforces catch-or-declare, the discipline Java applies to its checked
exceptions. A procedure that declares no throws may not let an exception escape,
whether thrown directly or propagated from a callee, and a procedure declaring
throws T may only let exceptions escape whose type is a subtype of T.
composite ArithError {}
composite ParseError {}
procedure wrongThrows()
throws (e: ArithError)
opaque
{
var e: ParseError := new ParseError;
throw e
// error: procedure 'wrongThrows' may throw 'ParseError', which is not a
// subtype of its declared `throws` type 'ArithError'
};
The check is about the program as you wrote it rather than about how it lowers, so
it runs during resolution and reports at the offending throw or call. Whether the
source language requires catch-or-declare is a separate question: procedures
coming from Python or JavaScript carry a throws clause too, even though neither
language has that surface construct.
4.11.3. Handling: try, catch, finally
catch dispatches on a predicate rather than on a type, written
catch e when <condition on e>. Type-based dispatch is one such predicate:
catch e when e is NotFound. Clauses are ordered and first-match-wins, and a clause
with no when guard is a catch-all. finally runs on the way out of the try.
composite Exception {}
composite NotFound extends Exception {}
composite Invalid extends Exception {}
procedure handle(fail: int) returns (r: int)
opaque
{
r := 0;
try {
if fail == 1 then {
var e: NotFound := new NotFound;
throw e
};
if fail == 2 then {
var i: Invalid := new Invalid;
throw i
}
} catch e when e is NotFound {
r := 1
} catch e {
r := 2
} finally {
assert r >= 0
}
};
finally runs after a normal completion, after a caught exception, on the way out
with an uncaught one, and when a handler itself throws or returns. A return or an
exit that leaves the try runs it too, and nested finally arms chain outward.
One rule decides the rest: if the finally arm itself completes abruptly — it
returns, throws, or exits — that completion wins, and whatever was pending is
discarded. This is Java's rule (JLS 14.20.2), so try { throw e } finally { return }
returns normally and the exception is gone.
4.11.4. The type of a catch binding
A catch binding is typed at the least common ancestor of the exception types
that can reach it: the types thrown directly in the try body, and the declared
throws types of the procedures the body calls. When those share a common ancestor
T, the binding has type T, and reading a field of e needs no downcast.
composite Exception {
var message: string
}
composite NotFound extends Exception {}
composite Invalid extends Exception {}
procedure logFailure(which: int) returns (out: string)
opaque
{
out := "";
try {
if which == 1 then {
var f: NotFound := new NotFound;
f#message := "missing";
throw f
};
var i: Invalid := new Invalid;
i#message := "invalid";
throw i
} catch e {
// `NotFound` and `Invalid` join at `Exception`, so `e` is an `Exception` and
// the inherited field is readable without a cast.
out := e#message
}
};
If the types reaching a catch share no common ancestor, Laurel reports an error
rather than leaving the binding untyped. If the body throws nothing determinable,
no exception can reach the clauses, and they are dropped as unreachable.
A frontend that needs to catch values with no useful common ancestor — unrelated types, or JavaScript's arbitrary thrown values — can box them: wrap the value in a composite field when throwing, and unwrap it in the handler.
4.11.5. Not yet supported
Two shapes are rejected during resolution with a not yet supported diagnostic rather than lowered, because the alternatives would be an internal error or a silent miscompile:
-
a call to a procedure that
throwsin a nested expression position. Only a whole statement or a whole assignment right-hand side is handled, sos := f()is fine whiles := 1 + f()is rejected; -
a
catchhandler that re-declares its own exception binding, because the substitution that rewrites the binding matches by name and is not scope-aware.