Гомоиконность: когда программа — это структура данных
Разбираемся в главной идее 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)— вычисляемое выражение; кавычка переключает между этими режимами.- Конвейер «текст → данные → макрорасширение → вычисление» порождает понятие момента времени (чтение, компиляция, выполнение), которое важно держать в голове.