29.3 Survey of Concepts

This section discusses various aspects of the condition system by topic, illustrating them with extensive examples. The next section contains definitions of specific functions, macros, and other facilities.

29.3.1 Signaling Errors

Conceptually, signaling an error in a program is an admission by that program that it does not know how to continue and requires external intervention. Once an error is signaled, any decision about how to continue must come from the “outside.”

The simplest way to signal an error is to use the error function with format-style arguments describing the error for the sake of the user interface. If error is called and there are no active handlers (described in sections 29.3.2 and 29.3.3), the debugger will be entered and the error message will be typed out. For example:

Lisp> (defun factorial (x)
        (cond ((or (not (typep x ’integer)) (minusp x))
               (error "~S is not a valid argument to FACTORIAL."
                      x))
              ((zerop x) 1)
              (t (* x (factorial (- x 1))))))
  FACTORIAL
Lisp> (factorial 20)
  2432902008176640000
Lisp> (factorial -1)
Error: -1 is not a valid argument to FACTORIAL.
To continue, type :CONTINUE followed by an option number:
 1: Return to Lisp Toplevel.
Debug>

In general, a call to error cannot directly return. Unless special work has been done to override this behavior, the debugger will be entered and there will be no option to simply continue.

The only exception may be that some implementations may provide debugger commands for interactively returning from individual stack frames; even then, however, such commands should never be used except by someone who has read the erring code and understands the consequences of continuing from that point. In particular, the programmer should feel confident about writing code like this:

(defun wargames:no-win-scenario ()
  (when (true) (error "Pushing the button would be stupid."))
  (push-the-button))

In this scenario, there should be no chance that the function error will return and the button will be pushed. ____________________________________________

Remark: It should be noted that the notion of “no chance” that the button will be pushed is relative only to the language model; it assumes that the language is accurately implemented. In practice, compilers have bugs, computers have glitches, and users have been known to interrupt at inopportune moments and use the debugger to return from arbitrary stack frames. Such violations of the language model are beyond the scope of the condition system but not necessarily beyond the scope of potential failures that the programmer should consider and defend against. The possibility of such unusual failures may of course also influence the design of code meant to handle less drastic situations, such as maintaining a database uncorrupted.—KMP and GLS__

In some cases, the programmer may have a single, well-defined idea of a reasonable recovery strategy for this particular error. In that case, he can use the function cerror, which specifies information about what would happen if the user did simply continue from the call to cerror. For example:

Lisp> (defun factorial (x)
        (cond ((not (typep x ’integer))
               (error "~S is not a valid argument to FACTORIAL."
                      x))
              ((minusp x)
               (let ((x-magnitude (- x)))
                 (cerror "Compute -(~D!) instead."
                         "(-~D)! is not defined." x-magnitude)
                 (- (factorial x-magnitude))))
              ((zerop x) 1)
              (t (* x (factorial (- x 1))))))
  FACTORIAL
Lisp> (factorial -3)
Error: (-3)! is not defined.
To continue, type :CONTINUE followed by an option number:
 1: Compute -(3!) instead.
 2: Return to Lisp Toplevel.
Debug> :continue 1
  -6

29.3.2 Trapping Errors

By default, a call to error will force entry into the debugger. You can override that behavior in a variety of ways. The simplest (and most blunt) tool for inhibiting entry to the debugger on an error is to use ignore-errors. In the normal situation, forms in the body of ignore-errors are evaluated sequentially and the last value is returned. If a condition of type error is signaled, ignore-errors immediately returns two values, namely nil and the condition that was signaled; the debugger is not entered and no error message is printed. For example:

Lisp> (setq filename "nosuchfile")
  "nosuchfile"
Lisp> (ignore-errors (open filename :direction :input))
  NIL and #<FILE-ERROR 3437523>

The second return value is an object that represents the kind of error. This is explained in greater detail in section 29.3.4.

In many cases, however, ignore-errors is not desirable because it deals with too many kinds of errors. Contrary to the belief of some, a program that does not enter the debugger is not necessarily better than one that does. Excessive use of ignore-errors may keep the program out of the debugger, but it may not increase the program’s reliability, because the program may continue to run after encountering errors other than those you meant to work past. In general, it is better to attempt to deal only with the particular kinds of errors that you believe could legitimately happen. That way, if an unexpected error comes along, you will still find out about it.

