Локальные переменные: let и let*

Учимся заводить локальные переменные двумя способами и понимать тонкую, но важную разницу между ними.

let создаёт локальные переменные, вычисляя все начальные значения параллельно (до установления привязок). let* делает то же, но последовательно: каждая следующая переменная видит уже связанные предыдущие. Обе ограничивают видимость переменных своим телом.

Мы много раз использовали let по ходу курса, не разбирая его подробно. В завершающем разделе, посвящённом связыванию имён со значениями, самое время сделать это обстоятельно. let и его вариант let* — основные инструменты создания локальных переменных, и различие между ними, хоть и кажется мелким, регулярно становится источником ошибок. Локальные переменные — это фундамент структурированного кода: они позволяют дать промежуточным результатам осмысленные имена, разбить сложное выражение на понятные шаги и ограничить область, в которой имя что-то значит. Без них пришлось бы либо повторять одни и те же подвыражения, либо нагромождать всё в одну строку, поэтому уверенное владение let — необходимая база для чтения и написания любого Lisp-кода.

let: параллельное связывание

Форма let вводит локальные переменные, видимые только внутри её тела. Синтаксис: список привязок (каждая — пара «имя начальное-значение») и тело. Ключевая особенность let: все начальные значения вычисляются до того, как создаётся хоть одна привязка. То есть в выражениях начальных значений переменные этого же let ещё не видны — они «возникают» все разом, одновременно, уже после вычисления всех правых частей.

;; let: переменные видны только в теле, значения считаются параллельно
(let ((x 10)
      (y 20))
  (+ x y))           ; => 30

;; Вне let переменных уже нет:
;; x   ; ОШИБКА: переменная x не определена

;; Тело let — неявный progn (несколько выражений):
(let ((a 5))
  (print a)          ; печатает 5
  (* a a))           ; => 25  (значение последнего выражения)

Параллельность связывания — не формальность, а строгая семантика. Когда вы пишете (let ((x 10) (y 20)) ...), сначала вычисляются оба значения, 10 и 20, в окружении без x и y, и лишь затем обе переменные становятся доступны в теле. Это значит, что в начальном значении y нельзя сослаться на x из того же let — его ещё нет. Тело let, как и тело функции, — неявный progn: выполняется по порядку, возвращается значение последнего выражения.

У параллельности есть и неочевидное практическое применение — обмен значений без временной переменной. Поскольку правые части вычисляются до установления привязок, можно записать (let ((a b) (b a)) ...), и обе переменные получат «старые» значения друг друга, ведь к моменту вычисления b и a справа новые a и b ещё не существуют. Это редкая, но красивая идиома, наглядно демонстрирующая, что значит «параллельное связывание»: все правые части видят мир до этого let, а не его собственные привязки. Если бы let связывал последовательно, такой обмен сломался бы — вторая переменная подхватила бы уже изменённую первую.

let*: последовательное связывание

Иногда как раз нужно, чтобы одна локальная переменная зависела от другой. Для этого есть let* (читается «лет-стар»). Он устроен так же, но связывает переменные последовательно, сверху вниз: к моменту вычисления начального значения очередной переменной все предыдущие уже связаны и видны. По сути let* эквивалентен вложенным let — каждая привязка попадает в область следующей.

;; let*: каждая переменная видит уже связанные предыдущие
(let* ((x 10)
       (y (* x 2))     ; здесь x уже доступен — 10
       (z (+ x y)))    ; здесь доступны и x, и y
  (list x y z))        ; => (10 20 30)

;; С обычным let это НЕ сработало бы:
;; (let ((x 10) (y (* x 2))) ...)  ; ОШИБКА: x ещё не виден в (* x 2)

Разница наглядна: в let* можно строить «цепочку» вычислений, где каждый шаг опирается на предыдущий. Это очень частая потребность — например, когда промежуточные результаты последовательно уточняются. С обычным let пришлось бы вкладывать несколько let друг в друга, что let* и делает за вас в более читаемой форме.

Когда какой выбирать

Правило простое. Если начальные значения независимы друг от друга — используйте let: он точнее выражает намерение («вот несколько независимых локальных переменных») и теоретически допускает параллельное вычисление значений. Если же значения образуют цепочку зависимостей — нужен let*. Многие по привычке всегда пишут let* «на всякий случай», но это считается дурным тоном: let сообщает читателю важную информацию — что привязки независимы, — и потеря этой информации затрудняет понимание кода.

ФормаСвязываниеВидят ли переменные друг друга
letпараллельноенет (только в теле)
let*последовательноеда, каждая видит предыдущие

Заметим, что у let и let* одинаковая стоимость и поведение в теле — различие исключительно в том, видны ли привязки в начальных значениях друг друга. Во всём остальном они идентичны: обе ограничивают область видимости телом и обе суть неявный progn.

Родственники let: связывание не только переменных

Полезно увидеть let в семье родственных форм, чтобы понимать его место. Подобно тому как let вводит локальные переменные, в Common Lisp есть формы, вводящие локальные функции: flet определяет локальные функции, не видящие друг друга (аналог let), а labels — локальные функции, которые видят друг друга и себя, что нужно для локальной рекурсии (аналог let* и даже мощнее). Это последовательное проведение одной идеи: связать имя со значением в ограниченной области, будь то значение-данные или значение-функция.

;; flet — локальные функции (как let для переменных):
(flet ((double (x) (* x 2))
       (triple (x) (* x 3)))
  (+ (double 5) (triple 5)))     ; => 25

