Skip to content

LISP - 错误处理

错误处理是编写健壮软件的关键部分。Common Lisp 具有独特而强大灵活的条件系统 (Condition System)。与许多语言中仅将调用栈展开到处理程序以处理异常不同,Lisp 条件系统允许将异常情况的发出与如何处理它分离。这使得能够创建高弹性、交互式程序。

该系统建立在三个核心概念之上:

  • 发出条件信号:通知系统发生了异常事件。这可以是致命错误、警告,或仅仅是通知。
  • 处理条件:捕获发出信号的条件并决定如何处理的代码。
  • 重启 (Restarting):一个独特的特性,发出条件信号的代码可以提供恢复策略。处理程序可以选择其中一种策略来恢复执行,而无需完全展开堆栈。

对于许多情况,handler-case 提供了简单而熟悉的 try-catch 风格的错误处理。它执行一个代码块,如果指定类型的条件被发出信号,它会中止该代码块并运行相应的处理程序代码。

让我们创建一个安全地除以两个数字的函数,优雅地处理内置的 division-by-zero 条件。

;;; main.lisp
(defun safe-divide (numerator denominator)
"除以两个数字,返回结果或错误时的描述性消息。"
(handler-case
;; 尝试的代码
(/ numerator denominator)
;; 错误子句:处理类型为 'division-by-zero' 的条件
(division-by-zero (c)
(format nil "Error: Cannot divide by zero. (Details: ~a)" c))))
;; --- 测试用例 ---
(format t "10 / 2 = ~a~%" (safe-divide 10 2))
(format t "10 / 0 = ~a~%" (safe-divide 10 0))
(format t "-5 / 2 = ~a~%" (safe-divide -5 2))
10 / 2 = 5
10 / 0 = Error: Cannot divide by zero. (Details: Division by zero.)
-5 / 2 = -5/2

条件系统的真正强大之处在于重启 (restarts)。重启允许发出错误的低级函数提供恢复选项。然后,高级处理程序可以选择其中一个选项来恢复执行,通常低级函数甚至不知道它曾被暂停。

让我们构建一个更复杂的除法函数来演示这一点。

首先,我们使用 define-condition 定义我们自己的特定条件类型。这使我们的处理程序更加精确。

;; 为我们的应用程序定义一个特定条件
(define-condition invalid-divisor (error)
((value :initarg :value
:reader invalid-divisor-value))
(:report (lambda (condition stream)
(format stream "The value ~s is an invalid divisor."
(invalid-divisor-value condition)))))

步骤 2:发出条件信号并提供重启选项

Section titled “步骤 2:发出条件信号并提供重启选项”

如果分母为零,我们的除法函数将发出自定义条件信号。关键是,它使用 restart-case 提供两种恢复策略:一种是使用不同的值,另一种是只返回 NIL。

;; 发出错误信号并提供解决方案的低级函数
(defun recoverable-divide (numerator denominator)
(if (zerop denominator)
;; 如果分母为零,则发出我们的条件信号。
(restart-case (error 'invalid-divisor :value denominator)
;; 重启 1:允许调用者提供一个新的分母。
(use-new-denominator (new-denominator)
:report "Specify a new denominator to use for the division."
(recoverable-divide numerator new-denominator))
;; 重启 2:允许调用者简单地中止并返回 NIL。
(abort-division ()
:report "Abort the division and return NIL."
nil))
;; 如果一切正常,则执行除法。
(/ numerator denominator)))

现在,一个更高级别的函数可以调用 recoverable-divide 并使用 handler-bind 来处理错误。与 handler-case 不同,handler-bind 不会展开堆栈。它可以检查条件,然后使用 invoke-restart 选择一个可用的恢复选项。

;; 以交互方式处理错误的高级函数。
(defun division-ui ()
(handler-bind ((invalid-divisor
(lambda (c)
(declare (ignore c)) ; 我们在这里不需要条件对象
(format *error-output* "~&Error signaled! Attempted to divide by zero.~%")
(format *error-output* "Let's try to recover by providing a new denominator.~%")
;; 选择 'use-new-denominator' 重启并传递 '2' 作为新值。
(invoke-restart 'use-new-denominator 2))))
(let ((result (recoverable-divide 10 0)))
(format t "~&The final result of the division is: ~a~%" result))))
(division-ui)
Error signaled! Attempted to divide by zero.
Let's try to recover by providing a new denominator.
The final result of the division is: 5

以下是用于发出条件信号的标准函数摘要。

函数描述和用例
error datum &rest args发出一个致命的、不可继续的错误信号。最常见的表示出现问题且在不干预的情况下无法正常执行的方式。
cerror continue-format-string error-datum &rest args发出一个可继续的错误信号。如果这在交互式调试器(如 SLIME)中被发出信号,用户将看到一个选项来继续执行。这在开发过程中非常有用。
warn datum &rest args发出一个非致命警告信号。警告打印后程序执行继续。用于提醒用户潜在问题,例如使用了已废弃函数。
break &optional format-string &rest args无条件进入调试器。这是一个强大的调试工具,行为类似于断点,也允许交互式检查和修改程序状态。
  • 使用 handler-case 处理简单的 try-catch 逻辑,即您只想处理错误并继续执行。
  • 为您的应用程序定义自定义条件,以使处理程序更具体,并使您的代码更具自文档化能力。
  • 使用 restart-case 和 handler-bind 来构建弹性系统,其中低级代码可以提供恢复策略,而高级代码可以决定如何使用它们。
  • 在开发过程中利用 cerror 在满足某些条件时创建可继续的断点。