Система условий и рестарты: мощнее исключений

Система условий и рестарты: почему механизм Common Lisp мощнее исключений — он разделяет «где обнаружили ошибку» и «как её исправить», не разрушая стек.

Рестарт — это именованная точка восстановления, объявленная глубоко в коде; обработчик, находящийся выше по стеку, может выбрать рестарт, и выполнение продолжится с этой точки — без раскрутки стека до обработчика.

Зачем это: исключения теряют контекст

Привычные исключения (try/catch) устроены так: при ошибке стек раскручивается до ближайшего обработчика, и весь промежуточный контекст уничтожается. Обработчик узнаёт о проблеме, но уже не может вернуться туда, где она возникла, чтобы починить и продолжить — место ошибки исчезло. Это фундаментальное ограничение: «где обнаружили» и «как реагировать» жёстко связаны раскруткой. Система условий Common Lisp разрывает эту связь. Обработчик вызывается до раскрутки стека, пока контекст ошибки ещё жив, и может выбрать одну из заранее объявленных стратегий восстановления (рестартов), продолжив работу с глубокого места. Это делает CL-механизм заметно мощнее исключений — и объясняет, почему интерактивная отладка в Lisp так удобна.

Чтобы прочувствовать ограничение исключений, представьте реальный сценарий: программа парсит файл из миллиона строк, и на строке 999 999 встречается одно битое число. С обычными исключениями выброс ошибки раскрутит стек до верхнего try, уничтожив весь прогресс разбора, — и вы окажетесь наверху с сообщением «ошибка на строке 999999», но без возможности «подставить разумное значение и доработать последнюю строку». Придётся либо обернуть каждую строку в try/catch заранее (если вы предвидели это место), либо перезапускать весь разбор. Система условий же позволяет верхнему коду, узнав о проблеме, сказать «для этой строки используй ноль» — и разбор продолжится с того же места, не потеряв 999 998 уже обработанных строк. Это не теоретическое удобство, а качественно иной уровень управления ошибками: вы отделяете обнаружение проблемы (глубоко, где есть детали) от решения о реакции (высоко, где есть контекст политики), и при этом не теряете ни место, ни прогресс. Именно поэтому говорят, что в Common Lisp ошибки — это не «аварийный выход», а полноценный канал коммуникации между слоями программы.

Базовый слой: сигналы и обработка

Условие (condition) — это объект, описывающий ситуацию (часто, но не всегда, ошибку). Его «бросают» через signal/error, а ловят обработчиками. Простейший аналог try/catchhandler-case: он раскручивает стек и выполняет ветвь по типу условия. На этом уровне всё похоже на исключения.

(defun safe-divide (a b)
  (handler-case
      (/ a b)
    (division-by-zero ()           ; ветвь по типу условия
      :infinity)))                 ; что вернуть при делении на ноль

(safe-divide 10 2)   ; => 5
(safe-divide 10 0)   ; => :INFINITY

;; собственное условие — это класс CLOS, наследник condition:
(define-condition insufficient-funds (error)
  ((requested :initarg :requested :reader requested)
   (balance   :initarg :balance   :reader balance))
  (:report (lambda (c stream)
             (format stream "Запрошено ~a, доступно ~a"
                     (requested c) (balance c)))))

Условия — это классы CLOS (наследники condition), поэтому у них есть слоты, наследование и обобщённые функции. :report задаёт человекочитаемое сообщение. Уже здесь видно отличие от многих языков: иерархия ошибок — это полноценные классы, а не строки или коды.

Ключевая идея: handler-bind без раскрутки

Главная мощь — в handler-bind. В отличие от handler-case, он вызывает обработчик не раскручивая стек: обработчик исполняется «на месте», в контексте сигнала, пока всё ещё живо. Это позволяет обработчику принять решение и продолжить, а не просто «поймать и выйти». Чтобы было куда продолжать, низкоуровневый код объявляет рестарты.

