Специальные переменные: defparameter и defvar
Разбираемся с глобальными переменными Lisp — и обнаруживаем, что они работают совсем не так, как глобальные переменные в других языках.
Специальная (динамическая) переменная объявляется через
defparameterилиdefvarи отличается особым поведением: её можно временно переопределить черезlet, и это переопределение увидит весь код, вызванный за время действияlet. По соглашению такие переменные именуют в «ушах»:*имя*.
Завершая раздел о связывании, мы подходим к самой необычной его части — глобальным переменным Common Lisp. Слово «глобальная» здесь обманчиво: эти переменные ведут себя принципиально иначе, чем привычные глобальные переменные, и эта особенность — динамическое связывание — одна из визитных карточек языка. Сначала освоим, как их объявлять, а в следующем уроке свяжем это с большой темой лексического и динамического связывания.
defparameter: объявить специальную переменную
Основной способ завести глобальную специальную переменную — defparameter. Он создаёт переменную и присваивает ей значение. Главное соглашение, которое нужно соблюдать всегда: имена специальных переменных оформляют в «ушах» — звёздочках с двух сторон: *counter*, *config*, *debug*. Это не требование компилятора, а железная конвенция: увидев *имя*, любой лиспер сразу понимает, что перед ним специальная переменная с особым поведением.
;; defparameter объявляет специальную переменную (в "ушах" *...*):
(defparameter *radius* 10)
(defparameter *pi* 3.14159)
*radius* ; => 10
(* *pi* *radius* *radius*) ; => 314.159
;; defparameter ВСЕГДА переустанавливает значение при повторном выполнении:
(defparameter *radius* 25)
*radius* ; => 25 (перезаписалось)
Особенность defparameter в том, что он всегда присваивает значение, даже если переменная уже существовала. Это удобно при разработке: переоценили форму с defparameter в REPL — и переменная гарантированно получила новое значение. defparameter как бы говорит: «это значение я хочу установить сейчас и переустанавливать впредь».
defvar: объявить с защитой от перезаписи
Второй способ — defvar. Он отличается ровно одним: присваивает значение только если переменная ещё не существует. Если переменная уже была определена, повторный defvar её значение не трогает. Это нужно для данных, которые должны пережить перезагрузку кода: накопленное состояние, кэш, счётчик, который не хочется обнулять при каждой переоценке файла.
;; defvar присваивает только при ПЕРВОМ определении:
(defvar *session-count* 0)
*session-count* ; => 0
;; Повторный defvar НЕ перезаписывает существующее значение:
(setf *session-count* 42) ; изменили значение
(defvar *session-count* 0) ; повторное объявление...
*session-count* ; => 42 (НЕ сбросилось в 0!)
Разница принципиальна для практики. Используйте defparameter для констант и настроек, которые при перезагрузке кода должны обновляться (текущая конфигурация, параметры алгоритма). Используйте defvar для состояния, которое нужно сохранять между перезагрузками (база данных в памяти, накопленные результаты, инициализируемое один раз). Правило-памятка: defparameter = «всегда устанавливай», defvar = «установи однажды и не трогай».
Динамическое связывание: главная особенность
Вот что делает специальные переменные по-настоящему особенными. Если связать специальную переменную через let, происходит не затенение (как с обычными локальными переменными), а динамическое переопределение: новое значение видит весь код, вызванный за время действия let, включая функции, вызванные косвенно. По выходе из let восстанавливается прежнее значение. Это совершенно не похоже на поведение обычных переменных.
;; Специальная переменная, переопределяемая через let:
(defparameter *scale* 1)
(defun scaled (x)
(* x *scale*)) ; функция читает глобальную *scale*
(scaled 10) ; => 10 (*scale* = 1)
;; Временно меняем *scale* через let — и scaled это ВИДИТ:
(let ((*scale* 100))
(scaled 10)) ; => 1000 (внутри let *scale* = 100!)
(scaled 10) ; => 10 (после let вернулось 1)
Вдумайтесь в чудо: функция scaled нигде не получает *scale* как аргумент, но внутри let видит его новое значение 100, а снаружи — прежнее 1. Связывание через let временно «подменило» значение для всего, что выполняется в динамической протяжённости этого let. Это и есть динамическое связывание — поведение, доступное в Common Lisp только для специальных переменных. Обычная локальная переменная так бы не сработала: её увидело бы лишь тело let, но не вызванная оттуда функция.
Зачем это нужно: настройки «по контексту»
Динамические переменные незаменимы для параметров, которые должны действовать «вглубь» вызовов, не таскаясь явным аргументом через каждую функцию. Классические примеры из самого языка: *standard-output* (куда идёт печать), *print-base* (в какой системе счисления печатать числа), *random-state* (состояние генератора случайных чисел). Временно связав такую переменную через let, вы меняете поведение всего вложенного кода, а по выходе всё возвращается.
;; Временно перенаправить или настроить вывод через специальную переменную:
(defparameter *log-prefix* "INFO")
(defun log-msg (text)
(format nil "[~a] ~a" *log-prefix* text))
(log-msg "старт") ; => "[INFO] старт"
;; В блоке отладки временно меняем префикс для ВСЕХ вложенных вызовов:
(let ((*log-prefix* "DEBUG"))
(log-msg "детали")) ; => "[DEBUG] детали"
(log-msg "стоп") ; => "[INFO] стоп" (префикс восстановлен)
Без динамических переменных пришлось бы добавлять параметр «префикс» в log-msg и во все функции, которые её вызывают, протаскивая его через всю цепочку, — громоздко и навязчиво. Динамическая переменная позволяет задать настройку в одном месте (в let) и сделать её доступной всему вложенному коду, не меняя сигнатур. Это мощный, хотя и требующий аккуратности инструмент.
Сравнение с глобальными переменными других языков
Чтобы оценить своеобразие специальных переменных, сравним их с обычными глобальными переменными из, скажем, C, Python или JavaScript. Там глобальная переменная — это просто общая ячейка: кто угодно её читает и пишет, и если вы хотите «временно» изменить её, а потом вернуть, приходится вручную сохранять старое значение, менять, а в конце восстанавливать (часто — в блоке try/finally, чтобы восстановить даже при ошибке). Это многословно и легко забыть восстановление. Динамическая переменная Lisp встраивает этот паттерн прямо в язык: let гарантированно восстанавливает прежнее значение по выходе из блока, даже если внутри произошла ошибка или нелокальный выход.
Таким образом, специальная переменная — это не «глобальная переменная похуже», а «глобальная переменная получше»: она даёт удобный, безопасный, автоматически откатываемый способ временно менять контекст. Именно поэтому стандартная библиотека Lisp так широко использует динамические переменные для настроек ввода-вывода, печати и случайных чисел — это идиоматичный и безопасный способ управлять поведением «вглубь» без протаскивания параметров. Понимание этого преимущества помогает применять динамические переменные по назначению, а не бояться их как «вредных глобальных».
Уместно и предостережение про многопоточность, поскольку оно объясняет современную практику. В типичных реализациях вроде SBCL динамическое связывание через let действует только в текущем потоке: каждый поток имеет собственный стек связываний, поэтому временное переопределение в одном потоке не задевает другие. Это, как правило, именно то, что нужно, и делает динамические переменные удобным инструментом для «контекста потока». Но глобальное значение по умолчанию (вне всякого let) разделяется потоками, так что менять его через setf из разных потоков без синхронизации — опасно. Идиоматичный путь — менять динамические переменные через let, получая потокобезопасное локальное переопределение, а не глобальную гонку.
Как это работает под капотом
Механизм опирается на устройство символа, знакомое нам по прошлым разделам. У специальной переменной значение хранится в ячейке значения символа, но динамическое связывание реализуется через стек связываний. Когда let связывает специальную переменную, прежнее значение сохраняется в стеке, а в ячейку записывается новое; по выходе из let старое значение восстанавливается из стека. Поскольку любой код во время действия let читает то же самое (текущее) значение из ячейки символа, он видит подменённое значение — отсюда «глубокая» видимость. Эту дисциплину «сохранить — подменить — восстановить» иногда называют «динамической протяжённостью».
Контраст с обычными лексическими переменными (из урока про let) предельный: лексическая переменная видна только в тексте своего тела, а специальная — всему коду, выполняемому во времени действия её связывания. Именно поэтому объявление через defparameter/defvar так важно: оно помечает переменную как специальную, переключая её на динамическое связывание. Эту фундаментальную развилку — лексическое против динамического — мы детально разберём в финальном уроке курса; здесь же главное — увидеть, что специальные переменные ведут себя «вглубь», а не «по тексту».
Частые ошибки
Первая ошибка — не оформлять специальные переменные в «ушах». Если объявить через defparameter переменную с обычным именем вроде counter (без звёздочек), вы получите специальную переменную, выглядящую как обычная, — и это коварно: любое локальное связывание counter через let внезапно станет динамическим, что собьёт с толку. Конвенция *имя* — это сигнал «осторожно, динамика», и нарушать её опасно.
Вторая ошибка — путать defparameter и defvar по эффекту при перезагрузке. Объявив накопленное состояние через defparameter, вы будете терять его при каждой переоценке файла (оно сбрасывается). А объявив изменяемую настройку через defvar, вы не сможете обновить её повторным определением (оно игнорируется). Выбор должен соответствовать намерению: обновляемое — defparameter, сохраняемое — defvar.
Третья ошибка — злоупотреблять динамическими переменными как обычной глобальной мутабельной свалкой. Их сила — во временном переопределении по контексту через let; использовать их просто как «глобальные переменные, которые все читают и пишут когда попало» — значит получить все классические проблемы глобального изменяемого состояния. Хороший стиль — применять динамические переменные осознанно, для сквозных настроек, и менять их предпочтительно через let, а не беспорядочным setf из разных мест.
Итоги
defparameterобъявляет специальную переменную и всегда устанавливает значение;defvarприсваивает только при первом определении (для состояния, переживающего перезагрузку).- Специальные переменные по соглашению именуют в «ушах»:
*имя*— это сигнал об их особом поведении. - Связывание специальной переменной через
letдаёт динамическое переопределение: новое значение видит весь код, вызванный за время действияlet, а по выходе восстанавливается прежнее. - Это незаменимо для сквозных настроек (вывод, формат печати, состояние генератора), которые иначе пришлось бы протаскивать аргументом через все функции.
- Под капотом работает стек связываний («сохранить — подменить — восстановить»); объявление помечает переменную как специальную, переключая её на динамическое связывание.