Специальные переменные: 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, а по выходе восстанавливается прежнее.
  • Это незаменимо для сквозных настроек (вывод, формат печати, состояние генератора), которые иначе пришлось бы протаскивать аргументом через все функции.
  • Под капотом работает стек связываний («сохранить — подменить — восстановить»); объявление помечает переменную как специальную, переключая её на динамическое связывание.
Проверьте себя
1. В чём разница между defparameter и defvar?
Adefparameter создаёт локальную переменную, а defvar — глобальную
Bdefparameter всегда устанавливает значение (даже при повторном определении), а defvar присваивает только если переменная ещё не существует
Cdefvar работает быстрее
Ddefparameter не требует звёздочек в имени, а defvar требует
2. Что увидит функция scaled внутри (let ((*scale* 100)) (scaled 10)), если она читает специальную переменную *scale*?
AПрежнее глобальное значение *scale*, потому что функция вне let
BНовое значение 100, потому что связывание специальной переменной через let динамическое и видно всему коду, вызванному за время действия let
CОшибку, потому что *scale* не передана аргументом
Dnil, потому что переменная затеняется
3. Зачем специальные переменные именуют в «ушах», как *config*?
AЭто требование компилятора, иначе будет ошибка
BЭто соглашение-сигнал: звёздочки предупреждают, что переменная специальная и имеет динамическое поведение при связывании через let
CЧтобы переменная работала быстрее
DЧтобы запретить её изменение