Последовательность вычислений: 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.
Проверьте себя
1. Что возвращает (prog1 (first stack) (incf counter))?
Aзначение (incf counter)
Bзначение (first stack) — первой формы
Cnil
Dсписок из обоих значений
2. Зачем оборачивать несколько top-level-определений в (progn ...), а не в произвольную форму?
Aprogn быстрее выполняется
Bтолько progn сохраняет статус форм верхнего уровня, чтобы каждое определение видело предыдущие при компиляции
Cprogn создаёт новую область видимости
Dэто единственный способ вернуть несколько значений