Ветвление: if, when, unless, cond и case

Как в Lisp принимают решения: if, when, unless, cond и case — и почему «всё есть выражение».

Управляющая конструкция в Lisp — это особая форма, которая сама решает, какие из её аргументов вычислять, и всегда возвращает значение, а не просто «делает что-то».

Зачем это: ветвление как значение, а не как команда

В большинстве привычных языков if — это оператор: он управляет потоком, но сам ничего не «стоит». Вы пишете if (x > 0) { ... } else { ... }, и тело — это набор команд-побочных-эффектов. В Lisp всё иначе. Здесь if — это выражение, которое возвращает значение. Можно написать (let ((sign (if (> x 0) "плюс" "минус"))) ...) — результат ветвления прямо присваивается переменной. Эта идея «всё есть выражение» пронизывает язык: и cond, и case, и when, и даже циклы возвращают значения. Понимание этого факта меняет стиль кода: вы перестаёте мыслить «сначала проверь, потом присвой» и начинаете мыслить «вычисли значение, которое зависит от условия».

Почему это важно практически? Потому что выражение можно вложить куда угодно: в аргумент функции, в элемент списка, в тело let. Код становится плотнее и честнее: вы видите, откуда берётся значение, а не охотитесь за присваиваниями по всему телу функции. Это прямое следствие того, что Lisp вырос из лямбда-исчисления, где вычисление — это редукция выражений к значению, а не последовательность изменений состояния.

Сделаем важную оговорку про терминологию, которая будет встречаться весь курс. В Lisp выражения принято называть формами (forms). Форма — это то, что можно вычислить: число, символ, строка или список вида (оператор аргументы...). Когда мы говорим «if вычисляет одну из форм», мы имеем в виду именно это: ветви if — это формы, и вычисляется ровно одна. Различают особые формы (special forms, их фиксированный набор и они вычисляют аргументы по своим правилам), макровызовы (раскрываются в другие формы) и вызовы функций (аргументы вычисляются все). Это деление — каркас модели исполнения, и держать его в голове полезно с самого начала: оно объясняет, почему одни конструкции «магические» (управляют вычислением), а другие — обычные.

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

if — минимальная развилка на два пути

Базовая форма — (if тест то-что-если-истина то-что-если-ложь). Тест вычисляется; если он не nil — вычисляется и возвращается вторая форма, иначе — третья. Ключевой момент семантики: if вычисляет ровно одну из двух ветвей, никогда обе. Это и делает её «особой формой» (special operator), а не обычной функцией: функция в Lisp вычисляет все свои аргументы до вызова, а if — нет.