ignore-errors is a useful special case built from a more general facility, handler-case, that allows the programmer to deal with particular kinds of conditions (including non-error conditions) without affecting what happens when other kinds of conditions are signaled. For example, an effect equivalent to that of ignore-errors above is achieved in the following example:

Lisp> (setq filename "nosuchfile")
  "nosuchfile"
Lisp> (handler-case (open filename :direction :input)
        (error (condition)
          (values nil condition)))
  NIL and #<FILE-ERROR 3437525>

However, using handler-case, one can indicate a more specific condition type than just “error.” Condition types are explained in detail later, but the syntax looks roughly like the following:

Lisp> (makunbound ’filename)
  FILENAME
Lisp> (handler-case (open filename :direction :input)
        (file-error (condition)
          (values nil condition)))
Error: The variable FILENAME is unbound.
To continue, type :CONTINUE followed by an option number:
 1: Retry getting the value of FILENAME.
 2: Specify a value of FILENAME to use this time.
 3: Specify a value of FILENAME to store and use.
 4: Return to Lisp Toplevel.
Debug>

29.3.3 Handling Conditions

Blind transfer of control to a handler-case is only one possible kind of recovery action that can be taken when a condition is signaled. The low-level mechanism offers great flexibility in how to continue once a condition has been signaled.

The basic idea behind condition handling is that a piece of code called the signaler recognizes and announces the existence of an exceptional situation using signal or some function built on signal (such as error).

The process of signaling involves the search for and invocation of a handler, a piece of code that will attempt to deal appropriately with the situation.

If a handler is found, it may either handle the situation, by performing some non-local transfer of control, or decline to handle it, by failing to perform a non-local transfer of control. If it declines, other handlers are sought.

Since the lexical environment of the signaler might not be available to handlers, a data structure called a condition is created to represent explicitly the relevant state of the situation. A condition either is created explicitly using make-condition and then passed to a function such as signal, or is created implicitly by a function such as signal when given appropriate non-condition arguments.

In order to handle the error, a handler is permitted to use any non-local transfer of control such as go to a tag in a tagbody, return from a block, or throw to a catch. In addition, structured abstractions of these primitives are provided for convenience in exception handling.

A handler can be made dynamically accessible to a program by use of handler-bind. For example, to create a handler for a condition of type arithmetic-error, one might write:

(handler-bind ((arithmetic-error handler))body)

The handler is a function of one argument, the condition. If a condition of the designated type is signaled while the body is executing (and there are no intervening handlers), the handler would be invoked on the given condition, allowing it the option of transferring control. For example, one might write a macro that executes a body, returning either its value(s) or the two values nil and the condition:

(defmacro without-arithmetic-errors (&body forms)
  (let ((tag (gensym)))
    ‘(block ,tag
       (handler-bind ((arithmetic-error
                         #’(lambda (c)     ;Argument c is a condition
                             (return-from ,tag (values nil c)))))
         ,@body))))

The handler is executed in the dynamic context of the signaler, except that the set of available condition handlers will have been rebound to the value that was active at the time the condition handler was made active. If a handler decline (that is, it does not transfer control), other handlers are sought. If no handler is found and the condition was signaled by error or cerror (or some function such as assert that behaves like these functions), the debugger is entered, still in the dynamic context of the signaler.

29.3.4 Object-Oriented Basis of Condition Handling

Of course, the ability of the handler to usefully handle an exceptional situation is related to the quality of the information it is provided. For example, if all errors were signaled by

(error "some format string")

then the only piece of information that would be accessible to the handler would be an object of type simple-error that had a slot containing the format string.

If this were done, string-equal would be the preferred way to tell one error from another, and it would be very hard to allow flexibility in the presentation of error messages because existing handlers would tend to be broken by even tiny variations in the wording of an error message. This phenomenon has been the major failing of most error systems previously available in Lisp. It is fundamentally important to decouple the error message string (the human interface) from the objects that formally represent the error state (the program interface). We therefore have the notion of typed conditions, and of formal operations on those conditions that make them inspectable in a structured way.

This object-oriented approach to condition handling has the following important advantages over a text-based approach:

Some condition types are defined by this document, but the set of condition types is extensible using define-condition. Common Lisp condition types are in fact CLOS classes, and condition objects are ordinary CLOS objects; define-condition merely provides an abstract interface that is a bit more convenient than defclass for defining conditions.

