fkr.dev

About


I am Flo, born 1990, developer, hacker, artist and this is my blog. Into lisps, oil painting and esoteric text editors. On the interwebs I like to appear as a badger interested in cryptography. Every other decade I like spinning old jazz/funk records or programming experimental electronic music as Winfried Scratchmann. Currently located in Aix la Chapelle.

A Short Dive into the Common Lisp Condition System

Table of Contents

The Flow of Exception Handling
Concepts
Debugging
Common Lisp is notoriously known for employing some of the most advanced programming concepts albeit being a decades old language. A cornerstone of the unique, repl-driven / interactive or image-driven development workflow is its condition system that allows recovering from unforseen events (arguably) in an elegant fashion and provides many of the facilities that allow updating, enhancing and fixing applications during runtime.
I find it a bit confusing, so I try to show a few concepts below. The different paths show a set of possible configurations a program may carry on the occurence of a condition. In this overview I skip the whole concept of defining custom conditions. We start from the top where a condition is either signalled or errored and end with one of the 5 different results displayed at the bottom.
img

The Flow of Exception Handling
  1. Condition Raised: This is the starting point where an exceptional situation is detected, and a condition object is created to represent it. It may carry information on program state and matching on custom types of conditions is a common concept.
    Signal vs. Error: The condition can be raised using either the signal or error function.
    • signal: Signals a condition without initiating stack unwinding. Control may return to the point immediately after the signal call if the condition is not handled.
    • error: Signals a serious condition and initiates stack unwinding if the condition is not handled.
  2. handler-bind Present?: The system checks if there is a dynamically established handler for the condition using handler-bind.
    • Handler Found: If a handler is found, it is executed.
      • Altering Control Flow: If the handler alters the control flow (e.g., by invoking a restart or transferring control), the program flow changes accordingly and we enter some part of the program unrelated to the original program flow. This case is therefore independent of the handling occuring due to error or signal and in consequence, our primary mechanism of preserving control flow (and preventing stack unwinding) on error.
      • Not Altering Control Flow: If the handler does not alter the flow, the condition is propagated further depending on whether a signal or error occured.
    • No Handler Found: If no handler is found, the system determines whether the condition was signaled or errored: The former is ignored with regular program flow continuing, the latter enforces unwinding of the stack and enters the handler-case structure if present.
      • Signaled Condition: If the condition was signaled and not handled, execution continues from the point immediately after the signal call with regular program flow.
      • Errored Condition: If the condition was errored and not handled, stack unwinding is enforced resulting in handler-case handling, interactive debugging or program termination.
  3. Stack Unwinding and handler-case
    • Stack Unwinding: The process of unwinding the call stack to find an appropriate handler or to clean up before termination.
    • handler-case Present?: During stack unwinding, the system checks for any handler-case forms.
      • Handler Found: If a handler-case is found, its handler is executed, and execution continues from the handler-case form.
      • No Handler Found: If no handler-case is found, the system checks if it’s in an interactive environment. If interactive, the debugger is invoked, allowing the programmer to inspect and possibly recover from the error, modify values, overwrite functions, pretty much execute arbitrary program modifications and rerun/continue. Otherwise the program terminates.
Concepts Signal vs. Error
  • signal Function:
    • Used for less severe conditions that the program might recover from.
    • Does not initiate stack unwinding if unhandled.
    • If no handler is found, execution continues as if nothing happened.
  • error Function:
    • Used for serious conditions that typically require termination or significant recovery actions.
    • Initiates stack unwinding if unhandled.
    • If no handler is found, may lead to program termination or invocation of the debugger.
handler-bind vs. handler-case
  • handler-bind:
    • Establishes dynamic handlers for conditions.
    • Handlers can intercept conditions before stack unwinding occurs.
    • Useful for temporarily handling conditions during the execution of a specific block of code.
    • Does not inherently alter the control flow unless the handler explicitly does so.
  • handler-case:
    • Similar to a try-catch block in other languages.
    • Handles conditions that occur during stack unwinding.
    • Allows for recovery or cleanup actions before resuming execution or terminating.
    • The control flow continues from the point after the handler-case form if a handler handles the condition.
