Диалекты Lisp: Common Lisp, Scheme, Clojure

Разбираемся, почему «выучить Lisp» — это неточная фраза, и чем три его главных диалекта отличаются друг от друга.

Диалект Lisp — самостоятельный язык внутри семьи Lisp, разделяющий общий синтаксис S-выражений, но со своей философией, стандартной библиотекой и семантическими решениями. Три самых влиятельных диалекта — Common Lisp, Scheme и Clojure.

Сказать «я пишу на Lisp» — примерно то же, что сказать «я говорю на романском языке»: понятно направление, но не понятно, идёт ли речь об испанском, итальянском или французском. Lisp — это не один язык, а семья, и её диалекты различаются достаточно сильно, чтобы путать их было ошибкой. В этом курсе мы выбираем за основу Common Lisp и регулярно сравниваем его со Scheme; чтобы эти сравнения были осмысленными, нужно понять характер каждого диалекта.

Common Lisp: большой промышленный стандарт

Common Lisp появился в 1980-е как попытка объединить расплодившиеся к тому времени несовместимые диалекты Lisp в один общий промышленный стандарт. В 1994 году он был закреплён как стандарт ANSI и с тех пор практически не менялся — это сознательное решение ради стабильности. Common Lisp — язык намеренно большой и прагматичный: в его стандарт входит огромная библиотека, развитая объектная система CLOS, мощная макросистема, система обработки ошибок через «условия и рестарты» и множество готовых конструкций на все случаи жизни. Философия Common Lisp — «дать программисту всё необходимое и не диктовать единственно верный стиль».

Главная современная реализация Common Lisp — SBCL (Steel Bank Common Lisp). Это свободный компилятор, который переводит код в быстрый машинный код, имеет отличный оптимизатор и развитую систему типов с проверками. Именно SBCL мы держим в уме на протяжении всего курса, когда говорим «Common Lisp». Есть и другие реализации — коммерческие LispWorks и Allegro CL, свободные CCL и ECL, — но все они следуют одному стандарту ANSI, поэтому код переносим между ними.

;; Common Lisp: определение функции, вычисление факториала
(defun factorial (n)
  (if (<= n 1)
      1
      (* n (factorial (- n 1)))))

(factorial 5)        ; => 120

Scheme: минимализм и академическая чистота

Scheme возник в 1975 году в том же MIT и пошёл противоположным путём: вместо «дать всё» — «дать минимум, но идеально продуманный». Это маленький, элегантный, академически выверенный язык, на котором десятилетиями учат основам программирования (знаменитый учебник SICP написан на Scheme). Стандарты Scheme нумеруются как «пересмотренные отчёты» — отсюда названия R5RS, R6RS, R7RS (актуальная редакция «малого» Scheme, на которую мы ссылаемся в курсе). Идеал Scheme — ортогональность: немного фундаментальных понятий, из которых всё остальное строится единообразно.

Из этой философии вытекают важные технические отличия. Scheme требует от реализаций правильной хвостовой рекурсии (proper tail calls): рекурсивный вызов в хвостовой позиции не наращивает стек, поэтому в Scheme циклы естественно выражаются рекурсией без риска переполнения. Scheme — это Lisp-1 (об этом ниже), у него единое пространство имён для функций и переменных. Популярная современная среда для Scheme — Racket: это уже скорее «потомок Scheme», выросший в самостоятельную мощную платформу со своей экосистемой, но сохраняющий дух минимализма в ядре.

; Scheme (R7RS): то же определение через define
(define (factorial n)
  (if (<= n 1)
      1
      (* n (factorial (- n 1)))))

(factorial 5)        ; => 120

Заметьте: вместо defun в Scheme используется define, а в остальном запись очень похожа. Различия диалектов чаще в именах и тонкой семантике, чем в синтаксисе скобок.

Lisp-2 против Lisp-1: главное идейное расхождение