;; Глубоко в коде объявляем рестарты — стратегии восстановления:
(defun parse-number (str)
  (restart-case
      (let ((n (read-from-string str)))
        (if (numberp n) n (error "Не число: ~s" str)))
    (use-value (v) :report "Подставить значение" v)   ; рестарт 1
    (use-zero  ()  :report "Считать нулём" 0)))         ; рестарт 2

;; Высоко по стеку обработчик ВЫБИРАЕТ рестарт, не раскручивая до себя:
(defun sum-strings (strings)
  (handler-bind
      ((error (lambda (c)
                (declare (ignore c))
                (invoke-restart 'use-zero))))   ; чинить на месте: брать 0
    (reduce #'+ (mapcar #'parse-number strings))))

(sum-strings '("1" "2" "oops" "4"))   ; => 7  ("oops" -> 0, остальное суммируется)

Разберём, что произошло. parse-number на плохой строке сигналит ошибку, но прежде объявил рестарты use-value и use-zero. Высокоуровневый sum-strings установил обработчик через handler-bind; когда ошибка случилась, обработчик выполнился в точке сигнала и вызвал (invoke-restart 'use-zero) — выполнение продолжилось оттуда, вернув 0 для «oops». Стек между parse-number и sum-strings не разрушался: остальные строки обработались нормально. Это невозможно с классическими исключениями — там «oops» прервал бы всё.

Разделение политики и механизма

Здесь кроется глубокая архитектурная идея. Низкоуровневый код (parse-number) знает как можно восстановиться (какие рестарты осмысленны), но не знает, какую стратегию выбрать — это зависит от контекста. Высокоуровневый код (sum-strings) знает политику («битые числа считаем нулём»), но не лезет в детали парсинга. Система условий идеально разделяет эти роли: «механизм восстановления» объявляется внизу как рестарты, «политика выбора» — наверху как обработчики. Один и тот же parse-number в другом контексте можно использовать с другим обработчиком (например, спросить пользователя через use-value). Это переиспользование стратегий восстановления без переписывания низкоуровневого кода — то, чего исключения дать не могут.

Это разделение «механизм внизу / политика наверху» — не просто красивая абстракция, а решение давней проблемы проектирования. Кто должен решать, что делать при ошибке? Низкоуровневая функция чтения числа не может: она не знает, в каком приложении её вызвали — может, тут нужно прервать всё, может, подставить ноль, может, спросить пользователя, а может, записать в лог и пропустить. Если зашить решение в неё, функция станет негодной для других контекстов. Но и просто «бросить исключение наверх» теряет возможность аккуратно восстановиться на месте. Рестарты разрешают дилемму: низкий уровень перечисляет варианты восстановления (это его компетенция — он знает, что технически возможно), а высокий уровень выбирает вариант (это его компетенция — он знает политику). Каждая часть решает то, в чём компетентна, и ничего лишнего. Это образец хорошего разделения ответственности, и система условий возводит его в архитектурный принцип, недостижимый с одними лишь исключениями.

Интерактивная отладка как следствие

Когда условие не перехвачено ни одним обработчиком, управление получает отладчик. И вот ключевой момент: поскольку стек ещё не раскручен, отладчик показывает живой стек в точке ошибки и предлагает все доступные рестарты как варианты выбора. Программист (или REPL) может, не перезапуская программу, выбрать рестарт, подставить значение, переопределить функцию и продолжить с места сбоя. Эта «отладка с продолжением» — прямое следствие модели «обработчик до раскрутки + рестарты». В языках с раскручивающими исключениями к моменту, когда вы видите ошибку, контекст уже мёртв, и продолжить нельзя — только перезапустить.

;; Стандартные рестарты доступны почти всегда, например continue/abort.
;; with-simple-restart — лёгкий способ объявить точку «пропустить и идти дальше»:
(defun process-all (items)
  (dolist (x items)
    (with-simple-restart (skip-item "Пропустить ~a" x)
      (process-one x))))     ; если внутри ошибка — можно выбрать skip-item

Условия — это не только ошибки

Важный нюанс: signal сигналит условие, но не обязательно ошибку. Если обработчика нет, signal просто возвращается (в отличие от error, который входит в отладчик). Это позволяет использовать условия как механизм уведомлений: предупреждения (warn), события, точки расширения. Код может «сообщить» о ситуации, а окружающий — решить, реагировать или нет. Так система условий становится не только обработкой ошибок, но и общим протоколом сигнализации между слоями программы. Это расширяет взгляд на «условия»: слово condition (условие, ситуация) выбрано вместо exception (исключение) намеренно — речь не только об «исключительных» сбоях, но о любых примечательных ситуациях, о которых один слой кода хочет уведомить другой, оставляя реакцию на усмотрение получателя.

Как работает под капотом

Обработчики и рестарты — это динамически устанавливаемые сущности: handler-bind и restart-case кладут на «динамический стек» соответственно обработчики (с типами условий) и рестарты (с именами и кодом). Когда сигналится условие, система не раскручивает управляющий стек, а проходит по списку активных обработчиков сверху вниз и вызывает подходящий по типу — в текущем контексте. Обработчик, если решит, ищет нужный рестарт в списке активных и через invoke-restart передаёт управление в тело этого рестарта; вот здесь и происходит раскрутка — но только до точки рестарта, а не до обработчика. handler-case реализован поверх этого: он ставит обработчик, который немедленно делает «выход» (нелокальный переход) к своей ветви — то есть раскручивает, эмулируя классический catch. Иными словами, исключения — частный случай более общей модели «обработчик + рестарт», где обработчик всегда выбирает «раскрутить до меня».

Частые ошибки

  • Путать handler-case и handler-bind. Первый раскручивает стек (как try/catch); второй вызывает обработчик на месте, позволяя выбрать рестарт и продолжить.
  • Объявлять рестарты, но ловить handler-case. Чтобы воспользоваться рестартом и продолжить, нужен handler-bind + invoke-restart; handler-case уже раскрутит стек и продолжать будет неоткуда.
  • Считать, что signal всегда останавливает программу. signal без обработчика просто возвращается; в отладчик вводит error. Для уведомлений это и нужно.
  • Использовать условия как обычный control flow. Сигнализация и рестарты — для исключительных/расширяемых ситуаций; обычные ветвления делайте if/cond.
  • Забыть :report. Без сообщения отладчик и логи покажут невнятное условие. Дайте :report своим условиям и рестартам.

Итоги

  • Система условий разделяет обнаружение ошибки и стратегию восстановления, не разрушая стек заранее.
  • handler-case — как try/catch (раскручивает стек); handler-bind вызывает обработчик на месте, до раскрутки.
  • Рестарты (restart-case) — объявленные внизу точки восстановления; обработчик сверху выбирает их через invoke-restart.
  • Это разделяет «механизм восстановления» (внизу) и «политику выбора» (наверху), позволяя переиспользовать стратегии.
  • Поскольку стек не раскручен, отладчик предлагает живые рестарты — отсюда интерактивная отладка с продолжением.
  • Условия — не только ошибки: signal без обработчика возвращается, что даёт общий протокол уведомлений.

Система условий — одна из тех черт Common Lisp, которые трудно оценить, пока не столкнёшься с ограничениями обычных исключений в реальной задаче. Освоив рестарты, вы получаете способ восстанавливаться из ошибок аккуратно и с сохранением прогресса — а заодно понимаете, почему отладка в Lisp ощущается такой живой и отзывчивой.

Проверьте себя
1. Чем система условий Common Lisp принципиально мощнее классических исключений?
AОна работает быстрее
BОбработчик вызывается до раскрутки стека и может выбрать объявленный внизу рестарт, продолжив выполнение с места ошибки
CОна не позволяет ловить ошибки
DУсловия — это просто строки с текстом ошибки
2. В чём разница handler-case и handler-bind?
AОни идентичны
Bhandler-case раскручивает стек до своей ветви (как try/catch), а handler-bind вызывает обработчик на месте, позволяя выбрать рестарт и продолжить без раскрутки
Chandler-bind раскручивает стек, а handler-case нет
Dhandler-bind работает только с предупреждениями