Собственный DSL: макрос как проектирование языка
Макрос как инструмент проектирования языка: пишем собственный DSL, разбираем, что делает макросы «парадигмой» и почему Lisp стирает границу между языком и библиотекой.
DSL (domain-specific language, предметно-ориентированный язык) — это небольшой язык, заточенный под конкретную задачу; в Lisp его создают макросами, встраивая прямо в основной язык без отдельного парсера.
Зачем это: подними язык к задаче, а не задачу к языку
Обычно программист опускает свою задачу до примитивов языка: пишет циклы, проверки, передаёт состояние. Lisp предлагает обратное — поднять язык к задаче: добавить конструкции, говорящие на языке предметной области. Это и есть «программирование снизу вверх», философия, прославленная Полом Грэмом: вы не пишете программу на языке, вы достраиваете язык под программу, а потом пишете на нём. Макросы делают это возможным без накладных расходов: новые конструкции компилируются в эффективный код. В пределе граница между «встроенным в язык» и «вашей библиотекой» исчезает — пользователь не отличит ваш defrule от штатного defun.
Чтобы оценить силу этой идеи, полезно понять разницу между внутренним и внешним DSL. Внешний DSL — это отдельный язык со своим синтаксисом, парсером, иногда отдельным файлом и интерпретатором (так устроены, скажем, SQL, регулярные выражения, конфигурационные форматы). Он мощный, но дорогой: нужно написать и поддерживать грамматику, лексер, обработку ошибок, и он живёт «отдельно» от основного кода — нельзя просто вызвать функцию из него. Внутренний DSL встроен прямо в язык-хозяин, используя его синтаксис. В большинстве языков внутренние DSL ограничены (можно лишь цепочки вызовов методов да перегрузку операторов). В Lisp же благодаря макросам внутренний DSL неотличим по выразительности от внешнего: вы получаете любой нужный синтаксис, но без отдельного парсера, с полным доступом ко всему языку и его инструментам. Это уникальное сочетание — мощь внешнего DSL при стоимости внутреннего — и есть то, ради чего макросы стоят изучения.
Шаг 1: from нуля до управляющей конструкции
Начнём с малого DSL — собственной управляющей конструкции. Допустим, нам часто нужно «выполнить тело и измерить время». В языке без макросов это либо повторяющийся шаблон, либо функция высшего порядка с лямбдой (синтаксически шумно). Макрос даёт чистую конструкцию.
;; Конструкция timing: выполнить тело и вернуть его значение,
;; напечатав затраченное время.
(defmacro timing (&body body)
(let ((start (gensym "START-"))
(result (gensym "RES-")))
`(let ((,start (get-internal-real-time)))
(let ((,result (progn ,@body)))
(format t "Заняло ~d тиков~%"
(- (get-internal-real-time) ,start))
,result)))) ; вернуть значение тела
;; Использование выглядит как встроенная конструкция:
(timing
(sleep 0)
(reduce #'+ (loop for i below 1000 collect i)))
;; печатает время, возвращает 499500
Заметьте: мы аккуратно применили уроки гигиены — start и result через gensym, а тело вычисляется один раз (сохранено в result) и его значение возвращается. Конструкция timing теперь неотличима от встроенной: её можно вкладывать, ставить куда угодно, она возвращает значение. Это первый уровень DSL — расширение управляющих конструкций.
Шаг 2: декларативный DSL с собственным синтаксисом
Поднимемся выше: создадим маленький декларативный язык. Пусть это будет описание простого конечного автомата (состояния и переходы). Мы хотим, чтобы пользователь описывал автомат, а макрос превращал описание в работающий код.
;; DSL: описываем автомат декларативно
;; (defmachine turnstile
;; (locked (coin -> unlocked) (push -> locked))
;; (unlocked (push -> locked) (coin -> unlocked)))
;; Превращается в функцию step: (state, event) -> new-state
(defmacro defmachine (name &body states)
`(defun ,name (state event)
(case state
,@(mapcar
(lambda (st)
(destructuring-bind (state-name &rest transitions) st
`(,state-name
(case event
,@(mapcar
(lambda (tr)
;; tr = (event -> target); берём 1-й и 3-й элементы
`(,(first tr) ',(third tr)))
transitions)
(otherwise state))))) ; неизвестное событие — остаться
states)
(otherwise (error "Неизвестное состояние ~a" state)))))
Здесь макрос читает структуру описания и генерирует вложенный case: внешний по состоянию, внутренний по событию. Стрелка -> в DSL — просто символ-разделитель, мы извлекаем (first tr) (событие) и (third tr) (целевое состояние), пропуская стрелку. Это и есть проектирование языка: пользователь пишет декларацию, читаемую как таблица переходов, а компилятор получает эффективный диспетчер. destructuring-bind здесь разбирает каждую строку-состояние на имя и список переходов — это удобный спутник макросов для разбора структурных аргументов.
;; Применяем сгенерированный автомат:
(defmachine turnstile
(locked (coin -> unlocked) (push -> locked))
(unlocked (push -> locked) (coin -> unlocked)))
(turnstile 'locked 'coin) ; => UNLOCKED
(turnstile 'unlocked 'push) ; => LOCKED
(turnstile 'locked 'push) ; => LOCKED (остались на месте)
Почему это меняет инженерию
Стоит проговорить, как именно макрос «читает» это описание, потому что здесь происходит маленькое чудо. Аргументы states приходят в макрос как вложенные списки данных — никто их не вычислял, это сырая структура, написанная пользователем. Макрос обходит её обычными списочными функциями: mapcar по состояниям, destructuring-bind для разбора каждого, first/third для извлечения частей перехода. То есть «разбор синтаксиса DSL» — это просто работа со списками, тем же инструментарием, что и любые данные. В этом сила гомоиконности: вам не нужен лексер и парсер, потому что описание уже разобрано в дерево (reader сделал это при чтении файла), и вы получаете готовый AST в виде списков. Сравните с внешним DSL, где пришлось бы писать грамматику и парсер вручную, — здесь этот этап исчезает целиком.
DSL на макросах решает реальную инженерную проблему: разрыв между намерением и кодом. Описание автомата выше читается почти как спецификация — его поймёт и не-программист, и в нём трудно ошибиться. При этом нет внешнего парсера, нет отдельного файла грамматики, нет рантайм-интерпретатора описания: всё это обычный Lisp-код, скомпилированный в быстрые функции. Именно так устроены многие легендарные библиотеки экосистемы: ORM, парсер-комбинаторы, системы правил, веб-маршрутизаторы — все они «выглядят как встроенный синтаксис», а под капотом это макросы. Сам стандарт Common Lisp построен так же: loop — это целый мини-язык циклов, реализованный макросом; defclass, defstruct, format — DSL-и внутри языка.
Дисциплина проектирования DSL
Сила обязывает. Хорошие DSL следуют принципам: (1) конструкции должны вести себя как встроенные — возвращать значения, вкладываться, не ломать гигиену; (2) сообщения об ошибках должны быть на языке DSL, а не вываливать кишки разворота — проверяйте корректность описания в теле макроса и сигналите осмысленно; (3) не злоупотребляйте — если задача решается функцией или функцией высшего порядка, DSL избыточен; макрос-DSL оправдан, когда даёт принципиально лучшую читаемость или невозможный иначе синтаксис; (4) отделяйте механизм от синтаксиса — пусть макрос лишь разворачивается в вызовы обычных функций (рантайм-логика — в функциях, макрос — тонкий слой синтаксиса). Последнее особенно важно: так DSL легче отлаживать и тестировать.
Контраст со Scheme
В Scheme DSL тоже строят макросами, но чаще декларативным syntax-rules (или мощным syntax-case) с автоматической гигиеной. Стиль получается иной: меньше «императивной сборки кода», больше «правил переписывания образцов». Для DSL вроде нашего автомата syntax-rules прекрасно подходит и безопаснее (нет риска захвата). Когда же DSL требует произвольных вычислений во время расширения (сложная генерация, анализ описания), берут syntax-case в Scheme или штатный defmacro в CL. Обе традиции согласны в главном: макрос — это инструмент проектирования языка, а не просто «сокращение».
Racket довёл эту идею до предела, превратив «язык для создания языков» в свою главную миссию. В нём можно объявить целый новый язык директивой #lang в первой строке файла — со своим синтаксисом, семантикой и даже системой типов, — и весь файл будет на нём. На Racket пишут учебные языки, языки для конкретных доменов, экспериментальные исследовательские языки — и всё это переиспользует инфраструктуру (среду, отладчик, пакеты). Это логическое завершение пути, начатого макросами: если макрос расширяет язык точечно, то механизмы Racket позволяют заменить язык целиком. Понимать это полезно даже если вы пишете на Common Lisp: это показывает, куда ведёт философия «код как данные» в пределе, и почему всё семейство Lisp считают лабораторией идей о языках программирования.
Как работает под капотом
DSL-макрос — это компилятор в миниатюре, встроенный в фазу макрорасширения. Когда компилятор встречает (defmachine turnstile ...), он вызывает наш макрос, передавая всё описание как данные (вложенные списки). Макрос анализирует эти данные (это уже синтаксический разбор!) и генерирует целевой код (это уже кодогенерация!). То есть мы написали мини-фронтенд и мини-бэкенд компилятора, не выходя из языка и используя списки как готовый AST. Результат — обычная форма (defun с вложенными case), которую штатный компилятор Lisp превращает в машинный код. Никакого рантайм-разбора описания не остаётся: вся «интерпретация DSL» произошла на этапе компиляции. Это и есть причина, почему Lisp-DSL не платят производительностью за выразительность.
Частые ошибки
- DSL там, где хватит функции. Макрос-DSL оправдан читаемостью или невозможным иначе синтаксисом; иначе предпочтите функцию (высшего порядка).
- Логика в макросе вместо функций. Держите рантайм-логику в обычных функциях, а макрос — тонким слоем синтаксиса. Так проще тестировать и отлаживать.
- Плохие ошибки DSL. Если описание некорректно, не дайте пользователю увидеть «кишки» разворота — проверьте в теле макроса и сигнализируйте на языке DSL.
- Забытая гигиена в DSL. Любые временные имена — через
gensym; аргументы пользователя вычисляйте ровно один раз. - Переусложнение. DSL должен упрощать предметную задачу, а не добавлять второй язык, который надо отдельно изучать. Минимализм синтаксиса — достоинство.
Подытоживая раздел о макросах целиком: вы прошли путь от «макрос — это функция времени компиляции» через квазицитирование и гигиену до проектирования собственных языков. Это и есть «суперсила Lisp», вынесенная в название раздела, — способность относиться к языку не как к данности, а как к материалу, который вы формуете под задачу. Ни одна другая широко используемая семья языков не даёт этого в такой полноте и с такой структурной чистотой. Освоив макросы, вы получили инструмент, ради которого многие и приходят в Lisp и остаются в нём.
Итоги
- Макросы позволяют «поднять язык к задаче»: создать предметный язык (DSL), встроенный в Lisp.
- Простейший DSL — новая управляющая конструкция (как
timing), ведущая себя как встроенная. - Декларативный DSL (как
defmachine) превращает описание-спецификацию в эффективный код на этапе компиляции. destructuring-bind— удобный спутник макросов для разбора структурных аргументов.- Дисциплина: вести себя как встроенный синтаксис, хорошие ошибки, не злоупотреблять, отделять механизм от синтаксиса.
- DSL-макрос — это мини-компилятор: разбор описания + кодогенерация в compile time, без потери производительности.