One of the most powerful features of the Common Lisp ecosystem is the Conditions system. The first time I heard of this being touted as one of the strengths of Lisp, I was completely flummoxed because I thought they were referring to the ‘cond’ construct! That was a very embarrassing moment for me indeed. Thankfully, I now have a much better understanding of Lisp than I had just a few weeks ago.
If you have ever worked with any Common Lisp distribution using emacs and SLIME, then you have undoubtedly been exposed to the Conditions system. The moment you screw up with anything, you land in what looks like the debugger – there are multiple numbered options available to you along with a comprehensive stack trace. The first few times I did land there, I was completely lost. I had no idea that this was part of the process of becoming a well-rounded Lisp developer. It is a pretty intimidating sight to say the least. Having had much more experience (and having screwed up pretty badly multiple times), the Conditions system now comforts me rather than anything else! It is a beautifully designed system much superior to any other error/condition handling system that I have ever come across.
Conditions and Restarts are a devilishly difficult concept to wrap your head around and use productively (at least for me). The twist is that the concepts are by themselves simple, but truly understanding how the entire system works is not quite that straightforward. I had to experiment with a lot of throwaway code to finally start seeing how the entire system works. In this post, I will attempt to share my (admittedly rudimentary) knowledge of the whole Conditions and Restarts mechanism.
What is a “condition” after all? Well, just like in languages that support exception handling (Java, C++, Python, etc.), a condition represents, for the most part, an “exceptional” situation. However, even more so that those languages, a condition in Common Lisp can represent a general situation where some branching in program logic needs to take place, not necessarily due to some error condition. Due to the highly interactive nature of Lisp development (the Lisp image in conjunction with the REPL), this makes perfect sense in a language like Lisp rather than say, a language like Java or even Python, which has a very primitive REPL. In most cases, however, we may not need (or even allow) the interactivity that this system offers us. Thankfully, the same system works just as well even in non-interactive mode.
Three conceptual levels, two general modes of use
As I figure it, there are three general levels of abstraction at which the Conditions & Restarts system works, and two general ways in which we can make use of this system. I will give examples of both here.
The first conceptual level (at the lowest level) would be defining and throwing the error/condition. Defining a condition is done using the DEFINE-CONDITION macro. If we are handling error conditions specifically (as we’ll assume for the rest of this post), we should extend from the “error” class. For instance, we can define a condition of type “foo” as follows:
(define-condition foo (error) ((message :initarg :message :reader error-message)))
Note that defining error conditions is orthogonal to the whole system of signalling , restarting and handling error conditions. “foo”, for instance, can be used by as many functions as we wish. This is pretty much still along the lines of exception classes in OOP languages.
Conditions can be “thrown” (or rather signalled) from code using, amongst others, “error”. We can use MAKE-CONDITION to create an instance of a condition class by suppling it with the initargS, or we can simply create an instance on the fly as, for instance:
(defun foo-thrower () (let ((num (random 2))) (when (zerop num) (error 'foo :message "foo was thrown!"))))
The second conceptual level is that of restarting the process (this may seem counter-intuitive at first, but bear with me as all will become clear in good time). Now the beauty of the Common Lisp Condition system starts becoming clearer – in brief, the generalised mode of execution is as follows: some code signals an error condition, higher level code defines different modes of restart options for the process, and the actual restart mode is chosen by error handling strategies defined at a higher level of abstraction. The advantage of this approach is that the function call stack unwinds only as much as is needed to execute the restart. If we had error handling code directly instead of restart code, the stack would already have unwound to lose most (if not all) context of the actual low-level code that needed to be run. As such, the higher level code would have to either duplicate the lower level code and redo the process, or simply continue after logging the error condition (as might happen in a language like Java).
Just a small note before an actual example: restarting cases basically offer ways to redo whatever process we were trying to execute in the first place, albeit with different parameters or modes of execution. Now, a restart case is created using the RESTART-CASE macro. For instance,
(defun foo-restarter () (restart-case (foo-thrower) (just-continue () nil) (retry () (foo-thrower))))
In this trivial case, we provide two restart cases – just-continue, which simply continues after returning a value of nil, and retry, which calls the same function again and hopes for a different result (which is justified in this case due to the random variable we use in foo-thrower to conditionally emit the error condition).
A very important note to mention at this point is that at the same conceptual level here, we could eschew restarting all together and handle the error conditions directly. This is done using the HANDLER-CASE macro. This would look like this:
(defun foo-handler () (handler-case (foo-thrower) (foo (foo-obj) (format t "error signalled: ~a~%" (error-message foo-obj)))))
handler-case has the same basic structure as restart-case. The difference is that the error handling stops right there in the case of handler-case. This is similar to the try-catch construct in OOP languages. However, maintaining restarting code at this conceptual level is more flexible and scalable.
The third and final conceptual level is that of handling the error conditions, or defining the restarting strategies. This is done at the highest levels of code in the call stack, and of course, we can have different strategies in different functions that call the same low-level functions, or we could even have multiple strategies defined in the same function. The main macros of interest here are: HANDLER-BIND which does the actual binding of error conditions to handling strategies, and INVOKE-RESTART, which actually invokes the specific restart case defined in lower level code. A point to note here is that restart cases are simply names or symbols. They are not objects. They are useful only to allow us to refer to the specific logic they include by name. Following the same ‘foo’ based example, we could define an error handler as follows (say, in case we wish to simply retry the function invocation):
(defun foo-client () (handler-bind ((foo (lambda (c) (format t "error: ~s~%" (error-message c)) (invoke-restart 'retry)))) (foo-handler)))
Note that the actual code which is wrapped by the handler-bind is the code that contains the restart cases (foo-handler) and not the actual function that signals foo.
Now let’s demonstrate all these concepts together in a single, coherent example.
(define-condition first-not-number (error) ((message :initarg :message :reader error-message))) (define-condition second-not-number (error) ((message :initarg :message :reader error-message))) (defun get-new-value (param) (format *query-io* "Enter a new value for ~s: " param) (force-output *query-io*) (list (read))) (defun add (x y) (restart-case (cond ((and (realp x) (realp y)) (+ x y)) ((not (realp x)) (error 'first-not-number :message "param 1 not a number")) ((not (realp y)) (error 'second-not-number :message "param 2 not a number"))) (return-zero () 0) (return-random-value () (random 100)) (restart-with-new-first (x) :report "Supply a new value for first param" :interactive (lambda () (get-new-value 'x)) (add x y)) (restart-with-new-second (y) :report "Supply a new value for second param" :interactive (lambda () (get-new-value 'y)) (add x y)) (just-continue () nil))) (defun add-client () (let ((x nil) (y nil)) (princ "Enter the first number: ") (setf x (read)) (princ "Enter the second number: ") (setf y (read)) (handler-bind ((first-not-number (lambda (c) (format t "error: ~s~%" (error-message c)) (invoke-restart 'restart-with-new-first))) (second-not-number (lambda (c) (format t "error: ~s~%" (error-message c)) (invoke-restart 'restart-with-new-second)))) (add x y))))
Explanation: In our trivial example, we define a function ‘add’ that tries to add together two numbers. We need to validate the parameters to ensure that they are numbers before we can add them. Of course, we could simply check them in code and fast fail or insert asserts to ensure that they are numbers. However, the point of this exercise is to demonstrate how error handling works so forgive me this transgression!
First off, we define two error conditions that check for the first and second parameters respectively. Then we define the actual add function that contains the restart cases. The :report keyword argument is used to provide custom messages in the Conditions UI when the error is actually encountered. Similarly, the :interactive keyword argument is used to enter a new value in the same screen. Of course, if we were writing a non-interactive application, we could simply forego :interactive and inject our own value instead.
Note, in particular, the restart cases restart-with-new-first and restart-with-new-second. The value that is returned by the get-new-value function is passed in as the parameter defined in parentheses and is not handled directly by us. Moreover, get-new-value returns a list (even if it is a single value) – the reason is that the Conditions system actually uses the “apply” function to retrieve the new value(s). The apply function requires at least the last argument to be a list, and so we need to return a list of the new value that we read in.
Finally we define the actual error handling strategies in our add-client function. In this case, we only handle two of the restart cases that could arise. However, other clients may choose to invoke any of the other defined restart cases.
All in all, the Common Lisp Conditions and Restarts system is a magnificent piece of work. Kudos to Kent Pitman for his brilliant work!