Последовательность вычислений: progn, prog1 и неявные тела
progn, prog1, prog2 и неявные тела: как Lisp выстраивает последовательность вычислений и возвращает значение.
progn — особая форма, которая вычисляет свои формы по порядку слева направо и возвращает значение последней; это базовый способ «склеить» несколько выражений в одно.
Зачем это: последовательность в языке выражений
Lisp — язык выражений: почти всё возвращает значение. Но программам нужна и последовательность действий с побочными эффектами — вывести, изменить переменную, снова вывести. Как выразить «сделай A, потом B, потом C» там, где синтаксически ожидается одно выражение? Ответ — progn. Он берёт произвольное число форм, выполняет их по порядку и отдаёт значение последней. Имя историческое: «programme N» — последовательность из N шагов. В современном коде вы редко пишете progn руками, потому что десятки конструкций содержат неявный progn в теле, но понимать его необходимо: это атом, из которого собрана вся последовательная семантика языка.
(progn
(format t "шаг 1~%")
(format t "шаг 2~%")
(+ 2 2)) ; => печатает две строки, ВОЗВРАЩАЕТ 4
Сравните с тем, как это устроено в императивных языках: там точка с запятой между операторами — встроенный синтаксис, и «значение последнего оператора» обычно не существует как понятие. В Lisp последовательность — это обычная форма progn, такой же гражданин языка, как и любой вызов функции. Её можно подставить в аргумент, в ветвь if, куда угодно.
Зачем вообще нужна последовательность в языке, который тяготеет к чистым выражениям? Ответ честный: реальные программы взаимодействуют с миром — печатают, читают, меняют состояние, открывают файлы. Чистое вычисление без эффектов не напишет ни одной полезной программы целиком. Lisp не запрещает эффекты (в отличие от чисто функциональных языков вроде Haskell), а даёт им явную форму: progn и его неявные варианты — это места, где «делается что-то по порядку». Поэтому понимание progn — это понимание того, где в вашем коде живут побочные эффекты и в каком порядке они происходят. Это особенно важно при отладке: если две печати вышли «не в том порядке», почти всегда дело в том, как устроена последовательность форм.
Где живёт неявный progn
Это, пожалуй, самый практически важный факт урока, и он экономит массу скобок и недоумения. Когда новичок приходит из языка с фигурными скобками, он часто пытается «обернуть блок» внутри defun или let — и не находит, во что. Ответ: оборачивать не нужно, тело уже является последовательностью. Понимание, какие конструкции дают неявный progn, превращает чтение чужого кода в спокойное занятие: вы сразу видите, где «несколько действий по порядку», а где «одно выражение». Перечислим главные носители неявного progn:
(defun f (x) ; тело defun — неявный progn
(log-call x)
(* x x)) ; вернётся это
(let ((a 1) (b 2)) ; тело let — неявный progn
(incf a)
(+ a b))
(when cond ; тело when/unless — неявный progn
(step-1)
(step-2))
(dolist (x list) ; тело dolist/dotimes — неявный progn
(prepare x)
(use x))
(lambda (x) ; тело lambda — неявный progn
(validate x)
(transform x))
Именно поэтому вы почти никогда не пишете progn внутри defun, let, when, dolist, lambda — там он уже встроен. Явный progn нужен лишь там, где синтаксис допускает ровно одну форму: классический случай — единственная ветвь if, в которой надо выполнить несколько действий. Но даже там идиоматичнее when/unless.
prog1 и prog2 — когда нужно вернуть не последнее
Иногда последовательность действий важна, но вернуть надо первое значение, а не последнее. Тогда берут prog1: он вычисляет все формы по порядку, но возвращает значение первой. Это идеально для паттерна «сохрани результат, сделай уборку, верни сохранённое».
(defun pop-and-log (stack-var)
;; вернуть верхушку, но сначала залогировать снятие
(prog1 (first stack-var) ; это значение и вернётся
(format t "снимаю ~a~%" (first stack-var))))
;; Классика: «текущее значение, потом увеличить счётчик»
(let ((counter 0))
(defun next-id ()
(prog1 counter ; вернуть текущее
(incf counter)))) ; затем увеличить
Есть и prog2 — вычисляет все формы, возвращает значение второй. На практике он редок; его нишевый сценарий — «сделай настройку, выполни главное, прибери», где главное стоит вторым. Но в современном коде вместо prog2 чаще пишут явный let с временной переменной — это читается яснее. Запоминать стоит прежде всего prog1: паттерн «вернуть старое значение и тут же изменить» встречается постоянно.
Чтобы закрепить разницу трёх форм, держите в голове простую картину. Все три — progn, prog1, prog2 — выполняют все свои формы по порядку ради эффектов; различаются они лишь тем, чьё значение отдают наружу: progn — последней, prog1 — первой, prog2 — второй. То есть «работа» у них одинаковая, отличается только «что вернуть». Это типичный для Lisp подход — дать набор близких примитивов под чуть разные нужды, вместо одного «универсального» с флагами. Зная это, вы выбираете форму по тому, какое из промежуточных значений вам нужно как результат, не задумываясь о порядке выполнения — он всегда строго слева направо.
Множественные значения и progn
Тонкость, которую стоит знать сразу: в Common Lisp форма может вернуть несколько значений сразу (multiple values) — это не кортеж и не список, а особый механизм языка. Например, (floor 7 2) возвращает два значения: частное 3 и остаток 1. Так вот, progn «прозрачен» для множественных значений последней формы: если последняя форма вернула несколько значений, progn вернёт их все. А вот prog1 возвращает только первое значение своей первой формы — он намеренно «схлопывает» множественность. Это редко всплывает, но объясняет тонкие баги, когда «потерялось второе значение».
(progn (+ 1 1) (floor 7 2)) ; => 3 и 1 (ДВА значения, как у floor)
(prog1 (floor 7 2) :ignored) ; => 3 (только ПЕРВОЕ значение floor)
;; принять оба значения помогает multiple-value-bind:
(multiple-value-bind (q r) (floor 7 2)
(format t "частное ~a, остаток ~a~%" q r)) ; частное 3, остаток 1
Это поучительный пример того, как аккуратно спроектирована семантика: progn не «теряет» множественные значения, потому что его задача — лишь упорядочить вычисление и отдать результат последней формы как есть. Большинство языков такой проблемы не имеют просто потому, что в них нет множественных значений; в Lisp они есть, и понимать их распространение через формы важно.
progn на верхнем уровне и для макросов
У progn есть особая роль на «верхнем уровне» файла (top level). Когда вы компилируете файл, формы верхнего уровня обрабатываются по очереди, и некоторые из них (например, defmacro, defstruct) влияют на компиляцию последующих. Если вы оборачиваете несколько определений в (progn ...) на верхнем уровне, Common Lisp гарантирует, что они по-прежнему считаются формами верхнего уровня (это свойство называется «top-level-ness» и сохраняется только для progn, но не для произвольной формы). Это критично для макросов, которые генерируют сразу несколько определений: они разворачиваются в (progn (defclass ...) (defmethod ...) ...), и каждое из определений видит предыдущие. Это тонкая, но важная причина, почему progn — не просто «точка с запятой», а часть модели компиляции.
Контраст со Scheme: begin
В Scheme аналог progn называется begin. Семантика та же: вычислить формы по порядку, вернуть последнюю. Различие в деталях окружающей экосистемы. В Scheme тела lambda, let, define тоже содержат неявный begin, так что писать его руками тоже приходится редко. Прямых аналогов prog1/prog2 в стандартном Scheme нет — там для «вернуть первое» пишут let с временной переменной. Это в духе Scheme: меньше специальных форм, больше комбинаций из небольшого набора примитивов.
;; Scheme (R7RS): begin вместо progn
(begin
(display "шаг 1") (newline)
(display "шаг 2") (newline)
(+ 2 2)) ; => 4
;; «вернуть первое» в Scheme — через let
(let ((top (car stack)))
(display "снимаю ") (display top) (newline)
top)
Как работает под капотом
Модель проста и предсказуема: progn заводит «контекст последовательного вычисления». Каждая форма вычисляется полностью (включая её побочные эффекты) до начала следующей — это строго левонаправленный порядок, гарантированный стандартом. Промежуточные значения форм, кроме последней (для progn) или первой (для prog1), отбрасываются. Компилятор, видя progn, генерирует код последовательного исполнения и часто умеет выкинуть формы без побочных эффектов в середине (например, лишний литерал) — но на формы с эффектами он, конечно, не покушается. Важно: progn не создаёт новой области видимости переменных (в отличие от let), он чисто про порядок. Поэтому если вам нужны временные привязки, это let, а не progn. Эта чёткая специализация — «progn отвечает за порядок, let за область видимости» — типична для дизайна Lisp: каждая форма делает одну вещь и делает её хорошо, а сложное поведение собирается их композицией.
Частые ошибки
- Лишний
prognтам, где он уже неявный. Писать(defun f (x) (progn ...))— избыточно: телоdefunуже последовательность. - Ждать, что
prognвернёт все значения. Он возвращает только последнее (аprog1— только первое). Остальные значения теряются — это нормально, на них и рассчитывают только ради эффектов. - Путать
prognиlet.prognне вводит переменных. Если вы пишете(progn (x 1) ...)в надежде «объявить x» — это не сработает; нужноlet. - Забыть про top-level-ness. Если макрос должен сгенерировать несколько top-level-определений, оборачивайте их именно в
progn— только он сохраняет статус форм верхнего уровня. - Брать
prog2ради читаемости. Обычноletс именованной временной переменной понятнее, чем «вернуть вторую форму».prog1же оправдан паттерном «верни старое, измени».
Итоги
progn— последовательное вычисление форм; возвращает значение последней.- Тела
defun,let,when,lambda,dolistи многих других — неявныйprogn. prog1возвращает первую форму (паттерн «верни старое и измени»),prog2— вторую.- На верхнем уровне
prognсохраняет статус форм верхнего уровня — это нужно макросам, генерирующим много определений. - В Scheme роль
prognиграетbegin;prog1там заменяют наlet. progn— про порядок, а не про область видимости; для переменных нуженlet.