Гомоиконность: когда программа — это структура данных

Разбираемся в главной идее Lisp, из которой вырастают макросы, метапрограммирование и всё его своеобразие.

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

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

Текст, данные и пропасть между ними

Возьмём типичный язык — скажем, Java или Python. Когда вы пишете строку x = a + b * 2, происходит следующее. Компилятор (или интерпретатор) читает эту строку символ за символом, разбирает её по правилам грамматики и строит у себя внутри дерево разбора — так называемое абстрактное синтаксическое дерево, AST. Это дерево живёт глубоко в недрах компилятора, обычным программам оно недоступно: к нему нет имени, его нельзя взять в переменную, изменить и скормить обратно. После сборки дерево превращается в байт-код или машинные инструкции и исчезает. Между «текстом, который вы написали» и «значениями, которыми оперирует ваша программа во время работы» лежит пропасть, перейти которую нельзя.

В Lisp этой пропасти нет, потому что синтаксическое дерево программы и есть обычная структура данных языка — список. Когда вы пишете (+ a (* b 2)), вы не просто записываете выражение; вы записываете список из трёх элементов: символа +, символа a и вложенного списка (* b 2). Этот список — полноценный объект, который можно построить руками, обойти, изменить и при желании выполнить. Грамматика языка настолько проста, что «дерево разбора» совпадает с тем, что вы видите на экране.

Читатель: мост между текстом и структурой

Чтобы прочувствовать это, нужно познакомиться с читателем (the reader) — частью Lisp-системы, которая превращает текст в данные. Читатель не вычисляет программу; он лишь переводит последовательность символов в структуру из списков, символов, чисел и строк. Это отдельная, явная стадия, к которой у программиста есть доступ. Есть даже специальная функция read, выполняющая чтение по требованию.

Рассмотрим пример. Мы используем quote (о нём подробно в следующем разделе) — он говорит «не вычисляй, а верни как данные» — и функцию length, измеряющую длину списка:

