Condition Handling for Non-Lispers
One of Common Lisp’s more advanced features is its condition handling system, which is a powerful generalisation of other languages’ exception handling systems. I first became aware of condition handling from Peter Seibel’s Practical Common Lisp, chapter 19. But if I’m honest I’ve only very recently understood what it meant; it seems to assume a higher proficiency with Lisp, and I find the S-expressive examples more of a hindrance than a help. Sorry, Lispers.
So instead I’m going to walk through condition handling, with a more thorough explanation of the rationale, and with pseudo-Python examples. (Though, to be clear, these concepts would work just as well in a static language as a dynamic one, as exceptions do.)
The trouble with exceptions
Suppose we’re writing a library for parsing log files. We have a parseEntry
function, which takes a single line and returns an Entry
, and we want to add a parseLog
function that takes a file and returns a whole list of entries, so we can sort them by severity or whatever. This seems perfectly fine, and we toss together a little function to do this:
def parseLog(file): list = [] for line in file: list.append(parseEntry(line)) return list
But parseEntry
could raise (throw
) a ParseError
, which would unwind the call stack back up to the nearest except
(catch
) block, and then we’re no longer in parseLog
. This means if there are any malformed log entries we cannot parse any of the log with parseLog
. If the user of our library wanted to parse the log and only print warnings for each malformed entry, they would be forced to write another function that looks identical to our parseLog
except it has a try
–except
block nestled between the for
loop and parseEntry
:
def parseLogLoudly(file): list = [] for line in file: try: list.append(parseEntry(line)) except ParseError, e: print e return list
The possibility of an exception means that we have to manually build the list, because the recovery mechanism (aborting the try
block) and the error handling mechanism (the except
block) are tightly bound. Our clumsy exception handling system is actually stopping us from building the abstraction that deals with parsing an entire log. And as programming is essentially the art of abstraction, this is something a language should never ever do.
Separating handling and recovery
Instead of requiring that each try
comes with its own set of except
blocks, let’s separate these two concepts into two distinct syntactic features. We know that parseEntry
might raise a ParseError
, so we’ll place a try
between the for
loop and parseEntry
, similar to parseLogLoudly
but without the except
block.
def parseLog(file): list = [] for line in file: try: list.append(parseEntry(line)) return list
At this point the ParseError
isn’t being handled, so we’ll still unwind out of parseLog
if one is raised. This is fine for the library: if the user of the library wants to stop as soon as they reach a malformed entry, they can just catch the error. But suppose they want a parseLogLoudly
function; they’ll want to handle the error further up, but then resume from the try
block inside parseLog
.
def parseLogLoudly(file): do: return parseLog(file) handle ParseError, e: print e resume
Now when we raise a ParseError
it will be handled within ParseLogLoudly
but then we will resume running as though we had just aborted the try
block within parseLog
, and therefore continue the loop. The handle
and resume
statements essentially act as an except
block for the topmost try
in the call stack, allowing us to deal with the error further up without fully unwinding from parseLog
.
(Common Lisp uses the term ‘restart’, but I think ‘resume’ is more indicative.)
Providing recovery strategies
There are two problems with the design we have so far. First, why is it always the topmost try
that resumes? If we had a parseLogDirectory
function that called parseLog
we would want handling a ParseError
to resume the try
inside parseLog
, not some file-opening try
in parseLogDirectory
. Second, there may be a number of ways to recover from a given error, and although the decision of which recovery strategy to take is up to the handle
block, it’s the code where the error occurred that most likely knows how to deal with the error gracefully — it already has all the state to hand, and again we want to abstract so that we do not need to know exactly how to recover from low-level errors in code much further up.
The solution to both of these problems is to add recovery strategies to the try
blocks. Suppose we have two ways we can deal with a ParseError
: we can skip the entry, or we can ask the user to fix the entry so we can read it. (Alright, I’m just stealing these examples from Seibel.)
def parseLog(file): list = [] for line in file: try: list.append(parseEntry(line)) recover SkipEntry: pass recover FixEntry, fixed: line = fixed retry return list
Here we define two recovery strategies: SkipEntry
, which just continues with the loop; and FixEntry
, which comes with a fixed
variable which it uses as the new line
, and then does the try
block again. This allows us to write wrapper functions which both use parseLog
but behave very differently.
def parseLogSilently(file): do: return parseLog(file) handle ParseError, e: resume SkipEntry
def parseLogInteractively(file): do: return parseLog(file) handle ParseError, e: resume FixEntry, ask("fix entry?", e.text)
Condition handling is much more compositional than normal exception handling, and would be great for libraries like Fabric (a task-based Python SSH library), which actually already contains an ad hoc, informally-specified implementation of half of Common Lisp’s conditions. But they aren’t even that complicated when you get down to it — so why don’t more languages have them?