;; labels — локальные функции, видящие себя (для рекурсии):
(labels ((fact (n)
           (if (<= n 1) 1 (* n (fact (- n 1))))))   ; fact видит саму себя
  (fact 5))                      ; => 120

Замечать эту симметрию важно: let/let* для переменных и flet/labels для функций — это один и тот же принцип локального связывания, применённый к двум пространствам имён Lisp-2 (помните раздел про функции?). Именно потому, что функции и переменные живут в разных пространствах, для них и нужны разные связывающие формы. В Scheme же, где пространство одно, локальные функции вводятся тем же let, что и переменные, — ещё одно следствие различия Lisp-1 и Lisp-2, прошедшего через весь курс.

Область видимости и затенение

Переменные, введённые let, видны только в его теле — это лексическая область видимости (детально мы разберём её в последнем уроке курса). Важное следствие — затенение (shadowing): если внутри let завести переменную с тем же именем, что и внешняя, внутри тела «видна» именно внутренняя, а внешняя временно скрыта. После выхода из let внешняя переменная возвращается без изменений — внутренний let её не трогал, а лишь заслонял.

;; Затенение: внутренний x скрывает внешний внутри тела let
(defparameter x 100)

(defun demo ()
  (let ((x 5))         ; локальный x затеняет глобальный
    (* x x)))          ; здесь x = 5

(demo)   ; => 25
x        ; => 100   (глобальный x НЕ изменился — его лишь заслоняли)

Затенение — нормальный и полезный механизм: он позволяет свободно использовать удобные имена локально, не боясь задеть что-то снаружи. Но он же — источник путаницы, если случайно затенить переменную, которую вы намеревались использовать. Понимание, что let создаёт новую переменную, а не изменяет существующую одноимённую, снимает целый класс недоумений.

Как это работает под капотом

На уровне реализации let создаёт новые лексические привязки — записи «имя → ячейка со значением», видимые только в пределах текста тела. Компилятор знает эти привязки статически, по тексту программы, и обращения к локальной переменной превращаются в прямой доступ к её ячейке (часто — к регистру или слоту на стеке), что очень быстро. Параллельность let означает, что компилятор вычисляет все правые части в окружении до добавления новых привязок, а затем разом устанавливает их; let* же добавляет привязки по одной, расширяя окружение перед каждым следующим начальным значением.

Именно лексическая природа этих привязок делает их основой для замыканий, которые мы изучали в прошлом разделе. Когда лямбда создаётся внутри let, она захватывает именно эти лексические переменные — вот почему счётчик из урока про замыкания заводил приватную count через let. Так связываются воедино темы курса: локальные переменные let, лексическая видимость и замыкания — это три грани одного механизма. Различие let/let*, кстати, есть и в Scheme в точно таком же виде, так что здесь диалекты согласны между собой.

Частые ошибки

Первая ошибка — сослаться в let на переменную из того же let. Запись (let ((x 10) (y (* x 2))) ...) не сработает: в момент вычисления (* x 2) переменная x ещё не связана (параллельность!). Нужен let*. Это, пожалуй, самая частая ошибка при работе с локальными переменными, и сообщение об ошибке («переменная x не определена») поначалу сбивает с толку, ведь x вроде бы рядом.

Вторая ошибка — ожидать, что let изменит внешнюю переменную. let создаёт новую локальную переменную; если её имя совпадает с внешней, внешняя лишь затеняется, а не меняется. Чтобы изменить существующую переменную, нужен setf, а не let. Путаница «завёл новую вместо изменения старой» приводит к тому, что ожидаемые изменения «не сохраняются».

Третья ошибка — злоупотреблять let* по умолчанию. Хотя let* «всегда работает», поголовное его использование скрывает от читателя информацию о независимости привязок и может замаскировать случайные зависимости. Хороший стиль — использовать let, пока зависимости реально не понадобятся, и переходить на let* осознанно. Это делает код самодокументируемым: сам выбор формы сообщает, связаны привязки или нет.

Итоги

  • let вводит локальные переменные с параллельным связыванием: все начальные значения вычисляются до установления привязок, поэтому переменные не видят друг друга в правых частях.
  • let* связывает последовательно: каждая следующая переменная видит уже связанные предыдущие — нужен для цепочек зависимостей.
  • Обе формы ограничивают видимость переменных своим телом (лексическая область) и являются неявным progn.
  • Затенение: локальная переменная с именем внешней скрывает внешнюю внутри тела, не изменяя её; let создаёт новую переменную, а не меняет существующую.
  • Выбирайте let для независимых привязок (это сообщает об их независимости), let* — для зависимых; различие let/let* одинаково в Common Lisp и Scheme.
Проверьте себя
1. В чём разница между let и let* в Lisp?
Alet работает быстрее let*
Blet связывает переменные параллельно (начальные значения не видят друг друга), а let* — последовательно (каждая переменная видит уже связанные предыдущие)
Clet* допускает только одну переменную
Dlet создаёт глобальные переменные, а let* — локальные
2. Почему (let ((x 10) (y (* x 2))) ...) приводит к ошибке?
AПотому что let не поддерживает арифметику
BПотому что в let связывание параллельное: при вычислении (* x 2) переменная x ещё не связана. Нужен let*, где x уже виден
CПотому что переменных в let не может быть больше одной
DПотому что y должна идти перед x
3. Что произойдёт с глобальной переменной x после выхода из (let ((x 5)) ...), где она затеняется?
AГлобальная x навсегда станет равна 5
BГлобальная x останется неизменной — let создал новую локальную переменную, лишь временно заслонив внешнюю внутри тела
CГлобальная x будет удалена
DВозникнет ошибка конфликта имён