Лексическое и динамическое связывание
Завершаем курс самым фундаментальным вопросом семантики переменных, к которому исподволь подводили все предыдущие уроки.
Лексическое связывание: переменная видна там, где она записана в тексте программы (по области определения). Динамическое связывание: переменная видна всему коду, выполняемому во время действия её связывания (по ходу вызовов). Современный Lisp по умолчанию лексический, а динамику оставляет специальным переменным.
Этот урок — кульминация раздела и всего курса. Мы видели let с его лексическими локальными переменными, видели специальные переменные с динамическим поведением, видели замыкания, которые «помнят» окружение. Все эти явления — грани одного глубокого вопроса: что значит «переменная видна»? Ответов исторически два, и понимание разницы между ними — признак зрелого программиста, способного осознанно работать с любым языком, а не только с Lisp. Этот вопрос лежит в самом основании семантики языков программирования, и Lisp — едва ли не лучшее место, чтобы разобраться в нём раз и навсегда.
Два смысла слова «видна»
Когда функция обращается к свободной переменной (не своему параметру и не своей локальной), откуда берётся её значение? Есть два принципиально разных ответа. Лексический: смотрим, где функция написана в тексте, и берём ту переменную, что была видна в этом месте кода. Динамический: смотрим, кто и в каком окружении функцию вызвал, и берём то значение переменной, что действует в момент вызова. Разница проявляется, когда функция определена в одном месте, а вызывается в другом, где переменная с тем же именем имеет иное значение.
Проиллюстрируем умозрительно. Пусть функция f читает переменную x, но в тексте, где f определена, x равен 1. А вызывают f из места, где локально завели x равным 2. При лексическом связывании f увидит 1 (значение из места определения). При динамическом — 2 (значение из места вызова). Этот мысленный эксперимент — суть всей темы, и дальше мы увидим его в коде.
Лексическое связывание: по умолчанию в современном Lisp
Обычные переменные — параметры функций и переменные let — в современном Common Lisp и в Scheme связываются лексически. Это значит: функция видит те переменные, что были в области видимости там, где она определена, независимо от того, откуда её потом вызвали. Именно лексика делает возможными замыкания: лямбда захватывает переменные из текста, где она написана, и носит их с собой.
;; Лексическое связывание: free-x берёт x из места ОПРЕДЕЛЕНИЯ
(defun make-fn ()
(let ((x 1)) ; x в области, где определяется лямбда
(lambda () x))) ; лямбда лексически захватывает этот x
(defparameter fn (make-fn))
;; Даже если рядом с вызовом есть другой x — берётся захваченный:
(let ((x 999))
(funcall fn)) ; => 1 (НЕ 999! взят x из определения)
Результат 1, а не 999, — наглядное доказательство лексики. Лямбда захватила x из let, где была определена (значение 1), и значение x = 999 в месте вызова её не касается. Лексическое связывание делает функции предсказуемыми: чтобы понять, какие переменные видит функция, достаточно прочитать текст вокруг её определения, не отслеживая всю цепочку вызовов. Это огромное преимущество для понятности и сопровождения кода.
Динамическое связывание: для специальных переменных
Специальные переменные из прошлого урока связываются динамически. Здесь функция видит то значение, что действует в момент вызова, а не определения. Мы уже наблюдали это с *scale*: функция scaled, определённая снаружи, внутри let видела подменённое значение. Сопоставим оба поведения в одном примере, чтобы контраст был предельно ясен.
;; Динамическое связывание: спец. переменная берётся из места ВЫЗОВА
(defparameter *dyn* 1) ; специальная (в "ушах")
(defun read-dyn () *dyn*) ; читает специальную *dyn*
(read-dyn) ; => 1
;; Связывание через let динамическое — read-dyn видит значение из ВЫЗОВА:
(let ((*dyn* 999))
(read-dyn)) ; => 999 (взято значение из места вызова!)
;; Сравните с лексическим x выше, который дал 1, а не 999.
Вот он, ключевой контраст: лексическая x дала значение из определения (1), а динамическая *dyn* — значение из вызова (999). Один и тот же по форме код (let ((имя ...)) (вызов-функции)) ведёт себя противоположно в зависимости от того, лексическая переменная или специальная. Именно поэтому объявление переменной специальной через defparameter/defvar — это не мелочь: оно меняет саму семантику видимости.
Почему Lisp выбрал лексику — и роль Scheme
Это один из важнейших поворотов в истории языка. Ранние Lisp-системы 1960-х были динамическими по умолчанию — отчасти случайно, из-за простоты реализации интерпретатора. Но динамика по умолчанию порождала коварные баги: функция могла повести себя по-разному в зависимости от того, какие переменные «случайно» оказались связаны в месте вызова, и замыкания работали ненадёжно. Эту проблему даже прозвали «funarg problem» — проблемой функциональных аргументов.
Перелом совершил Scheme: созданный в 1975 году, он первым в семье Lisp сделал лексическое связывание основным и показал, насколько чище и мощнее становится язык — в частности, насколько надёжнее работают замыкания. Влияние оказалось огромным: когда в 1980-е разрабатывали Common Lisp, он перенял лексику по умолчанию, сохранив динамику лишь для явно объявленных специальных переменных. Так сложился сегодняшний компромисс: лексика по умолчанию (предсказуемость), динамика по запросу (гибкость сквозных настроек). А идея лексических замыканий, отшлифованная в Scheme, через Common Lisp и далее разошлась по JavaScript, Python, Ruby и почти всем современным языкам — сегодня «замыкание» означает именно лексическое замыкание, и в этом прямая заслуга Scheme.
Таблица-памятка и общая картина
Сведём различие в компактную таблицу, которая закрепляет всё сказанное и служит шпаргалкой на будущее:
| Свойство | Лексическое | Динамическое |
| Где видна переменная | в тексте, по определению | во время действия связывания, по вызову |
| Какие переменные | обычные (параметры, let) | специальные (defparameter/defvar) |
| Разрешение имени | статически, при компиляции | во время выполнения |
| Делает возможным | замыкания, предсказуемость | сквозные настройки по контексту |
| Имя по соглашению | обычное | *в-ушах* |
Эта таблица — концентрат раздела. Большинство переменных, которые вы пишете, — лексические, и это хорошо: они делают код локально понятным. Динамические переменные — специальный инструмент для редких, но важных случаев сквозной настройки. Зрелое владение Lisp состоит в том, чтобы по умолчанию мыслить лексически, а к динамике прибегать осознанно и помечать её «ушами». Этот баланс — итог десятилетий эволюции языка, и теперь вы понимаете, почему он именно таков.
Как это работает под капотом
Реализационно два вида связывания устроены по-разному, и это объясняет их поведение. Лексическая переменная разрешается статически, на этапе компиляции: компилятор по тексту программы точно знает, к какой именно переменной относится каждое имя, и превращает обращение в прямой доступ к конкретной ячейке (или захваченной переменной окружения для замыкания). Поэтому лексический доступ быстр и не зависит от хода выполнения. Динамическая переменная разрешается во время выполнения через ту самую ячейку символа и стек связываний из прошлого урока: читается текущее значение, которое мог подменить вышестоящий let. Отсюда «глубокая» видимость и зависимость от места вызова.
Этот разрыв — статическое разрешение против динамического — и есть техническая суть различия. Лексика «вшита» в скомпилированный код по тексту; динамика «спрашивает» актуальное значение в момент обращения. Соединяя нити курса: символ с его раздельными ячейками (раздел про символы), let с лексическими привязками (этот раздел), замыкания, захватывающие лексическое окружение (раздел про функции), и специальные переменные с динамическим стеком (прошлый урок) — всё это части единой и продуманной модели связывания имён, которая и делает Lisp таким цельным языком.
Частые ошибки
Первая ошибка — ожидать динамического поведения от обычной переменной. Если вы рассчитываете, что функция «подхватит» значение переменной из места вызова, но переменная не объявлена специальной, ничего не выйдет: обычные переменные лексические, и функция видит лишь то, что было в тексте её определения. Чтобы получить «сквозную» видимость, переменную нужно сделать специальной через defparameter/defvar.
Второе заблуждение — считать, что любая глобальная переменная динамическая. В Common Lisp динамическое поведение даёт именно объявление через defparameter/defvar (помечающее переменную специальной), а не сам факт «глобальности». Без такого объявления нельзя завести по-настоящему лексическую глобальную переменную обычными средствами — поэтому на практике все глобальные переменные объявляют специальными, но важно понимать, что это следствие объявления, а не магия «глобальности».
Третья ошибка — смешивать лексику и динамику в одном имени, забыв «уши». Если специальную переменную назвать без звёздочек, легко потом локально завести let с тем же именем, ожидая лексического затенения, а получить динамическое переопределение, влияющее на вызванные функции. Это коварнейший класс багов. Конвенция *имя* для специальных переменных существует именно затем, чтобы вы всегда знали, с каким видом связывания имеете дело, и не путали два мира.
Итоги
- Лексическое связывание: переменная видна там, где записана в тексте (по определению); динамическое: видна всему коду во время действия связывания (по вызову).
- Обычные переменные (параметры,
let) в современном Lisp лексические — функция видит переменные из места своего определения, что делает её предсказуемой и делает возможными замыкания. - Специальные переменные (
defparameter/defvar) динамические — функция видит значение из места вызова; один по формеletведёт себя противоположно для лексической и специальной переменной. - Ранние Lisp были динамическими; Scheme в 1975 сделал лексику основной, и Common Lisp перенял этот выбор — лексика по умолчанию, динамика по запросу.
- Под капотом: лексика разрешается статически при компиляции (быстро, по тексту), динамика — во время выполнения через стек связываний (по месту вызова); конвенция
*имя*предотвращает путаницу двух миров.