;; Кавычка ' говорит читателю: верни это как данные, не вычисляй
(length '(+ a (* b 2)))     ; => 3

;; Тот же список можно обойти как обычные данные:
(first  '(+ a (* b 2)))     ; => +     (символ)
(second '(+ a (* b 2)))     ; => A     (символ)
(third  '(+ a (* b 2)))     ; => (* b 2)  (вложенный список)

Обратите внимание: выражение (+ a (* b 2)), которое в другом языке было бы «кодом» и жило бы только в компиляторе, здесь — обычный список длины 3, у которого можно спросить первый, второй и третий элементы. Программа видит свой собственный синтаксис как данные.

И наоборот: список, построенный из частей программно, можно затем выполнить. Функция eval берёт структуру-данные и вычисляет её как программу:

;; Строим список программно из символа + и двух чисел
(list '+ 2 3)               ; => (+ 2 3)   — это данные, список

;; ...и выполняем построенный список как код
(eval (list '+ 2 3))        ; => 5

Здесь круг замкнулся: мы взяли символ + и числа, собрали из них список средствами работы с данными, а затем превратили этот список обратно в выполняемый код. Ни в Java, ни в Python такого прямого моста между «значениями в памяти» и «исполняемым кодом» по умолчанию нет.

Зачем это нужно: макросы

Гомоиконность — не самоцель и не трюк ради красоты. Её главное практическое следствие — макросы, то есть программы, которые во время компиляции получают на вход код (как список), преобразуют его по любым правилам и возвращают новый код, который и будет скомпилирован вместо исходного. Поскольку код — это списки, написать такую программу-над-программой ничем не сложнее, чем обычную обработку списков.

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

;; Макрос получает НЕвычисленный код и возвращает НОВЫЙ код.
;; `(...) — это шаблон, ,test и ,body подставляют переданные куски.
(defmacro unless (test &body body)
  `(if (not ,test)
       (progn ,@body)))

;; Использование выглядит как встроенная конструкция:
(unless (> 3 5)
  (print "три не больше пяти"))
;; во время компиляции разворачивается в:
;; (if (not (> 3 5)) (progn (print "три не больше пяти")))

Важно, что unless здесь — не функция. Функция получила бы уже вычисленные аргументы, а нам нужно получить сам код (> 3 5) и тело (print ...) как данные, чтобы переставить их внутри шаблона if. Именно гомоиконность делает это возможным: аргументы макроса приходят к нему не как значения, а как куски синтаксиса — списки. В реальности unless уже встроен в Common Lisp, но суть в том, что вы могли бы написать его сами, и он работал бы неотличимо от встроенного.

Гомоиконность в других языках

Справедливости ради: Lisp не единственный гомоиконный язык, но он самый чистый его представитель. В семье Lisp гомоиконны все диалекты — и Common Lisp, и Scheme, и Clojure, — потому что все они записывают код S-выражениями. За пределами этой семьи частичная гомоиконность встречается у Prolog (программа — это термы, с которыми язык работает) и у языков семейства Rebol, но они куда менее распространены.

А вот популярные языки гомоиконными не являются, и попытки добавить им метапрограммирование наглядно показывают, чего им не хватает. В Python есть модуль ast, позволяющий получить дерево разбора, но это громоздкие объекты особых классов, а не обычные списки, и собирать из них код вручную мучительно. В Rust и Scala есть процедурные макросы, но они работают с токенами и требуют отдельного аппарата. Во всех этих случаях язык вынужден надстраивать поверх себя специальный «второй язык» для работы с кодом. В Lisp такого второго языка не нужно: код и данные — одно и то же, и обрабатываются они одними и теми же функциями: car, cdr, cons, mapcar.

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

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

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

Под капотом всё держится на том, что мы уже назвали: читатель и вычислитель строго разделены и между ними существует промежуточное представление в виде структур данных. Конвейер выглядит так: текст → (читатель) → S-выражение как структура данных → (макрорасширение) → преобразованное S-выражение → (компилятор/вычислитель) → результат. Стадия макрорасширения вклинивается ровно в тот зазор, где код уже стал данными, но ещё не начал вычисляться.

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

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

Первое заблуждение новичков: думать, что '(+ 2 3) и (+ 2 3) — это одно и то же, написанное по-разному. Нет: первое — данные (список из трёх элементов, который ничего не вычисляет), второе — вычисляемое выражение, дающее 5. Кавычка принципиально меняет смысл, переключая между «данными» и «кодом».

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

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

Итоги

  • Гомоиконность — это запись программ в виде основной структуры данных языка; в Lisp код — это вложенные списки.
  • Читатель превращает текст в структуру данных (S-выражение), а eval превращает структуру данных обратно в выполняемый код — между ними есть прямой мост.
  • Главное практическое следствие — макросы: программы, которые получают код как данные, преобразуют его и возвращают новый код во время компиляции.
  • '(+ 2 3) — это данные (список), а (+ 2 3) — вычисляемое выражение; кавычка переключает между этими режимами.
  • Конвейер «текст → данные → макрорасширение → вычисление» порождает понятие момента времени (чтение, компиляция, выполнение), которое важно держать в голове.
Проверьте себя
1. Чем выражение '(+ 2 3) отличается от выражения (+ 2 3) в Lisp?
AНичем, это просто два способа записать число 5
B'(+ 2 3) — это данные (список из трёх элементов), которые не вычисляются, а (+ 2 3) — вычисляемое выражение, дающее 5
C'(+ 2 3) вычисляется быстрее за счёт кэша
DКавычка делает выражение строкой символов
2. Почему unless в Lisp реализуется макросом, а не функцией?
AМакросы работают быстрее функций
BФункция получила бы уже вычисленные аргументы, а макросу нужен сам код (условие и тело) как данные, чтобы переставить его внутри шаблона if до вычисления
CФункции в Lisp не умеют принимать условия
DЭто требование стандарта ANSI для всех управляющих конструкций
3. В какой момент конвейера Lisp работает макрос?
AВо время выполнения, получая вычисленные значения
BНа стадии макрорасширения (время компиляции), когда код уже стал структурой данных, но ещё не вычисляется
CНа стадии чтения, до появления каких-либо структур данных
DПосле завершения программы, при сборке мусора