S-выражения: атомы, списки и префиксная нотация

Изучаем единственное синтаксическое правило Lisp, из которого строится всё остальное.

S-выражение (symbolic expression) — базовая синтаксическая единица Lisp: это либо атом (неделимое значение вроде числа или символа), либо список — последовательность S-выражений в круглых скобках. Вся программа на Lisp — это вложенные S-выражения.

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

Атомы: неделимые значения

Атом — это S-выражение, которое нельзя разбить на части средствами чтения. К атомам относятся числа (42, 3.14, 1/3), символы (имена вроде x, +, defun), строки ("привет"), а также особые значения t и nil. Слово «атом» здесь употреблено в исходном древнегреческом смысле — «неделимый». Когда читатель встречает атом, он создаёт соответствующий объект: для 42 — целое число, для x — символ, для "привет" — строку.

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

;; Примеры атомов:
42            ; целое число
3.14          ; число с плавающей точкой
1/3           ; рациональное число (дробь!)
"привет"      ; строка
foo           ; символ (имя)
+             ; тоже символ (имя операции сложения)
t             ; логическая истина
nil           ; пустой список и логическая ложь одновременно

Списки: всё в скобках

Список — это упорядоченная последовательность S-выражений, заключённая в круглые скобки. Элементами списка могут быть как атомы, так и другие списки — отсюда вложенность. Список (1 2 3) состоит из трёх атомов; список (1 (2 3) 4) состоит из атома, вложенного списка и ещё одного атома. Пустой список записывается как () и совпадает с атомом nil — это особый случай, к которому мы вернёмся в уроке про t и nil.

Здесь возникает гениальная двойственность Lisp: список — это одновременно и структура данных, и форма записи кода. Когда вы пишете (1 2 3) в контексте данных, это список из трёх чисел. Когда вы пишете (+ 1 2) в контексте вычисления, это вызов функции сложения. Синтаксически это одно и то же — список, — и только контекст (вычисляется он или нет) определяет, как он трактуется. Именно поэтому в Lisp код можно обрабатывать как данные: с точки зрения читателя между ними нет разницы.

()              ; пустой список (он же nil)
(1 2 3)         ; список из трёх чисел
(a b c)         ; список из трёх символов
(1 (2 3) 4)     ; вложенный список
(+ 1 2)         ; список — и одновременно вызов сложения
(defun sq (x) (* x x))   ; список — и одновременно определение функции

Префиксная нотация: оператор стоит первым

Самое непривычное для новичка — это префиксная нотация: в Lisp оператор (а точнее, имя функции) ставится первым элементом списка, перед всеми аргументами. Мы пишем не 2 + 3, а (+ 2 3); не max(a, b), а (max a b). Правило чтения любого вычисляемого списка простое и единообразное: первый элемент — что делаем, остальные — над чем делаем.

На первый взгляд это кажется неудобством, но у префиксной нотации есть весомые преимущества. Во-первых, исчезает понятие приоритета операций. В привычной записи 2 + 3 * 4 нужно помнить, что умножение «сильнее» сложения; ошибёшься в приоритете — получишь неверный результат. В Lisp структура задаётся скобками явно: (+ 2 (* 3 4)) не оставляет никакой двусмысленности. Во-вторых, операторы естественно принимают любое число аргументов. Сложить пять чисел — это просто (+ 1 2 3 4 5); не нужно писать 1 + 2 + 3 + 4 + 5 с повторением знака. В-третьих, вызов функции и применение оператора — это одно и то же: нет искусственного разделения на «встроенные операторы» и «обычные функции», всё записывается единообразно.

;; Префиксная запись против привычной инфиксной:
(+ 2 3)              ; 2 + 3        = 5
(* 3 4)              ; 3 * 4        = 12
(+ 2 (* 3 4))        ; 2 + 3*4      = 14  (скобки заменяют приоритет)
(* (+ 2 3) 4)        ; (2+3) * 4    = 20
(+ 1 2 3 4 5)        ; 1+2+3+4+5    = 15  (любое число аргументов!)
(max 7 2 9 4)        ; максимум из четырёх  = 9

Как читать вложенные скобки

Главный навык начинающего — научиться читать вложенные S-выражения, не пугаясь скобок. Секрет в том, чтобы читать изнутри наружу: находите самое глубоко вложенное выражение, мысленно вычисляете его, заменяете результатом — и поднимаетесь на уровень выше. Возьмём (* (+ 1 2) (- 10 4)). Самое внутреннее слева — (+ 1 2), это 3. Справа — (- 10 4), это 6. Остаётся (* 3 6) — это 18. Так любое, сколь угодно громоздкое выражение распадается на простые шаги.

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

