Локальные переменные: 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.