The pattern addresses conditional code paths in a function's execution, in particular exceptional situations. Typically, there are the following ways you want to deal with a conditional situation:
- return a caller-specific value,
- signal an error as a structured way to transfer control,
- signal a warning or log a message and continue execution,
- signal a warning or log a message and return from execution,
- ignore the conditional situation and just continue execution,
- or perform some arbitrary caller-specific action.
As illustrative example throughout this posting, we will use JOIN-THREAD. (It was the recent update of SBCL's JOIN-THREAD that reminded me on this pattern.)
JOIN-THREAD waits until the passed thread finishes its execution and returns the values of evaluating the last form in the thread. There are two exceptional situations involved:
- as one does not necessarily want to wait forever, a caller can specify a timeout that might expire;
- the thread might not have finished gracefully.
Where ON-TIMEOUT and ON-FAILURE can be either functions taking a condition object, or a non-function value that would essentially be interpreted as if (CONSTANTLY <the-value>) was passed.(join-thread thread &key timeout on-timeout on-failure)
The code invoking the ON-TIMEOUT callback might be written as follows:
Notice that the callback is invoked with a Condition object. Notice further that it's invoked in a tail position of the function being defined. This is true for exceptional situations; in the broader case of conditional situations, the function will usually continue further after having invoked the callback. (Passing a non-function value should result in returning that value even in the case of a mere conditional situations; it makes writing tests much easier that purport to exercise exactly that code path.)(if (functionp on-timeout)
(return-from join-thread (funcall on-timeout <timeout condition>))
(return-from join-thread on-timeout))
Despite being an exceptional situation (i.e. just continuing is usually impossible), we might still want to provide the caller the ability to proceed further by using Common Lisp's restart system. E.g. to allow a caller to just plainly continue and hang on waiting on the thread forever, we could have written:
And we can already express a variety of different behaviours. To simply return :TIMEOUT when the timeout expires, we call JOIN-THREAD as follows:(if (functionp on-timeout)
(with-simple-restart (continue "Ignore timeout, hang on thread ~S." thread)
(return-from join-thread (funcall on-timeout <timeout condition>)))
(return-from join-thread on-timeout))
To signal an error on timeout, we call JOIN-THREAD as follows:(join-thread <thread> ... :on-timeout ':timeout)
To turn the timeout into a mere warning, it is unfortunately not enough to simply pass #'WARN because WARN is specified to require a condition of subtype Warning.(join-thread <thread> ... :on-timeout #'error) ; this should probably be the default
This restriction on WARN is superfluous but it's there, and we can always define our own variant:(join-thread <thread> ... :on-timeout #'warn) ; caveat: won't do
Notice that the above call will display a message on timeout and return NIL. To display a message (or log it using some arbitrary log mechanism) and go on waiting, we could use the following functions:(declaim (inline alert))
(defun alert (datum &rest args)
(let ((condition (apply #'coerce-to-condition datum args)))
(if (not (typep condition 'warning))
(cl:warn "~A" condition)
(join-thread <thread> ... :on-timeout #'alert)
Another example would be ALERT+RESIGNAL which will resignal the condition. This can be useful during debugging when you want to display a condition's underlying message stemming from a certain function call that a handler higher up might catch -- just add :ON-TIMEOUT #'ALERT+RESIGNAL to the call.(declaim (inline invoke-and-continue))
(defun invoke-and-continue (function condition)
(funcall function condition)
(error "BUG: could not find a CONTINUE restart for condition ~S." condition))
(defun alert+continue (condition)
(invoke-and-continue #'alert condition))
(defun logg+continue (condition)
(invoke-and-continue #'logg condition))
(join-thread <thread> ... :on-timeout #'alert+continue)
(join-thread <thread> ... :on-timeout #'logg+continue)
If desired, one could add
and make :ON-TIMEOUT and :ON-FAILURE default to these variables.(defvar *timeout-behaviour* #'error)
(defvar *failure-behaviour* #'error)
So what's nice about this pattern?
After all the CL equivalent would be
or(handler-bind ((timeout #'alert+continue))
(join-thread <thread> ...))
which may not be as concise but surely is not overly verbose either.(handler-case (join-thread <thread> ...)
(timeout () :timeout))
A couple of things.
For one, it's essentially Continuation Passing Style for exceptional situations which can be exactly what you want occasionally. Also the return-a-value case does not involve signaling a condition or an extra function call - which can make a difference. Likewise, handling an exceptional situation does not involve a non-local exit; just a function call and a local exit.
And another thing, it serves as a window of opportunity to document the exceptional situations in the function's docstring, and of course, it helps remembering these situations during automatic lambda list display.
And it's easier to change between different behaviours as it does not require changing a HANDLER-BIND to a HANDLER-CASE or vice versa, or having to add a BLOCK for explicit transfer of control out of a handler.
And, as mentioned before, the conciseness of the return-a-value case is very handy when writing tests.
It also makes code read more like prose. (He said, handwavingly^Wsternly.)