;; Те же скобки, выровненные отступами для читаемости:
(defun discriminant (a b c)
  (- (* b b)
     (* 4 a c)))

;; читается изнутри: b*b, потом 4*a*c, потом их разность

Комментарии и оформление

Раз уж мы говорим о синтаксисе, отметим, как в Lisp пишутся комментарии, — это часто удивляет новичков. Однострочный комментарий начинается с точки с запятой ; и тянется до конца строки. Это и есть причина, по которой точка с запятой не может служить разделителем выражений, как в C: она занята под комментарии. По принятому стилю количество точек с запятой кодирует уровень комментария: ; — комментарий в конце строки кода, ;; — отдельная строка-пояснение внутри функции, ;;; — комментарий уровня всего определения. Кроме того, есть блочный комментарий #| ... |#, который может занимать несколько строк и вкладываться сам в себя.

; комментарий уровня строки кода
;; пояснение к блоку внутри функции
;;; комментарий ко всему определению ниже

#| Это блочный комментарий.
   Он может занимать
   несколько строк. |#

(+ 1 2)   ; складываем — комментарий справа от кода

Ещё одна полезная конструкция читателя — «немедленный комментарий выражения» #;, который выбрасывает не строку, а следующее целое S-выражение. Это удобно при отладке: можно «выключить» подвыражение, поставив перед ним #;, не стирая его. Такие мелочи показывают, насколько читатель Lisp гибок: он не просто разбирает скобки, а предоставляет набор инструментов для управления тем, что попадёт в структуру данных, а что будет отброшено ещё на стадии чтения.

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

Когда читатель встречает открывающую скобку, он начинает собирать список, читая S-выражения одно за другим, пока не встретит закрывающую скобку. Каждый элемент рекурсивно читается тем же механизмом — поэтому вложенность обрабатывается естественно. Результат — структура из cons-ячеек в памяти (их мы детально разберём в следующем разделе), где первый элемент списка доступен через car, а остаток — через cdr. Атомы же читаются по своим правилам: последовательность цифр становится числом, последовательность букв — символом, текст в кавычках — строкой.

Когда затем вычислитель получает список, он применяет простое правило: смотрит на первый элемент. Если это имя функции — вычисляет остальные элементы как аргументы и вызывает функцию. Если это имя специального оператора (например, if или quote) — обрабатывает его особым образом. Атом-число вычисляется сам в себя, атом-символ — в значение связанной с ним переменной. Вся семантика вычисления Lisp умещается в этот короткий свод правил, что мы детально разберём в следующем уроке про quote и eval.

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

Самая частая ошибка новичка — писать инфиксно по привычке: (2 + 3) вместо (+ 2 3). Запись (2 + 3) Lisp воспримет как попытку вызвать «функцию» с именем 2 и аргументами + и 3, что приведёт к ошибке, потому что число не является функцией. Помните железное правило: первый элемент списка — это всегда «что делаем».

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

Третья ошибка — путать символ и строку. foo — это символ (имя-объект), а "foo" — это строка (текст). Они ведут себя по-разному: символы интернируются и сравниваются как объекты, строки — как последовательности литер. Использовать строку там, где нужен символ (или наоборот), — источник тонких ошибок, особенно при работе с ключами и метками.

Итоги

  • S-выражение — это либо атом (число, символ, строка, t, nil), либо список S-выражений в скобках; вся программа на Lisp устроена так.
  • Список — одновременно структура данных и форма записи кода; различает их только контекст вычисления, что и лежит в основе гомоиконности.
  • Префиксная нотация ставит оператор первым: правило «первый элемент — что делаем, остальные — над чем» убирает приоритеты операций и допускает любое число аргументов.
  • Вложенные выражения читаются изнутри наружу; отступы не влияют на смысл — структуру задают только скобки.
  • Символ — это именованный объект (не текст), а строка — последовательность литер; путать их нельзя.
Проверьте себя
1. Как на Lisp правильно записать сумму 2 и 3?
A(2 + 3)
B(+ 2 3)
C2 + 3
D+(2, 3)
2. Какое преимущество даёт префиксная нотация Lisp?
AОна требует помнить больше правил приоритета операций
BИсчезает понятие приоритета (структуру задают скобки явно), операторы принимают любое число аргументов, а вызов функции и применение оператора единообразны
CОна запрещает вкладывать выражения друг в друга
DОна ускоряет программу в два раза
3. Чем символ foo отличается от строки "foo" в Lisp?
AНичем, это два написания одного и того же
BСимвол — это именованный объект (может быть именем переменной/функции, интернируется), а строка — последовательность литер (текст)
CСтрока всегда длиннее символа
DСимвол можно вычислять, а строку нельзя хранить в переменной