Лексическое и динамическое связывание

Завершаем курс самым фундаментальным вопросом семантики переменных, к которому исподволь подводили все предыдущие уроки.

Лексическое связывание: переменная видна там, где она записана в тексте программы (по области определения). Динамическое связывание: переменная видна всему коду, выполняемому во время действия её связывания (по ходу вызовов). Современный 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 перенял этот выбор — лексика по умолчанию, динамика по запросу.
  • Под капотом: лексика разрешается статически при компиляции (быстро, по тексту), динамика — во время выполнения через стек связываний (по месту вызова); конвенция *имя* предотвращает путаницу двух миров.
Проверьте себя
1. В чём ключевое различие лексического и динамического связывания?
AЛексическое быстрее, динамическое медленнее, но ведут себя одинаково
BПри лексическом переменная видна там, где функция записана в тексте (по определению), а при динамическом — всему коду, выполняемому во время действия связывания (по месту вызова)
CЛексическое только для чисел, динамическое только для строк
DЛексическое связывание существует лишь в Scheme, а динамическое — лишь в Common Lisp
2. Почему лямбда из (let ((x 1)) (lambda () x)) вернёт 1, даже если её вызвать внутри (let ((x 999)) ...)?
AПотому что 999 слишком большое число
BПотому что обычные переменные лексические: лямбда захватывает x из места своего определения (где x=1), и значение x в месте вызова её не касается
CПотому что let всегда обнуляет переменные
DПотому что funcall игнорирует аргументы
3. Какова роль Scheme в истории связывания в Lisp?
AScheme вернул Lisp к динамическому связыванию
BScheme в 1975 первым в семье Lisp сделал лексическое связывание основным, показав надёжность замыканий; этот выбор перенял Common Lisp и затем почти все современные языки
CScheme отменил замыкания как ненадёжные
DScheme не повлиял на связывание, это сделал Fortran