Here, as an example, we define a two-argument function called divide that is patterned after the / function but does some stylized error checking:

(defun divide (numerator denominator)
  (cond ((or (not (numberp numerator))
             (not (numberp denominator)))
         (error "(DIVIDE ’~S ’~S) - Bad arguments."
                numerator denominator))
        ((zerop denominator)
         (error ’division-by-zero
                :operator ’divide
                :operands (list numerator denominator)))
        (t ...)))

Note that in the first clause we have used error with a string argument and in the second clause we have named a particular condition type, division-by-zero. In the case of a string argument, the condition type that will be signaled is simple-error.

The particular kind of error that is signaled may be important in cases where handlers are active. For example, simple-error inherits from type error, which in turn inherits from type condition. On the other hand, division-by-zero inherits from arithmetic-error, which inherits from error, which inherits from condition. So if a handler existed for arithmetic-error while a division-by-zero condition was signaled, that handler would be tried; however, if a simple-error condition were signaled in the same context, the handler for type arithmetic-error would not be tried.

29.3.5 Restarts

The Common Lisp Condition System creats a clear separation between the act of signaling an error of a particular type and the act of saying that a particular way of recovery is appropriate. In the divide example above, simply signaling an error does not imply a willingness on the part of the signaler to cooperate in any corrective action. For example, the following sample interaction illustrates that the only recovery action offered for this error is “Return to Lisp Toplevel”:

Lisp> (+ (divide 3 0) 7)
Error: Attempt to divide 3 by 0.
To continue, type :CONTINUE followed by an option number:
 1: Return to Lisp Toplevel.
Debug> :continue 1
Returned to Lisp Toplevel.
Lisp>

When an error is detected and the function error is called, execution cannot continue normally because error will not directly return. Control can be transferred to other points in the program, however, by means of specially established “restarts.”

29.3.6 Anonymous Restarts

The simplest kind of restart involves structured transfer of control using a macro called restart-case. The restart-case form allows execution of a piece of code in a context where zero or more restarts are active, and where if one of those restarts is “invoked,” control will be transferred to the corresponding clause in the restart-case form. For example, we could rewrite the previous divide example as follows.

(defun divide (numerator denominator)
  (loop
    (restart-case
        (return
          (cond ((or (not (numberp numerator))
                     (not (numberp denominator)))
                 (error "(DIVIDE ’~S ’~S) - Bad arguments."
                         numerator denominator))
                ((zerop denominator)
                 (error ’division-by-zero
                        :operator ’divide
                        :operands (list numerator denominator)))
                (t ...)))
      (nil (arg1 arg2)
          :report "Provide new arguments for use by DIVIDE."
          :interactive
            (lambda ()
               (list (prompt-for ’number "Numerator: ")
                     (prompt-for ’number "Denominator: ")))
        (setq numerator arg1 denominator arg2))
      (nil (result)
          :report "Provide a value to return from DIVIDE."
          :interactive
            (lambda () (list (prompt-for ’number "Result: ")))
        (return result)))))

__________________________________________________________________________

Remark: The function prompt-for used in this chapter in a number of places is not a part of Common Lisp. It is used in the examples in this chapter only to keep the presentation simple. It is assumed to accept a type specifier and optionally a format string and associated arguments. It uses the format string and associated arguments as part of an interactive prompt, and uses read to read a Lisp object; however, only an object of the type indicated by the type specifier is accepted.

The question of whether or not prompt-for (or something like it) would be a useful addition to Common Lisp is under consideration by X3J13, but as of January 1989 no action has been taken. In spite of its use in a number of examples, nothing in the Common Lisp Condition System depends on this function. __________________________________________________________________________

In the example, the nil at the head of each clause means that it is an “anonymous” restart. Anonymous restarts are typically invoked only from within the debugger. As we shall see later, it is possible to have “named restarts” that may be invoked from code without the need for user intervention.

If the arguments to anonymous restarts are not optional, then special information must be provided about what the debugger should use as arguments. Here the :interactive keyword is used to specify that information.

The :report keyword introduces information to be used when presenting the restart option to the user (by the debugger, for example).

Here is a sample interaction that takes advantage of the restarts provided by the revised definition of divide:

Lisp> (+ (divide 3 0) 7)
Error: Attempt to divide 3 by 0.
To continue, type :CONTINUE followed by an option number:
 1: Provide new arguments for use by the DIVIDE function.
 2: Provide a value to return from the DIVIDE function.
 3: Return to Lisp Toplevel.
