In this post, I will outline how to create your own exception types in GNU Guile and how to handle exceptions and errors in general. This can be a difficult thing to learn using the Guile manual, which provides the building blocks for the system and leaves it up to the reader to bring it together.
This post was updated to include continuable exceptions.
History
Guile has two different exception systems, with the new
with-exception-handler
and raise-exception
based system
appearing in the year 2019. This new system allows for compound error
types that can contain a lot of useful information. The old exception
system is based on catch
, with-throw-handler
, and
throw
. An exception in the old system is a symbol, along with
objects, typically for printing a helpful error message.
Both systems are compatible with each other, so this post will outline the both systems and provide examples on how to handle both of them using either style.
What do exceptions look like? How do I throw / raise them?
Let's throw an exception using the old system and see what it looks like.
(throw 'read-error)
/guile-code.scm:5:0: Throw to key `read-error' with args `()'.
Let's add some arguments.
(throw 'read-error "ran into trouble while reading the file")
/guile-code.scm:5:0: Throw to key `read-error' with args `("ran into trouble while reading the file")'.
As you can see, old style errors can be determined by the phrase "Throw to key ...".
Note that Guile also provides the (error ...)
procedure, which
throws an old style exception with the key 'misc-error
.
What do new style exceptions look like when raised? I will explain how
to create these types further into the blog post. For now, just know
that a &read-exception
contains a read-reason
and a
read-severity
.
(raise-exception
(make-read-exception 'full 'high))
/guile-code.scm:16:0: ERROR:
1. &read-exception:
read-reason: full
read-severity: high
The new exception system also allows for compound exceptions, which are exceptions composed of multiple exceptions. This is what they look like.
(raise-exception
(make-exception (make-read-exception 'full 'high)
(make-exception-with-message "the filesystem is full")))
/guile-code.scm:16:0: ERROR:
1. &read-exception:
read-reason: full
read-severity: high
2. &message: "the filesystem is full"
Note that sometimes, you are not provided information about which
exception type or key was used for an exception. This usually occurs
for old style exceptions with the key 'system-error
, where
Guile instead formats the error message for the user. We can replicate
this by opening a file that does not exist.
(call-with-input-file "does-not-exist.txt" (lambda (port) #f))
ERROR: In procedure open-file:
In procedure open-file: No such file or directory: "does-not-exist.txt"
So now, you should be able to tell what exception system (new or old) an exception is using.
When in doubt, you can always catch the exception and print it. Let's do that for the above exception.
Note that we use the guard macro to keep Guile's output clean. This will be explained further down the blog post.
(use-modules (ice-9 exceptions))
(guard (ex (else (display ex) (newline)))
(call-with-input-file "does-not-exist.txt" (lambda (port) #f)))
#<&compound-exception components: (#<&external-error> #<&origin origin: "open-file"> #<&message message: "~A: ~S"> #<&irritants irritants: ("No such file or directory" "does-not-exist.txt")> #<&exception-with-kind-and-args kind: system-error args: ("open-file" "~A: ~S" ("No such file or directory" "does-not-exist.txt") (2))>)>
Let's break this down. This is a compound exception, i.e. an exception
that contains other exceptions. It contains the
&external-error
, &origin
, &message
,
&irritants
, and the &exception-with-kind-and-args
exceptions. The fact that it is an
&exception-with-kind-and-args
lets us know that this is an old
style exception, with the key 'system-error
.
If that looks complicated, don't worry, you won't have deal with this sort of output often. You should now know what exceptions look like, and how to categorize them into "new" and "old".
Creating exception types
You saw earlier that I created an instance of a
&read-exception
, which is not built in to Guile. How did I do
it?
Guile provides a helpful macro for this purpose, called
define-exception-type
. Let's look at how to use it.
(use-modules (ice-9 exceptions)) ;; for the macro and the root exception &exception
; name of type ; parent ; constructor name ; predicate to check type
(define-exception-type &read-exception &exception make-read-exception read-exception?
; (field-name field-accessor) ...
(read-reason read-exception-reason)
(read-severity read-exception-severity))
We can now run the code provided earlier and see the exception.
Why this complexity? This exception type allows for inheritance, so you can have a rich error hierarchy for your application if you need it. The predicates and accessors for the parent exception would work for the child exception. Additionally, you can now associate structured data with your exception, which can be accessed using the accessors defined by the macro.
Handling exceptions
Old-style
Let's say we only want to handle old-style exceptions. We want to
catch system-error
's and let all other exceptions pass. For
this, we can use the catch
procedure, which allows us to
specify the error key. To catch errors with any key, use #t
for
the key. Note that all new style exceptions have the key
'%exception
.
The following code will read from the file if it exists, but
otherwise returns #f
in the case of a system-error.
(define contents
(catch 'system-error
(lambda ()
(call-with-input-file "does-not-exist.txt"
(lambda (port) (read port))))
(lambda (key . args)
;; exception handler, called when an exception is thrown.
#f)))
You can always re-throw the same exception (using (apply throw key args)
)
or throw a new exception in the exception handler.
New-style
Let's say that read-something is a procedure that can sometimes fail.
We want contents to be #f
when a &read-exception
is thrown.
Otherwise, we want it to be the return value of
(read-something)
. In case another exception occurs, we want it
to be thrown. This can be accomplished this way:
(use-modules (ice-9 exceptions))
(define (read-something)
(raise-exception (make-read-exception 'damage 'low)))
(define contents
(guard (ex
((read-exception? ex) #f))
(read-something)))
In the guard form, ex
is bound to thrown exception and the
following forms follow the same pattern as a (cond (conditional-expr expr) ...)
.
When a conditional evaluates to true, expr
becomes the return
value of the expression. If you'd like to handle all exceptions, you
can add an else clause in the guard form, for e.g. (guard (ex
(else #f)) body ...)
.
If you want to handle old-style exceptions using this form you can use
the exception-kind
and the exception-args
procedures.
For e.g.
(guard (ex
((eq? (exception-kind ex) 'system-error) #f))
(call-with-input-file "does-not-exist.txt"
(lambda (port) (read port))))
Intermediate: Continuable exceptions
In Guile Scheme, you can raise an exception at a place in the code
that can be resumed by the exception handler. This is called a
continuable exception, raised using raise-continuable
.
One potential use case for this is when the code finds a invalid value. Instead of aborting the function and having to run the function or program again, continuable exceptions allow the program to ask the user if they'd like to provide a new value instead, and allowing the program to resume where it left off with the new valid value.
We can demonstrate this with an example. In this scenario, the code is
supposed to write a file of a certain size, 1028, by default. However,
we want to make sure that the file is not larger that the amount of
space in the file system. To do this, we need a function to determine
the amount of free space in the filesystem (disk-space-amount)
and a function to check whether a size is within that
(disk-space-left? query)
. For the purposes of this example,
let's hard code the amount of space left.
(define (disk-space-amount)
1000)
(define (disk-space-left? query)
(< query (disk-space-amount)))
Now that we have these functions, we can implement the logic of the
program. To raise a continuable exception, we need to use
(raise-continuable ex)
. What this does is find the relevant
exception handler, and runs it with the exception. If the exception
handler returns, that value becomes the return value of
(raise-continuable ex)
exception, resuming the
computation. The exception handler can choose to throw the exception
again, letting other exception handlers a chance to handle the
exception.
To set the exception handler for a block of code, we need to use
(with-exception-handler handler thunk)
. Whenever the code in
thunk
throws an exception, handler
is called with the
exception.
Combining all of these together, here is the program.
(with-exception-handler
(lambda (ex)
(cond
((and (read-exception? ex)
(eq? (read-exception-reason ex) 'almost-full))
(format #t "the disk is almost full, only has ~a left.\n"
(disk-space-amount))
(format #t "please provide a different file size: ")
(let ((new-file-size (read)))
(if (disk-space-left? new-file-size)
new-file-size
(raise-exception ex))))
(else (raise-exception ex))))
(lambda ()
(let ((file-size (if (disk-space-left? 1028)
1028
(raise-continuable
(make-read-exception 'almost-full 'medium)))))
(format #t "writing ~a\n" file-size))))
Let's walk through this program step by step. Let's look at the code in the thunk.
(let ((file-size (if (disk-space-left? 1028)
1028
(raise-continuable
(make-read-exception 'almost-full 'medium)))))
(format #t "writing ~a\n" file-size))
If the file size is within the bounds of the filesystem, return the
file size. If it is not, throw a continuable exception, which is a
&read-exception
, with a read-reason
of
'almost-full
. If the exception is continued, it will return the
new file size provided by the handler
.
Now let's look at the code in the handler.
(lambda (ex)
(cond
((and (read-exception? ex)
(eq? (read-exception-reason ex) 'almost-full))
(format #t "the disk is almost full, only has ~a left.\n"
(disk-space-amount))
(format #t "please provide a different file size: ")
(let ((new-file-size (read)))
(if (disk-space-left? new-file-size)
new-file-size
(raise-exception ex))))
(else (raise-exception ex))))
If the exception is not a &read-exception
with a read-reason of
'almost-full
, the handler re-raises the exception. Otherwise,
it informs the user that the disk is almost full, and asks the user to
provide a new file size using (read)
. If the new-file-size
is
within the filesystem limit, then it returns the new-file-size
,
otherwise, it reraises the exception.
The way the code is setup, it always asks the user for a new file
size. Play with the return value of disk-space-amount
to gain
an intuition for what's happening.
Summary
You should now be able to throw / raise exceptions using both the old
and new systems and be able to handle both types of exceptions using
either catch
and guard
forms. You should also be able to
use continuable exceptions to your advantage.
Feel free to send feedback to this post via email or IRC (if I'm around). I intend this to be a resource for beginners, and would like to improve clarity anywhere possible.
For additional resources, read the following Guile manual pages.