ERRORS & CONDITIONS
In the next chapter we'll cover macros–Common Lisp's most famous and powerful feature. But Lisp's condition system is competitive for the next more powerful feature, and it's the feature we'll cover in this chapter.
Signaling Conditions
In Lisp, instead of "throwing" or "returning" an error, you "signal a
condition". The most common way to signal some condition is to use error or
cerror.
(defun oops (x)
(when (zerop x)
(error "What the h?")))
That opens the debugger:
(oops 0)
What the h?
[Condition of type SIMPLE-ERROR]
Restarts:
0: [*ABORT] Return to SLY's top level.
1: [ABORT] abort thread (#<THREAD tid=12411 "slynk-worker" RUNNING {7007185183}>)
Notice that the condition is a SIMPLE-ERROR.
cerror will create a continuable error.
(defun oops (x)
(when (zerop x)
(cerror "Want to continue?" "You passed ~d" x))
(print "You continued."))
Calling it:
(oops 0)
You passed 0
[Condition of type SIMPLE-ERROR]
Restarts:
0: [CONTINUE] Want to continue?
1: [*ABORT] Return to SLY's top level.
2: [ABORT] abort thread (#<THREAD tid=12483 "slynk-worker" RUNNING {700776ADA3}>)
Continuing will continue after the point of the error.
If you only want to make a warning, you can use warn.
(progn
(warn "You are about to be Rick Rolled.")
(format t "Never Gonna Give You Up")
(error "You've been deserted.")
(format t "I hate meta."))
If you execute this, it will print the warning and the first format in the
REPL and then open a debugger with the error message. You won't see the second
format message.
With assert, you can check a condition. If it returns t, the program
continues. If the condition returns nil, it will signal a continuable error
and then you can assign a value to the selected symbols and retry the assert.
(defun using-assertion (x)
(print "before assert")
(assert (> x 0)
(x)
"~d Needs to be a positive number." x)
(print "after assert")
(print x))
Calling it:
(using-assertion -4)
-4 Needs to be a positive number.
[Condition of type SIMPLE-ERROR]
Restarts:
0: [CONTINUE] Retry assertion with new value for X.
1: [*ABORT] Return to SLY's top level.
2: [ABORT] abort thread (#<THREAD tid=12435 "slynk-worker" RUNNING {7007A4FF33}>)
If you choose CONTINUE, then you'll be prompted for a new value.
Defining Conditions
If you need or want to provide more control over error handling, you can define your own conditions.
(define-condition naughty-word (error)
()
(:documentation "A condition for when naughty words are detected."))
Conditions are objects using the CLOS.
(make-condition 'naughty-word)
#<NAUGHTY-WORD {7009850503}>
Let's make a list of naughty words to detect and a function to detect them.
(defparameter *naughty-words* '(rust java haskell ruby python crypto bitcoin)
"Words that must never be uttered.")
(defun naughty-word-p (word)
"Check if a word is in *naughty-words*."
(member word *naughty-words*))
(defun process-word (word)
"Signal a naughty-word condition if a word is naughty."
(when (naughty-word-p word)
(error 'naughty-word))
word)
(defun process-sentence (sentence)
"Look through all the words in a sentence and do something if any of them are
naughty."
(loop :for word :in sentence
:collect (process-word word)))
(process-sentence '(java has the best oop system among all programming languages))
When you run the above code, the debugger will pop up with an error:
Condition CL-USER::NAUGHTY-WORD was signalled.
[Condition of type NAUGHTY-WORD]
Restarts:
0: [*ABORT] Return to SLY's top level.
1: [ABORT] abort thread (#<THREAD tid=12475 "slynk-worker" RUNNING {70069EF533}>)We can improve our error messages by modifying the condition definition.
(define-condition naughty-word (error)
((bad-word :initarg :bad-word
:initform nil
:reader bad-word))
(:documentation "A condition for when naughty words are detected.")
(:report (lambda (c stream)
(let ((word (bad-word c)))
(format stream "Naughty word detected: ~a" word)))))
Notice that after the slots (we only have one: bad-word) come the options,
each passed as plists.
Then we update the call to error:
(defun process-word (word)
"Signal a naughty-word condition if a word is naughty."
(when (naughty-word-p word)
(error 'naughty-word :bad-word word))
word)
(process-sentence '(java has the best oop system among all programming languages))
Run process-sentence again and your debugger will display the following
message:
Naughty word detected: JAVA
[Condition of type NAUGHTY-WORD]
Restarts:
0: [RETRY] Retry SLY evaluation request.
1: [*ABORT] Return to SLY's top level.
2: [ABORT] abort thread (#<THREAD tid=21471 "slynk-worker" RUNNING {700CB7C5F3}>)
Backtrace:
0: (PROCESS-WORD JAVA)
1: (PROCESS-SENTENCE (JAVA HAS THE BEST OOP SYSTEM ...))Restarts
If you define a condition, it's usually because you want to provide the ability
to recover from some error. One recovery mechanism you can provide are
restarts. We've seen restarts many times inside the debugger: there are three
restarts in the above debugger message.
To provide more restarts, use restart-case.
(defun replace-word ()
"Replace a word in a list."
(intern (string-upcase (read-line))))
(defun process-word (word)
"Check a word. If it's naughty, give the user the choice to replace the word."
(if (naughty-word-p word)
(restart-case
(error 'naughty-word :bad-word word)
(continue ()
:report "Sticks and stones. Just leave the word as it is."
word)
(censor ()
:report "Replace the naughty word with one you like more."
(replace-word)))
word))
(process-sentence '(java has the best oop system among all programming languages))
Naughty word detected: Java
[Condition of type NAUGHTY-WORD]
Restarts:
0: [CONTINUE] Sticks and stones. Just leave the word as it is.
1: [CENSOR] Replace the naughty word with one you like more.
2: [RETRY] Retry SLY evaluation request.
3: [*ABORT] Return to SLY's top level.
4: [ABORT] abort thread (#<THREAD tid=21479 "slynk-worker" RUNNING {700CC9EDD3}>)
Now when you run process-sentence, you can replace any of the words found in
the *naughty-words* list using a restart.
Handling Conditions
Another recovery mechanism is via "handling". When the naughty-word condition
is signaled, you can prevent the program from stopping and the debugger from
opening by using handler-case.
(defun process-sentence (sentence)
"Look through all the words in a sentence and do something if any of them are
naughty."
(loop :for word :in sentence
:collect (handler-case (process-word word)
(naughty-word () '***))))
(process-sentence '(java has the best oop system among all programming languages))
(*** HAS THE BEST OOP SYSTEM AMONG ALL PROGRAMMING LANGUAGES)
Now, instead of bringing up the debugger, if a naughty word is encountered during the iteration, it will simply be replaced by "*".
Preserving Your Stack
Being able to signal a condition, and then choose how to respond based on the condition is very nice, but Lisp has one more trick up its sleeve.
handler-case has a problem: it unwinds the stack. That means that you lose
local state at the point where you decide to do something with a naughty-word
condition. In the simple example above, it's difficult to understand what that
looks like. To illustrate the problem with handler-case, we'll consider a
different scenario: working with a stream of data.
Let's say we have a simple key=value style configuration system and a file like this:
environment=production
debug=false
api_key+oh-dear
db=sqlite
db_username=admin
db_password:pass
some other broken stuff
Save that to a file called "my.config". In another file, add the rest of this
code and make sure to set the directory in the REPL using ,set-directory
(sly-mrepl-shortcut) in the REPL to the directory holding the config file and
the rest of this code.
We see a few errors. While parsing this file, we'd like to signal a condition and recover, report which lines are broken, what the broken lines look like, etc. and continue reading the rest of the file.
Let's begin by parsing the file:
(defun parse-line (line)
"Parse a 'key=value' line into a cons pair."
(let ((pos (position #\= line)))
(when pos
(cons (subseq line 0 pos)
(subseq line (1+ pos))))))
(defun read-config (stream)
"Read config lines from a stream, collecting key=value pairs."
(loop :for line = (read-line stream nil nil)
:for line-number :from 1
:while line
:for parsed = (parse-line line)
:if parsed
:collect parsed
:else
:do (restart-case
(error 'malformed-line
:line line
:line-number line-number)
(skip-line ()
:report "Skip this line and continue."))))
We use loop to read from each line and to count line-numbers up from 1. If we
hit a line we can't parse, we signal a malformed-line condition, providing the user
the skip-line restart in the debugger.
The malformed-line condition looks like this:
(define-condition malformed-line (error)
((line :initarg :line
:reader malformed-line-text)
(line-number :initarg :line-number
:reader malformed-line-number))
(:report (lambda (c stream)
(format stream "Malformed line ~d: ~a"
(malformed-line-number c)
(malformed-line-text c)))))
When this condition is signaled, it will tell us both the line number and content of the broken line.
Let's try it out as is:
(with-open-file (stream #P"my.config" :direction :input
:element-type 'character)
(read-config stream))
Malformed line 3: api_key+oh-dear
[Condition of type MALFORMED-LINE]
Restarts:
0: [SKIP-LINE] Skip this line and continue.
1: [RETRY] Retry SLY evaluation request.
2: [*ABORT] Return to SLY's top level.
3: [ABORT] abort thread (#<THREAD tid=14415 "slynk-worker" RUNNING {70054C5033}>)
All is well: that is the broken line and its contents. If you choose to skip the line, it will continue parsing and give us the next broken line, and so on, and then finally the not-broken parts of the configuration:
(("environment" . "production") ("debug" . "false") ("db" . "sqlite") ("db_username" . "admin"))
(defun load-config-case (path)
(with-open-file (stream path)
(handler-case (read-config stream)
(malformed-line (c)
(format t "Gave up at line ~d.~%" (malformed-line-number c))
nil))))
(load-config-case #P"my.config")
Gave up at line 3. => NIL
You don't have access to the skip-line restart, since that's established in
the read-config loop. You've broken out of the loop and can't get back in. The
stack has unwound.
That's where handler-bind and invoke-restart come to the rescue. Unlike
handler-case, handler-bind can catch that malformed-line condition and
then invoke the skip-line restart.
(defun load-config-bind (path)
(with-open-file (stream path)
(handler-bind
((malformed-line
(lambda (c)
(format t "Skipping line ~d.~%" (malformed-line-number c))
(invoke-restart 'skip-line))))
(read-config stream))))
(load-config-bind #P"my.config")
Skipping line 3.
Skipping line 6.
Skipping line 7.
=> (("environment" . "production") ("debug" . "false") ("db" . "sqlite")
("db_username" . "admin"))
invoke-restart transfers control to a named restart established with restart-case, without unwinding past it.
In this case, the debugger never popped up. Instead of manually choosing the
restart for the broken lines, we used invoke-restart to skip them for us.
What if we try to call invoke-restart inside the handler-case version?
(defun load-config-case (path)
(with-open-file (stream path)
(handler-case (read-config stream)
(malformed-line (c)
(format t "Gave up at line ~d.~%" (malformed-line-number c))
(invoke-restart 'skip-line)))))
(load-config-case #P"my.config")
No restart SKIP-LINE is active.
[Condition of type SB-INT:SIMPLE-CONTROL-ERROR]
Restarts:
0: [RETRY] Retry SLY evaluation request.
1: [*ABORT] Return to SLY's top level.
2: [ABORT] abort thread (#<THREAD tid=14387 "slynk-worker" RUNNING {7007B59BA3}>)
Won't work. The stack has been unwound, so we no longer have access to the
skip-line restart. And you need restarts to be able to return to the loop
iteration. If you call a normal function inside the handler-bind, the
condition won't actually be handled. Instead, it will be "propagated" to the
next outer call to handler-bind that also handles the same condition.
invoke-restart is how you stop the propagation of the condition, handle the
condition, and restart the process at the point where the condition was
signaled.
Safely Unwinding
You often want a way to run "clean up" after a process completes, erroneously or not. For example, if you successfully write to a file or database, you want your stream or connection to the file or database closed. And of course, if you do encounter an error, you don't want to leave your data in a malformed state.
unwind-protect is how you run clean up actions like closing connections. We'll
use it later in the Account Library chapter to close database connections for us.
(defmacro with-db-connection (&optional (db-name "sqlite.db") &body body)
`(let ((mito:*connection* (dbi:connect-cached :sqlite3 :database-name ,db-name)))
(unwind-protect (progn ,@body)
(dbi:disconnect mito:*connection*))))
In the example above, the body will hold whatever forms you want to run with an
open DB connection. The first argument to unwind-protect is the form you want to
run. The second argument is your cleanup operation that you always want to run
after the previous form returns.
Typically, you don't write unwind-protect directly; as explained in the section
on Streams in the LISP FUNDAMENTALS chapter, with-open-file uses unwind-protect
internally to close the open file connection once your operation is complete.
Many macros using the with-* naming convention use unwind-protect internally.
Ignoring Errors
There are times when you don't want errors to open a debugger, you just want to have information about the kind of error.
ignore-errors is used for doing just that.
(ignore-errors
(error "This error will not open a debugger."))
NIL, #<SIMPLE-ERROR "This error will not open a debugger." {7005DA3AD3}>
If a form in ignore-errors returns an error, ignore-errors itself will return
two values: NIL and the condition object with the error. Otherwise, it simply
returns the return value of the last form in ignore-errors.
Built-in Conditions
There are a number of built-in conditions that you can signal in your own code.
Examples include style-warning, type-error, arithmetic-error, etc. You can
find a complete list in the Hyperspec (https://cl-community-spec.github.io/pages/Condition-Types.html).
Built-in Restarts
In addition to being able to invoke your own custom restarts, you can invoke any of the built-in restarts:
continuestore-valueuse-valuemuffle-warningabort
While playing around with code in this book you've probably seen most of these
restarts already. muffle-warning is useful for times when you have some warn
call that is filling your REPL with noise that you want to suppress.
For these built-in restarts, there are also corresponding functions (https://cl-community-spec.github.io/pages/f_abort.html) that you can call.