Debug> :continue 1
1
Numerator: 4
Denominator: 2
  9

29.3.7 Named Restarts

In addition to anonymous restarts, one can have named restarts, which can be invoked by name from within code. As a trivial example, one could write

(restart-case (invoke-restart ’foo 3)
  (foo (x) (+ x 1)))

to add 3 to 1, returning 4. This trivial example is conceptually analogous to writing:

(+ (catch ’something (throw ’something 3)) 1)

For a more realistic example, the code for the function symbol-value might signal an unbound variable error as follows:

(restart-case (error "The variable ~S is unbound." variable)
  (continue ()
      :report
        (lambda (s)     ;Argument s is a stream
          (format s "Retry getting the value of ~S." variable))
    (symbol-value variable))
  (use-value (value)
      :report
        (lambda (s)     ;Argument s is a stream
          (format s "Specify a value of ~S to use this time."
                  variable))
    value)
  (store-value (value)
      :report
        (lambda (s)     ;Argument s is a stream
          (format s "Specify a value of ~S to store and use."
                  variable))
    (setf (symbol-value variable) value)
    value))

If this were part of the implementation of symbol-value, then it would be possible for users to write a variety of automatic handlers for unbound variable errors. For example, to make unbound variables evaluate to themselves, one might write

(handler-bind ((unbound-variable
                 #’(lambda (c)     ;Argument c is a condition
                     (when (find-restart ’use-value)
                       (invoke-restart ’use-value
                                       (cell-error-name c))))))
  body)

29.3.8 Restart Functions

For commonly used restarts, it is conventional to define a program interface that hides the use of invoke-restart. Such program interfaces to restarts are called restart functions.

The normal convention is for the function to share the name of the restart. The pre-defined functions abort, continue, muffle-warning, store-value, and use-value are restart functions. With use-value the above example of handler-bind could have been written more concisely as

(handler-bind ((unbound-variable
                   #’(lambda (c)     ;Argument c is a condition
                       (use-value (cell-error-name c)))))
  body)

29.3.9 Comparison of Restarts and Catch/Throw

One important feature that restart-case (or restart-bind) offers that catch does not is the ability to reason about the available points to which control might be transferred without actually attempting the transfer. One could, for example, write

(ignore-errors (throw ...))

which is a sort of poor man’s variation of

(when (find-restart ’something)
  (invoke-restart ’something))

but there is no way to use ignore-errors and throw to simulate something like

(when (and (find-restart ’something)
           (find-restart ’something-else))
  (invoke-restart ’something))

or even just

(when (and (find-restart ’something)
           (yes-or-no-p "Do something? "))
  (invoke-restart ’something))

because the degree of inspectability that comes with simply writing

(ignore-errors (throw ...))

is too primitive—getting the desired information also forces transfer of control, perhaps at a time when it is not desirable.

Many programmers have previously evolved strategies like the following on a case-by-case basis:

(defvar *foo-tag-is-available* nil)

(defun fn-1 ()
  (catch ’foo
    (let ((*foo-tag-is-available* t))
      ... (fn-2) ...)))

(defun fn-2 ()
  ...
  (if *foo-tag-is-available* (throw ’foo t))
  ...)

The facility provided by restart-case and find-restart is intended to provide a standardized protocol for this sort of information to be communicated between programs that were developed independently so that individual variations from program to program do not thwart the overall modularity and debuggability of programs.

Another difference between the restart facility and the catch/throw facility is that a catch with any given tag completely shadows any outer pending catch that uses the same tag. Because of the presence of compute-restarts, however, it is possible to see shadowed restarts, which may be very useful in some situations (particularly in an interactive debugger).

29.3.10 Generalized Restarts

restart-case is a mechanism that allows only imperative transfer of control for its associated restarts. restart-case is built on a lower-level mechanism called restart-bind, which does not force transfer of control.

restart-bind is to restart-case as handler-bind is to handler-case. The syntax is

(restart-bind ((name function . options)) . body)

The body is executed in a dynamic context within which the function will be called whenever (invoke-restart ’name) is executed. The options are keyword-style and are used to pass information such as that provided with the :report keyword in restart-case.

A restart-case expands into a call to restart-bind where the function simply does an unconditional transfer of control to a particular body of code, passing along “argument” information in a structured way.