DebuggingWhen an unhandled error occurs in an interactive environment (like the REPL), Common Lisp invokes the interactive debugger. The debugger allows to examine variables, inspect the call stack, and even modify or rewrite the execution flow.
Invoking RestartsRestarts in Common Lisp are predefined or user-defined mechanisms that provide options for recovering from errors. They act as labeled points in the program that can be invoked to alter the control flow during exception handling.
  • Invoking a Restart: Within a handler or the debugger, you can invoke a restart using the invoke-restart function.
  • Effect on Control Flow: Invoking a restart transfers control to the point where the restart was established, potentially with arguments to influence behavior.
Defining Custom RestartsFrom the prompt of the interactive debugger you have a set of options to choose from. Generally there is always an option to ignore the exception, to retry the command that raised the condition or to terminate the program. However it is also possible to define custom options that can be called from the interactive debuggers menu.
You can define your own restarts using the restart-case macro or by establishing restarts with with-simple-restart or restart-bind. Custom restarts allow you to offer specific recovery strategies that can be interactively chosen when an error occurs.
  • Interactive Selection: When in the debugger, the available restarts are presented as options, and you can choose one to recover from the error.
  • Providing Recovery Options: Custom restarts can perform actions like retrying an operation, providing default values, or skipping over problematic code.