Самое глубокое и часто экзаменуемое различие между Common Lisp и Scheme — это вопрос: может ли одно имя одновременно обозначать и функцию, и переменную? Common Lisp отвечает «да»: у него раздельные пространства имён для функций и для значений переменных, поэтому такие языки называют Lisp-2. Имя list может быть функцией, и при этом переменная с именем list может хранить какой-то конкретный список — они не конфликтуют. Scheme отвечает «нет»: у него единое пространство имён, и такой язык называют Lisp-1; имя означает что-то одно.

Это расхождение имеет видимые последствия в синтаксисе. В Common Lisp, чтобы взять функцию как значение (например, передать её другой функции), нужен специальный синтаксис #' и оператор funcall для вызова такого значения. В Scheme ничего этого не нужно: функция — обычное значение, её можно передавать и вызывать напрямую. Этой теме мы посвятим отдельный подробный урок в разделе про функции; пока запомните формулу: Common Lisp = Lisp-2 (два пространства имён), Scheme = Lisp-1 (одно).

;; Common Lisp (Lisp-2): #' берёт функцию, funcall её вызывает
(defun apply-twice (f x)
  (funcall f (funcall f x)))
(apply-twice #'1+ 5)      ; => 7

; Scheme (Lisp-1): функция — обычное значение, без #' и funcall
(define (apply-twice f x) (f (f x)))
(apply-twice (lambda (n) (+ n 1)) 5)   ; => 7

Clojure: Lisp на платформе JVM

Clojure — самый молодой из трёх (2007 год) и самый «промышленно популярный» сегодня. Его создал Рич Хикки, чтобы принести идеи Lisp в мир Java: Clojure работает поверх виртуальной машины JVM, имеет прямой доступ ко всей экосистеме Java и ориентирован на параллельное программирование. Идейно Clojure делает ставку на неизменяемые данные и функциональный стиль: его структуры данных по умолчанию неизменяемы, что упрощает многопоточность. Синтаксически Clojure отходит от классики: использует не только круглые скобки, но и квадратные [] для векторов и фигурные {} для отображений, что многих новичков удивляет.

В этом курсе мы не углубляемся в Clojure, потому что он живёт в мире JVM и его экосистема сильно отличается. Но знать о нём важно: для многих сегодняшних программистов Clojure — это первое и единственное знакомство с Lisp, и именно благодаря ему идеи семьи Lisp остаются в живой индустриальной практике, а не только в учебниках.

Почему диалектов так много

Множественность диалектов — не случайность, а прямое следствие устройства Lisp. Раз язык так легко расширять макросами, а его ядро так мало, любой коллектив может за разумное время построить собственный Lisp, заточенный под свои задачи, добавив нужные конструкции прямо поверх читателя и вычислителя. В 1970-е именно это и произошло: в разных лабораториях и компаниях расцвели десятки несовместимых Lisp-ов — MacLisp, InterLisp, ZetaLisp и многие другие. Каждый был хорош по-своему, но программа, написанная для одного, не работала на другом, и сообщество фрагментировалось.

Common Lisp родился именно как реакция на этот хаос — как договор крупных игроков о едином стандарте, чтобы код наконец стал переносимым. Этим объясняется и его «всеохватность»: чтобы объединить разные диалекты, в стандарт пришлось включить возможности из каждого, отсюда и репутация большого языка. Scheme же в это время сознательно держался в стороне, развивая противоположную ценность — минимализм и чистоту вместо охвата. А Clojure спустя тридцать лет показал, что традиция жива: это снова новый Lisp, построенный под новую среду (JVM) и новые ценности (неизменяемость, многопоточность). История диалектов — это история одного и того же приёма: взять крошечное ядро Lisp и нарастить вокруг него язык под свою эпоху.

Как соотносятся реализации и стандарты

Здесь новичков легко запутать, поэтому разложим по полочкам. Стандарт — это документ, описывающий язык (ANSI для Common Lisp; R7RS и другие RnRS для Scheme). Реализация — это конкретная программа, которая исполняет язык по стандарту (SBCL — реализация Common Lisp; Racket — среда, реализующая Scheme и его расширения). Один стандарт может иметь много реализаций, и переносимый код работает в любой из них. Эту структуру удобно держать в голове такой таблицей:

ДиалектСтандартРеализация в курсеПространства имён
Common LispANSI (1994)SBCLLisp-2 (раздельные)
SchemeR7RSRacketLisp-1 (единое)
Clojure(нет ISO/ANSI)JVMLisp-1 (единое)

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

Несмотря на различия, у всех диалектов общий скелет, который мы описали в прошлых уроках: читатель превращает S-выражения в данные, вычислитель их вычисляет, макросистема преобразует код. Различия — в наполнении: какие функции есть в стандартной библиотеке, требуется ли оптимизация хвостовой рекурсии, одно пространство имён или два, изменяемы ли данные по умолчанию. Именно поэтому, освоив Common Lisp, вы за несколько часов сориентируетесь в Scheme: фундаментальная механика та же, отличаются «правила дорожного движения» поверх неё.

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

Главная ошибка — переносить идиомы одного диалекта в другой. Например, в Scheme естественно выражать цикл хвостовой рекурсией, полагаясь на её оптимизацию; в Common Lisp стандарт такой оптимизации не гарантирует (хотя SBCL её обычно делает), поэтому глубокая рекурсия может переполнить стек, и для циклов используют специальные конструкции. Или наоборот: новичок, выучивший Common Lisp, пишет в Scheme #' и funcall, которых там просто нет.

Вторая ошибка — считать, что «новее значит лучше», и выбирать диалект по дате создания. Выбор зависит от задачи: для академического изучения основ и чистоты — Scheme/Racket; для самостоятельных больших систем с богатой библиотекой «из коробки» — Common Lisp/SBCL; для работы в экосистеме Java и многопоточности — Clojure. Ни один из них не «устарел».

Третья ошибка — путать стандарт и реализацию, говоря, например, «язык SBCL» или «язык Racket». SBCL — это реализация языка Common Lisp, а не отдельный язык; Racket — среда, реализующая Scheme (и больше). Эта путаница мешает искать документацию и обсуждать переносимость кода.

Итоги

  • Lisp — это семья диалектов; «выучить Lisp» неточно, потому что Common Lisp, Scheme и Clojure различаются философией и семантикой.
  • Common Lisp (стандарт ANSI, реализация SBCL) — большой прагматичный промышленный язык с богатой библиотекой; основа этого курса.
  • Scheme (стандарты RnRS, R7RS; среда Racket) — минималистичный академический язык с правильной хвостовой рекурсией и единым пространством имён.
  • Главное идейное различие: Common Lisp — Lisp-2 (раздельные пространства имён функций и переменных, отсюда #' и funcall), Scheme и Clojure — Lisp-1 (единое пространство).
  • Стандарт описывает язык, реализация исполняет его; один стандарт может иметь много реализаций, и эти понятия нельзя путать.
Проверьте себя
1. В чём состоит ключевое различие между Lisp-2 (Common Lisp) и Lisp-1 (Scheme)?
ALisp-2 работает только на двухъядерных процессорах
BВ Lisp-2 раздельные пространства имён для функций и переменных (одно имя может быть и функцией, и переменной), а в Lisp-1 пространство имён единое
CLisp-2 поддерживает только два типа данных, а Lisp-1 — один
DLisp-1 на единицу быстрее Lisp-2
2. Какая реализация Common Lisp принята за основу в этом курсе?
ARacket
BSBCL (Steel Bank Common Lisp)
CClojure на JVM
DDrRacket
3. Чем отличается «стандарт» от «реализации» языка?
AЭто синонимы, разница только в слове
BСтандарт — это документ, описывающий язык (ANSI, R7RS), а реализация — конкретная программа, исполняющая язык по стандарту (SBCL, Racket); один стандарт может иметь много реализаций
CСтандарт исполняет код, а реализация его описывает
DСтандарт нужен только для Scheme, а реализация — только для Common Lisp