Search code examples
error-handlingcommon-lisp

What exactly does it mean for a handler to "decline" to handle a signal?


In the HyperSpec entry for HANDLER-BIND, it says that a handler can decline to handle a signal.

However, the linked glossary entry for decline to handle a signal is not very enlightening:

decline v. (of a handler) to return normally without having handled the condition being signaled, permitting the signaling process to continue as if the handler had not been present.

This definition begs and does not answer the question of what constitutes not returning normally?

Is there a full list of actions that constitute "handling" a signal?

I know from hands-on experience that INVOKE-RESTART appears to fit this criterion. But is that the only way for a handler to "handle" a signal, or are there others?


Solution

  • I think to understand the true meaning of “handle”, you should consider how the condition system works under the hood. Kent Pitman's sample implementation, written during the standardization process, is a good place to start (even though it has kludgy stuff like essentially implementing an entire object system, since CLOS was not yet part of the language).

    Roughly speaking, the action of handler-bind is to set up a special variable, which we will call *handler-clusters*, so that it holds lists of lists of (type . function) pairs, corresponding to the list of bindings. A possible definition is

    (defmacro handler-bind (bindings &body forms)
      `(let ((*handler-clusters* (cons (list (mapcar #'(lambda (x) `(cons ',(car x) ,(cadr x)))
                                             bindings))
                                       *handler-clusters*)))
         ,@forms))
    

    The signal function, then, goes through the clusters; if it finds one for the correct condition type, it calls the associated function. Definition:

    (defun signal (datum &rest arguments)
      (let ((condition (coerce-to-condition datum arguments :default 'simple-condition))
            (*handler-clusters* *handler-clusters*)) ; save current value
        (when (typep condition *break-on-signals*)
          (with-simple-restart (continue "Continue the signaling process")
            (break "Break caused by *BREAK-ON-SIGNALS*")))
        (loop for cluster := (pop *handler-clusters*) do
          (loop for binding in cluster do
            (when (typep condition (car binding))
              (funcall (cdr binding) condition)))))
      nil)
    

    where coerce-to-condition is a function that deals with “condition designators”. A subtle point is that we can't simply (loop for cluster in *handler-clusters* do …), because if, during the call of a handler, a condition of the same type as that which is being handled is signaled, the handler would be called recursively, which is probably not desirable. Thus the previous value is saved and we destructively pop the cluster off.

    Now, remember that Common Lisp allows closures over block names and tagbody tags. That is, after the definitions

    (defvar *transfer-control*)
    
    (defun weird (function)
      (tagbody
         (go :start)
         :tag
         (print 'transferred)
         (return-from weird)
         :start
         (setf *transfer-control* (lambda () ; captures the tag :tag
                                    (go :tag)))
         (funcall function)))
    

    a form like

    (weird (lambda () (funcall *transfer-control*)))
    

    is allowed, and will print 'transferred. The control transfer happens, in a sense, outside of the lexical scope of the tagbody; the ability to (go :tag) has “escaped” its enclosing scope. (It would be an error to funcall *transfer-control* after weird has returned, because the dynamic extent of the tagbody has been exited.)

    All this is to say that calling an ordinary Common Lisp function can cause a transfer of control, instead of returning a value. Calling *transfer-control* does nothing but unwind the dynamic environment up to the point of the tagbody, and then jumps to :tag. The function doesn't “return” in the usual sense of the term, because the evaluation of the expression in which it is embedded will be abruptly stopped, never to resume. (With weird and *transfer-control*, we've defined a primitive substitute for catch and throw that simply transfers control but doesn't convey a value at the same time. To see definitions of tagbody, block, and catch in terms of each other, see Henry Baker's “Metacircular Semantics for Common Lisp Special Forms”.)

    Therefore, when signal calls the handler for a condition, two things can happen:

    1. The handler transfers control, aborting the evaluation of signal and unwinding the stack to the place to which control was transferred.
    2. The handler does not transfer control, but returns a value. In this case, as the definition above shows, signal will continue looking for a handler until reaching the end of *handler-clusters* or until another handler transfers control. This is called “declining”.

    (In a way, it can also do neither or both, by, for instance, calling signal on another condition. The specification calls this deferring.)

    For example, the hyperspec gives a sample expansion for handler-case. The form

    (handler-case form
      (type1 (var1) . body1)
      (type2 (var2) . body2) ...)
    

    becomes (ignoring problems of variable capture)

    (block return-point
      (let ((condition nil))
        (tagbody
          (handler-bind ((type1 #'(lambda (temp)
                                          (setq condition temp)
                                          (go :handler-tag-1)))
                         (type2 #'(lambda (temp)
                                          (setq condition temp)
                                          (go :handler-tag-2)))
                         ...)
            (return-from return-point form))
          :handler-tag-1
          (return-from return-point (let ((var1 condition)) . body1))
          :handler-tag-2
          (return-from return-point (let ((var2 condition)) . body2))
          ...)))
    

    (I've rewritten the hyperspec's code to be more readable, although unhygienic, and I also fixed an error in the original.)

    As you can see, the handlers established by handler-case unconditionally transfer control if called. Thus handler-case handlers definitely “handle” conditions.

    Restarts are implemented in a very similar way, by restart-bind setting up the dynamic environment and invoke-restart using it to call a function. Because restarts are just functions, they need not transfer control, and so calling invoke-restart is not always an act of “handling”, although it is if the restart was established by restart-case or with-simple-restart—or, of course, if the restart transfers control.