It is also possible to write restarts that do not transfer control. Such restarts may be useful in implementing various special commands for the debugger that are of interest only in certain situations. For example, one might imagine a situation where file space was exhausted and the following was done in an attempt to free space in directory dir:

(restart-bind ((nil #’(lambda () (expunge-directory dir))
                    :report-function
                      #’(lambda (stream)
                          (format stream "Expunge ~A."
                                  (directory-namestring dir)))))
  (cerror "Try this file operation again."
          ’directory-full :directory dir))

In this case, the debugger might be entered and the user could first perform the expunge (which would not transfer control from the debugger context) and then retry the file operation:

Lisp> (open "FOO" :direction :output)
Error: The directory PS:<JDOE> is full.
To continue, type :CONTINUE followed by an option number:
 1: Try this file operation again.
 2: Expunge PS:<JDOE>.
 3: Return to Lisp Toplevel.
Debug> :continue 2
Expunging PS:<JDOE> ... 3 records freed.
Debug> :continue 1
  #<OUTPUT-STREAM "PS:<JDOE>FOO.LSP" 2323473>

29.3.11 Interactive Condition Handling

When a program does not know how to continue, and no active handler is able to advise it, the “interactive condition handler,” or “debugger,” can be entered. This happens implicitly through the use of functions such as error and cerror, or explicitly through the use of the function invoke-debugger.

The interactive condition handler never returns directly; it returns only through structured non-local transfer of control to specially defined restart points that can be set up either by the system or by user code. The mechanisms that support the establishment of such structured restart points for portable code are outlined in sections 29.3.5 through 29.3.10.

Actually, implementations may also provide extended debugging facilities that allow return from arbitrary stack frames. Although such commands are frequently useful in practice, their effects are implementation-dependent because they violate the Common Lisp program abstraction. The effect of using such commands is undefined with respect to Common Lisp.

29.3.12 Serious Conditions

The ignore-errors macro will trap conditions of type error. There are, however, conditions that are not of type error.

Some conditions are not considered errors but are still very serious, so we call them serious conditions and we use the type serious-condition to represent them. Conditions such as those that might be signaled for “stack overflow” or “storage exhausted” are in this category.

The type error is a subtype of serious-condition, and it would technically be correct to use the term “serious condition” to refer to all serious conditions whether errors or not. However, normally we use the term “serious condition” to refer to things of type serious-condition but not of type error.

The point of the distinction between errors and other serious conditions is that some conditions are known to occur for reasons that are beyond the scope of Common Lisp to specify clearly. For example, we know that a stack will generally be used to implement function calling, and we know that stacks tend to be of finite size and are prone to overflow. Since the available stack size may vary from implementation to implementation, from session to session, or from function call to function call, it would be confusing to have expressions such as (ignore-errors (+ a b)) return a number sometimes and nil other times if a and b were always bound to numbers and the stack just happened to overflow on a particular call. For this reason, only conditions of type error and not all conditions of type serious-condition are trapped by ignore-errors. To trap other conditions, a lower-level facility must be used (such as handler-bind or handler-case).

By convention, the function error is preferred over signal to signal conditions of type serious-condition (including those of type error). It is the use of the function error, and not the type of the condition being signaled, that actually causes the debugger to be entered.

29.3.13 Non-Serious Conditions

Some conditions are neither errors nor serious conditions. They are signaled to give other programs a chance to intervene, but if no action is taken, computation simply continues normally.

For example, an implementation might choose to signal a non-serious (and implementation-dependent) condition called end-of-line when output reaches the last character position on a line of character output. In such an implementation, the signaling of this condition might allow a convenient way for other programs to intervene, producing output that is truncated at the end of a line.

By convention, the function signal is used to signal conditions that are not serious. It would be possible to signal serious conditions using signal, and the debugger would not be entered if the condition went unhandled. However, by convention, handlers will generally tend to assume that serious conditions and errors were signaled by calling the error function (and will therefore force entry to the interactive condition handler) and that they should work to avoid this.

29.3.14 Condition Types

Some types of conditions are predefined by the system. All types of conditions are subtypes of condition. That is, (typep x ’condition) is true if and only if the value of x is a condition.

Implementations supporting multiple (or non-hierarchical) type inheritance are expressly permitted to exploit multiple inheritance in the tree of condition types as implementation-dependent extensions, as long as such extensions are compatible with the specifications in this chapter. [X3J13 voted in March 1989 to integrate the Condition System and the Object System, so multiple inheritance is always available for condition types.—GLS]