Below is a minimal code example illustrating the concepts of condition handling, custom restarts, and interactive debugger interaction.
(defun divide (a b)
  "Divides A by B, signaling an error if B is zero."
  (if (zerop b)
      (error 'division-by-zero :numerator a :denominator b)
      (/ a b)))

(defun safe-divide (a b)
  "Performs division with custom restarts for error recovery."
  (restart-case
      (divide a b)
    (use-default ()
      :report "Use 1 as the default denominator."
      (divide a 1))
    (specify-new-denominator (new-denominator)
      :report "Specify a new denominator."
      :interactive (lambda () (list (parse-integer (read-line))))
      (divide a new-denominator))
    (return-nil ()
      :report "Return NIL instead of performing division."
      nil)))

;; Example usage
(let ((result (safe-divide 10 0)))
  (format t "Result: ~a~%" result))
In this example we divide by 0 which is obviously a bad thing to do. Running this code block in an interactive Lisp session will spawn the debugger, and it will also carry a set of options we provide in the restart-case clause of the safe-divide function resulting in this prompt on execution:
debugger invoked on a DIVISION-BY-ZERO in thread
#<THREAD tid=77820 "main thread" RUNNING {10012D8003}>:
  arithmetic error DIVISION-BY-ZERO signalled

Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL.

restarts (invokable by number or by possibly-abbreviated name):
  0: [USE-DEFAULT            ] Use 1 as the default denominator.
  1: [SPECIFY-NEW-DENOMINATOR] Specify a new denominator.
  2: [RETURN-NIL             ] Return NIL instead of performing division.
  3: [RETRY                  ] Retry EVAL of current toplevel form.
  4: [CONTINUE               ] Ignore error and continue loading file "/tmp/dud.lisp".
  5: [ABORT                  ] Abort loading file "/tmp/dud.lisp".
  6:                           Ignore runtime option --load "/tmp/dud.lisp".
  7:                           Skip rest of --eval and --load options.
  8:                           Skip to toplevel READ/EVAL/PRINT loop.
  9: [EXIT                   ] Exit SBCL (calling #'EXIT, killing the process).
By binding handlers to the division-by-zero condition we may skip the interactive selection by instructing the program to choose (invoke) a certain restart option:
(defun auto-fix-divide (a b)
  "Attempts to divide, automatically using default restart on error."
  (handler-bind ((division-by-zero
                  (lambda (c)
                    (invoke-restart 'use-default))))
    (safe-divide a b)))
If a division-by-zero condition is signaled, the handler automatically invokes the use-default restart we defined above without any user intervention.
All this is pretty powerful but also quite complicated. For me the distinction between signaling and erroring is especially puzzling because I learned exception handling from the family of C-like languages where the concept of signaling is not really covered.

fkr.dev

About


I am Flo, born 1990, developer, hacker, artist and this is my blog. Into lisps, oil painting and esoteric text editors. On the interwebs I like to appear as a badger interested in cryptography. Every other decade I like spinning old jazz/funk records or programming experimental electronic music as Winfried Scratchmann. Currently located in Aix la Chapelle.

A Short Dive into the Common Lisp Condition System

Table of Contents

The Flow of Exception Handling
Concepts
Debugging
Common Lisp is notoriously known for employing some of the most advanced programming concepts albeit being a decades old language. A cornerstone of the unique, repl-driven / interactive or image-driven development workflow is its condition system that allows recovering from unforseen events (arguably) in an elegant fashion and provides many of the facilities that allow updating, enhancing and fixing applications during runtime.
I find it a bit confusing, so I try to show a few concepts below. The different paths show a set of possible configurations a program may carry on the occurence of a condition. In this overview I skip the whole concept of defining custom conditions. We start from the top where a condition is either signalled or errored and end with one of the 5 different results displayed at the bottom.
img

The Flow of Exception Handling
  1. Condition Raised: This is the starting point where an exceptional situation is detected, and a condition object is created to represent it. It may carry information on program state and matching on custom types of conditions is a common concept.
    Signal vs. Error: The condition can be raised using either the signal or error function.
    • signal: Signals a condition without initiating stack unwinding. Control may return to the point immediately after the signal call if the condition is not handled.
    • error: Signals a serious condition and initiates stack unwinding if the condition is not handled.
  2. handler-bind Present?: The system checks if there is a dynamically established handler for the condition using handler-bind.
    • Handler Found: If a handler is found, it is executed.
      • Altering Control Flow: If the handler alters the control flow (e.g., by invoking a restart or transferring control), the program flow changes accordingly and we enter some part of the program unrelated to the original program flow. This case is therefore independent of the handling occuring due to error or signal and in consequence, our primary mechanism of preserving control flow (and preventing stack unwinding) on error.
      • Not Altering Control Flow: If the handler does not alter the flow, the condition is propagated further depending on whether a signal or error occured.
    • No Handler Found: If no handler is found, the system determines whether the condition was signaled or errored: The former is ignored with regular program flow continuing, the latter enforces unwinding of the stack and enters the handler-case structure if present.
      • Signaled Condition: If the condition was signaled and not handled, execution continues from the point immediately after the signal call with regular program flow.
      • Errored Condition: If the condition was errored and not handled, stack unwinding is enforced resulting in handler-case handling, interactive debugging or program termination.
  3. Stack Unwinding and handler-case
    • Stack Unwinding: The process of unwinding the call stack to find an appropriate handler or to clean up before termination.
    • handler-case Present?: During stack unwinding, the system checks for any handler-case forms.
      • Handler Found: If a handler-case is found, its handler is executed, and execution continues from the handler-case form.
      • No Handler Found: If no handler-case is found, the system checks if it’s in an interactive environment. If interactive, the debugger is invoked, allowing the programmer to inspect and possibly recover from the error, modify values, overwrite functions, pretty much execute arbitrary program modifications and rerun/continue. Otherwise the program terminates.
Concepts Signal vs. Error
  • signal Function:
    • Used for less severe conditions that the program might recover from.
    • Does not initiate stack unwinding if unhandled.
    • If no handler is found, execution continues as if nothing happened.
  • error Function:
    • Used for serious conditions that typically require termination or significant recovery actions.
    • Initiates stack unwinding if unhandled.
    • If no handler is found, may lead to program termination or invocation of the debugger.
handler-bind vs. handler-case
  • handler-bind:
    • Establishes dynamic handlers for conditions.
    • Handlers can intercept conditions before stack unwinding occurs.
    • Useful for temporarily handling conditions during the execution of a specific block of code.
    • Does not inherently alter the control flow unless the handler explicitly does so.
  • handler-case:
    • Similar to a try-catch block in other languages.
    • Handles conditions that occur during stack unwinding.
    • Allows for recovery or cleanup actions before resuming execution or terminating.
    • The control flow continues from the point after the handler-case form if a handler handles the condition.
DebuggingWhen an unhandled error occurs in an interactive environment (like the REPL), Common Lisp invokes the interactive debugger. The debugger allows to examine variables, inspect the call stack, and even modify or rewrite the execution flow.
Invoking RestartsRestarts in Common Lisp are predefined or user-defined mechanisms that provide options for recovering from errors. They act as labeled points in the program that can be invoked to alter the control flow during exception handling.
  • Invoking a Restart: Within a handler or the debugger, you can invoke a restart using the invoke-restart function.
  • Effect on Control Flow: Invoking a restart transfers control to the point where the restart was established, potentially with arguments to influence behavior.
Defining Custom RestartsFrom the prompt of the interactive debugger you have a set of options to choose from. Generally there is always an option to ignore the exception, to retry the command that raised the condition or to terminate the program. However it is also possible to define custom options that can be called from the interactive debuggers menu.
You can define your own restarts using the restart-case macro or by establishing restarts with with-simple-restart or restart-bind. Custom restarts allow you to offer specific recovery strategies that can be interactively chosen when an error occurs.
  • Interactive Selection: When in the debugger, the available restarts are presented as options, and you can choose one to recover from the error.
  • Providing Recovery Options: Custom restarts can perform actions like retrying an operation, providing default values, or skipping over problematic code.
Below is a minimal code example illustrating the concepts of condition handling, custom restarts, and interactive debugger interaction.
(defun divide (a b)
  "Divides A by B, signaling an error if B is zero."
  (if (zerop b)
      (error 'division-by-zero :numerator a :denominator b)
      (/ a b)))

(defun safe-divide (a b)
  "Performs division with custom restarts for error recovery."
  (restart-case
      (divide a b)
    (use-default ()
      :report "Use 1 as the default denominator."
      (divide a 1))
    (specify-new-denominator (new-denominator)
      :report "Specify a new denominator."
      :interactive (lambda () (list (parse-integer (read-line))))
      (divide a new-denominator))
    (return-nil ()
      :report "Return NIL instead of performing division."
      nil)))

;; Example usage
(let ((result (safe-divide 10 0)))
  (format t "Result: ~a~%" result))
In this example we divide by 0 which is obviously a bad thing to do. Running this code block in an interactive Lisp session will spawn the debugger, and it will also carry a set of options we provide in the restart-case clause of the safe-divide function resulting in this prompt on execution:
debugger invoked on a DIVISION-BY-ZERO in thread
#<THREAD tid=77820 "main thread" RUNNING {10012D8003}>:
  arithmetic error DIVISION-BY-ZERO signalled

Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL.

restarts (invokable by number or by possibly-abbreviated name):
  0: [USE-DEFAULT            ] Use 1 as the default denominator.
  1: [SPECIFY-NEW-DENOMINATOR] Specify a new denominator.
  2: [RETURN-NIL             ] Return NIL instead of performing division.
  3: [RETRY                  ] Retry EVAL of current toplevel form.
  4: [CONTINUE               ] Ignore error and continue loading file "/tmp/dud.lisp".
  5: [ABORT                  ] Abort loading file "/tmp/dud.lisp".
  6:                           Ignore runtime option --load "/tmp/dud.lisp".
  7:                           Skip rest of --eval and --load options.
  8:                           Skip to toplevel READ/EVAL/PRINT loop.
  9: [EXIT                   ] Exit SBCL (calling #'EXIT, killing the process).
By binding handlers to the division-by-zero condition we may skip the interactive selection by instructing the program to choose (invoke) a certain restart option:
(defun auto-fix-divide (a b)
  "Attempts to divide, automatically using default restart on error."
  (handler-bind ((division-by-zero
                  (lambda (c)
                    (invoke-restart 'use-default))))
    (safe-divide a b)))
If a division-by-zero condition is signaled, the handler automatically invokes the use-default restart we defined above without any user intervention.
All this is pretty powerful but also quite complicated. For me the distinction between signaling and erroring is especially puzzling because I learned exception handling from the family of C-like languages where the concept of signaling is not really covered.