(defun classify (x)
  (if (> x 0)
      'positive
      (if (< x 0)
          'negative
          'zero)))

(classify 5)    ; => POSITIVE
(classify -3)   ; => NEGATIVE
(classify 0)    ; => ZERO

Важная деталь Common Lisp: «ложь» — это только nil. Любое другое значение, включая ноль 0, пустую строку "" и символ t, считается истиной. Это отличает Lisp от языков, где ноль или пустота «ложны». Здесь (if 0 'yes 'no) вернёт YES, потому что 0 — это не nil. Запомните это раз и навсегда: единственная ложь — nil, она же пустой список ().

У if третья ветвь необязательна: (if тест то-что-если-истина) вернёт nil, если тест ложен. Но когда «иначе» не нужно, идиоматичнее писать when — об этом ниже.

when и unless — когда нужна одна ветвь и несколько действий

Часто развилка односторонняя: «если условие — сделай это (возможно, несколько шагов), иначе ничего». Для этого есть when и его зеркало unless. Их сверхспособность по сравнению с if в том, что тело — это неявный progn: можно перечислить сколько угодно форм, они выполнятся по порядку, вернётся значение последней.

(defun greet (name verbose)
  (when verbose
    (format t "Подготовка приветствия...~%")
    (format t "Привет, ~a!~%" name)
    :done))            ; вернётся :DONE, если verbose истинно

(unless (null some-list)
  (process some-list))  ; выполнится, только если список НЕ пуст

Правило выбора простое: when читается как «когда условие — делай», unless — как «пока не условие — делай». Использовать (if тест (progn ...)) вместо when — признак непривычки к языку: when и unless ровно для этого и созданы и избавляют от лишнего progn и пустой ветви nil.

cond — лестница условий

Когда веток больше двух, вложенные if превращаются в «пирамиду судного дня». Канонический инструмент Lisp здесь — cond. Это лестница пар «тест → тело»: проверяются тесты сверху вниз, у первого истинного выполняется тело (снова неявный progn), и его значение возвращается. Если ни один тест не сработал — возвращается nil.

(defun water-state (temp)
  (cond
    ((<= temp 0)   'ice)
    ((< temp 100)  'liquid)
    (t              'steam)))   ; t — «во всех остальных случаях»

(water-state -5)   ; => ICE
(water-state 20)   ; => LIQUID
(water-state 150)  ; => STEAM

Идиома (t ...) в конце — это «иначе»: символ t всегда истинен, поэтому такая ветвь срабатывает, если не сработала ни одна предыдущая. У cond есть тонкость: если у ветви только тест и нет тела — ((find-thing) ) — то возвращается само значение теста. Это позволяет писать «верни первое непустое»: (cond ((lookup-cache key)) ((lookup-db key)) (t :not-found)).

case — диспетчеризация по конкретному значению

Если вы сравниваете одно выражение с набором констант, cond с кучей (eql x ...) избыточен. Для этого есть case: он один раз вычисляет ключевое выражение и сравнивает его (через eql) с ключами каждой ветви.

(defun day-type (day)
  (case day
    ((:sat :sun) 'weekend)      ; ключ-список: совпадение с любым из
    ((:mon :tue :wed :thu :fri) 'workday)
    (otherwise 'unknown)))      ; otherwise или t — ветвь по умолчанию

(day-type :sat)  ; => WEEKEND
(day-type :wed)  ; => WORKDAY
(day-type :xyz)  ; => UNKNOWN

Поскольку сравнение идёт через eql, case отлично работает с символами, ключевыми словами, числами и знаками, но не со строками (две одинаковые на вид строки не eql). Для строк используйте cond с string=. Ещё одна ловушка: ключ t и nil в обычном case воспринимаются как ветвь-по-умолчанию и пустой список ключей соответственно, поэтому если нужно сравнивать именно с символами t/nil, берите cond. Если хочется, чтобы непопадание сигналило об ошибке, есть ecase («exhaustive case») — он бросит исключение, если ни одна ветвь не подошла, что отлично ловит забытые случаи.

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

Все эти конструкции — синтаксический сахар поверх if. Это не метафора: в Common Lisp when, unless, cond, case определены как макросы, которые на этапе компиляции разворачиваются в комбинацию if, progn и временных переменных. Вы можете убедиться сами: вызов (macroexpand-1 '(when a b c)) вернёт (IF A (PROGN B C)). Это фундаментальное свойство Lisp: язык построен на крошечном ядре особых форм (if, quote, lambda, let и ещё нескольких), а всё остальное — макросы поверх него. Поэтому «управляющих конструкций» в Lisp в принципе сколько угодно: не хватает своей — напишите макрос (мы займёмся этим в разделе про макросы).

Семантически важно различать особые формы и функции. (+ a b) — функция: оба аргумента вычисляются всегда. (if a b c) — особая форма: a вычисляется, а из b и c только одно. Именно поэтому нельзя написать «свой if» как обычную функцию — она вычислила бы обе ветви. Это можно только макросом. Понимание границы «функция вычисляет всё / особая форма управляет вычислением» — ключ к модели исполнения Lisp.

Полезно увидеть точные развороты, чтобы это перестало быть абстракцией. when разворачивается в (if тест (progn тело...)); unless — в (if (not тест) (progn тело...)); cond — в лестницу вложенных if; case — в cond с серией сравнений (через eql или member для списков ключей). То есть в «настоящем» ядре языка из всего этого зоопарка нужен лишь if (плюс quote, lambda, let и горстка других). Всё остальное — удобные надстройки, которые любой мог бы написать сам. Это не преувеличение, а буквальная архитектура: посмотрите развороты через macroexpand-1, и вы увидите тот же if внутри. Такой «маленький центр + макрослой» — то, что отличает Lisp от языков с большой встроенной грамматикой, где новую управляющую конструкцию может добавить только разработчик компилятора.

И последнее про выбор конструкции — это вопрос читаемости, а не возможностей (они эквивалентны). Идиома такая: две ветви по значению — if; одна ветвь ради эффектов — when/unless; много взаимоисключающих условий — cond; сравнение одного значения с константами — case. Следование этим привычкам делает код мгновенно узнаваемым для других лисперов, а отклонения (например, cond с единственной веткой вместо when) сразу читаются как «что-то здесь не так».

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

  • Считать, что 0 или "" — ложь. В Common Lisp ложь — исключительно nil. (if 0 'a 'b) вернёт A. Это частый сюрприз для тех, кто пришёл из C или Python.
  • Лишний progn внутри if. Если в ветви несколько форм, не оборачивайте их вручную в progn внутри if ради одной ветви — возьмите when/unless: их тело уже неявный progn.
  • case со строками. Сравнение идёт через eql, строки так не сравниваются. Используйте cond + string=.
  • Забытая ветвь по умолчанию. cond и case без последней ветви возвращают nil молча. Если непопадание — это ошибка, берите ecase/etypecase или явную ветвь с (error ...).
  • Путать otherwise и ключ-список. В case ветвь (otherwise ...) или (t ...) — это «по умолчанию»; а ветвь со списком ключей пишется ((:a :b) ...). Одиночный ключ можно без скобок: (:a ...).

Итоги

  • if — особая форма-выражение: вычисляет ровно одну ветвь и возвращает значение.
  • when/unless — односторонние развилки с неявным progn в теле.
  • cond — лестница «тест → тело»; (t ...) — ветвь «иначе».
  • case — диспетчеризация по eql; для строк не годится, есть строгий ecase.
  • Единственная ложь — nil; всё остальное (включая 0 и "") истинно.
  • Все эти конструкции — макросы поверх if: язык строится из крошечного ядра особых форм.
Проверьте себя
1. Что вернёт (if 0 'yes 'no) в Common Lisp?
ANO
BYES
CNIL
DОшибку
2. Почему case нельзя использовать для сравнения строк?
Acase вообще не работает со строками как с типом
Bcase сравнивает ключи через eql, а одинаковые на вид строки обычно не eql
Cстроки в case нужно писать без кавычек
Dcase сравнивает только числа