Replace Exceptions With Defaults
December 1, 2017
There are several ways to approach this task, so our first priority is to define precisely what we want:
Write syntax
try
that takes zero or more expressions and returns the value computed by the left-most expression that is non-#f
. If all the expressions are#f
, return#f
. An expression that raises an exception is considered to have returned#f
and does not cause program execution to halt.
For instance, we can search an association-list like this:
> (define a-list '((1 . 1) (3 . 3) (5 . 5))) > (assoc 4 a-list) #f
But in many cases it’s better to return a default value:
> (define (lookup x) (try (cdr (assoc x a-list)) x)) > (lookup 4) 4
We can almost use or
for assigning a default value:
> (define (lookup x) (or (cdr (assoc x a-list)) x))
But that doesn’t work; the assoc
returns #f
if x is not found, causing cdr
to throw an exception. Some programming languages make use of something like that; here’s pseudo-perl, which I’ve always found to be a little bit ugly:
lookup 4 || die
It’s almost always better to prevent an exception from allowing an expression to fail, because the program can continue execution without halting:
> (define xs '()) > (car xs) Exception in car: () is not a pair > (try (car xs) #f) #f
Here is our version of try
:
(define-syntax try (syntax-rules (trying) ((try trying expr default) (call-with-current-continuation (lambda (return) (with-exception-handler (lambda (x) (return default)) (lambda () expr))))) ((try) #f) ((try expr) (try trying expr #f)) ((try expr0 expr1 ...) (let ((t (try trying expr0 #f))) (if t t (try expr1 ...))))))
Here, try trying
is a low-level rule that evaluates a single expression, returning either the value of the expression in the continuation or the default value if the expression throws an exception; the magic is in with-exception-handler
, which has been part of Scheme since R6RS. The other three rules, without trying
, walk through a series of expressions, returning the first that neither causes an exception nor evaluates #f
. Any of the expressions can be arbitrarily complex:
> (try (car '()) (+ 2 2) 4
By the way, try
must be a macro, not a function, because some of the expressions may not be evaluated. The beauty of Scheme’s hygienic macros is that trying
, as it appears literally in the macro above, doesn’t capture an appearance of trying
in client code; here, trying
is unbound, and throws an exception:
> trying Exception: variable trying is not bound > (try trying 3) 3
Try
provides a clean way to prevent exceptions from causing a program to halt. Liberal use of try
leads to a different mind-set when programming, because every expression successfully produces a value and you don’t have to worry about unhandled exceptions. You can run the program at https://ideone.com/ejdllr.
Below are my procedural and syntactic interpretations of the idea.
The syntactic version puts the default first, before the body of
expressions, because I thought that a bit nicer, though it is easy to
change.
@chaw: Your version fails because it evaluates the default expression even when it is not needed. Here is output from my macro, at the top, and your macro/procedure, at the bottom:
Both programs correctly evaluate to the
car
of the list. But your program also prints “hello” as a side-effect of evaluating the default expression, which it should not do.You can run the program at https://ideone.com/9ajhyO.
Here’s a solution in Python.
The default value is specified using a lambda. This provides deferred evaluation to address the issue that @programmingpraxis mentions in an earlier comment (so that the default is only evaluated when needed).
The function returns a second value that indicates whether the expression was evaluated successfully.
Output:
I disagree that this is in any way good practice. If you are passing a non-pair to car, it should fail immediately and loudly, as something is severely wrong. Changing it to #f just postpones the evil day and makes the error harder to debug.
That said, long-running programs do need to catch exceptions so as to remain long-running, and the Nulll Object pattern (which returns an object of the correct type with no contents rather than a generic nil) is perfectly good even in Lisps.
@John: I used (car ‘()) as an example. I would not handle type errors that way in practice. The text makes clear that this is a mechanism for handling some types of exceptions, not as a way to bypass real errors. For instance, if a binary tree lookup doesn’t find an expected key, this mechanism can replace the exception with some default value so the program can continue.