Система условий и рестарты: мощнее исключений
Система условий и рестарты: почему механизм Common Lisp мощнее исключений — он разделяет «где обнаружили ошибку» и «как её исправить», не разрушая стек.
Рестарт — это именованная точка восстановления, объявленная глубоко в коде; обработчик, находящийся выше по стеку, может выбрать рестарт, и выполнение продолжится с этой точки — без раскрутки стека до обработчика.
Зачем это: исключения теряют контекст
Привычные исключения (try/catch) устроены так: при ошибке стек раскручивается до ближайшего обработчика, и весь промежуточный контекст уничтожается. Обработчик узнаёт о проблеме, но уже не может вернуться туда, где она возникла, чтобы починить и продолжить — место ошибки исчезло. Это фундаментальное ограничение: «где обнаружили» и «как реагировать» жёстко связаны раскруткой. Система условий Common Lisp разрывает эту связь. Обработчик вызывается до раскрутки стека, пока контекст ошибки ещё жив, и может выбрать одну из заранее объявленных стратегий восстановления (рестартов), продолжив работу с глубокого места. Это делает CL-механизм заметно мощнее исключений — и объясняет, почему интерактивная отладка в Lisp так удобна.
Чтобы прочувствовать ограничение исключений, представьте реальный сценарий: программа парсит файл из миллиона строк, и на строке 999 999 встречается одно битое число. С обычными исключениями выброс ошибки раскрутит стек до верхнего try, уничтожив весь прогресс разбора, — и вы окажетесь наверху с сообщением «ошибка на строке 999999», но без возможности «подставить разумное значение и доработать последнюю строку». Придётся либо обернуть каждую строку в try/catch заранее (если вы предвидели это место), либо перезапускать весь разбор. Система условий же позволяет верхнему коду, узнав о проблеме, сказать «для этой строки используй ноль» — и разбор продолжится с того же места, не потеряв 999 998 уже обработанных строк. Это не теоретическое удобство, а качественно иной уровень управления ошибками: вы отделяете обнаружение проблемы (глубоко, где есть детали) от решения о реакции (высоко, где есть контекст политики), и при этом не теряете ни место, ни прогресс. Именно поэтому говорят, что в Common Lisp ошибки — это не «аварийный выход», а полноценный канал коммуникации между слоями программы.
Базовый слой: сигналы и обработка
Условие (condition) — это объект, описывающий ситуацию (часто, но не всегда, ошибку). Его «бросают» через signal/error, а ловят обработчиками. Простейший аналог try/catch — handler-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 ощущается такой живой и отзывчивой.