In order to avoid problems in portable code that runs both in systems with multiple type inheritance and in systems without it, programmers are explicitly warned that while all correct Common Lisp implementations will ensure that (typep c ’condition) is true for all conditions c (and all subtype relationships indicated in this chapter will also be true), it should not be assumed that two condition types specified to be subtypes of the same third type are disjoint. (In some cases, disjoint subtypes are identified explicitly, but such disjointness is not to be assumed by default.) For example, it follows from the subtype descriptions contained in this chapter that in all implementations (typep c ’control-error) implies (typep c ’error), but note that (typep c ’control-error) does not imply (not (typep c ’cell-error)).

29.3.15 Signaling Conditions

When a condition is signaled, the system tries to locate the most appropriate handler for the condition and to invoke that handler.

Handlers are established dynamically using handler-bind or abstractions built on handler-bind.

If an appropriate handler is found, it is called. In some circumstances, the handler may decline simply by returning without performing a non-local transfer of control. In such cases, the search for an appropriate handler is picked up where it left off, as if the called handler had never been present.

If no handler is found, or if all handlers that were found decline, signal returns nil.

Although it follows from the description above, it is perhaps worth noting explicitly that the lookup procedure described here will prefer a general but more (dynamically) local handler over a specific but less (dynamically) local handler. Experience with existing condition systems suggests that this is a reasonable approach and works adequately in most situations. Some care should be taken when binding handlers for very general kinds of conditions, such as is done in ignore-errors. Often, binding for a more specific condition type than error is more appropriate.

29.3.16 Resignaling Conditions

[The contents of this section are still a subject of some debate within X3J13. The reader may wish to take this section with a grain of salt.—GLS]

Note that signaling a condition has no side effect on that condition, and that there is no dynamic state contained in a condition object. As such, it may at times be reasonable and appropriate to consider caching condition objects for repeated use, re-signaling conditions from within handlers, or saving conditions away somewhere and re-signaling them later.

For example, it may be desirable for the system to pre-allocate objects of type storage-condition so that they can be signaled when needed without attempting to allocate more storage.

29.3.17 Condition Handlers

A handler is a function of one argument, the condition to be handled. The handler may inspect the object to be sure it is “interested” in handling the condition.

A handler is executed in the dynamic context of the signaler, except that the set of available condition handlers will have been rebound to the value that was active at the time the condition handler was made active. The intent of this is to prevent infinite recursion because of errors in a condition handler.

After inspecting the condition, the handler should take one of the following actions:

In fact, the latter two actions (signaling another condition or entering the debugger) are really just ways of putting off the decision to either handle or decline, or trying to get someone else to make such a decision. Ultimately, all a handler can do is to handle or decline to handle.

29.3.18 Printing Conditions

When *print-escape* is nil (for example, when the princ function or the ~A directive is used with format), the report method for the condition will be invoked. This will be done automatically by functions such as invoke-debugger, break, and warn, but there may still be situations in which it is desirable to have a condition report under explicit user control. For example,

(let ((form ’(open "nosuchfile")))
  (handler-case (eval form)
    (serious-condition (c)
      (format t "~&Evaluation of ~S failed:~%~A" form c))))

might print something like

Evaluation of (OPEN "nosuchfile") failed:
The file "nosuchfile" was not found.

Some suggestions about the form of text typed by report methods:

When *print-escape* is not nil, the object should print in some useful (but usually fairly abbreviated) fashion according to the style of the implementation. It is not expected that a condition will be printed in a form suitable for read. Something like #<ARITHMETIC-ERROR 1734> is fine.

X3J13 voted in March 1989 to integrate the Condition System and the Object System. In the original Condition System proposal, no function was provided for directly accessing or setting the printer for a condition type, or for invoking it; the techniques described above were the sole interface to reporting. The vote specified that, in CLOS terms, condition reporting is mediated through the print-object method for the condition type (that is, class) in question, with *print-escape* bound to nil.

Specifying (:report fn) to define-condition when defining condition type C is equivalent to a separate method definition:

(defmethod print-object ((x C) stream)
  (if *print-escape*
      (call-next-method)
      (funcall #’fn x stream)))

Note that the method uses fn to print the condition only when *print-escape* has the value nil.