Макро-шалости: генерация API и условная компиляция
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Макро-шалости: генерация API и условная компиляция
Последний содержательный урок про язык. Механику мы разобрали в уроках про макросы и гигиену, здесь берём верхний слой: метапрограммирование всерьёз. Макросы, которые пишут определения, генерация API из данных и разговор о том, когда пора остановиться.
Макрос живёт в другом времени
Зафиксируем главное следствие, из которого растут все ошибки этого урока: макрос выполняется в другое время, чем твой код. Представь верстальщика в типографии: он правит текст до печати, видит на странице слово “some-var”, может его переставить или заменить, но что за число окажется на месте этого слова у читателя, он не узнает никогда. Макрос это верстальщик, твой работающий код это напечатанная страница.
Макрос получил символ some-var, а не число 42. Значение появится только во время выполнения, когда макроса уже давно нет.
Пощёлкай варианты и посмотри, что макросу доступно, а что нет:
Макрос не может прочитать значение переменной. Это самая частая ошибка в сложных макросах: попытка что-то вычислить из аргумента, который на момент раскрытия ещё не имеет значения. Макросу доступно только то, что известно при компиляции: литералы, форма кода, определения, сделанные раньше в том же файле. Всё остальное это данные времени выполнения, и с ними работает порождённый код, а не сам макрос.
Определения из макроса
Макрос может породить не одно выражение, а сразу несколько определений. Склеивает их upscope: он выполняет формы последовательно, не создавая новой области видимости, поэтому определения попадают наружу.
Почему upscope, а не do. (do (def A 1)) тоже выполнит определение, но do создаёт новую область видимости, и A останется внутри неё. upscope этого не делает, поэтому именно он нужен, когда макрос определяет имена для вызывающего кода.
Имена можно и вычислять. Функция symbol собирает символ из частей:
Две функции появились из одной строки, и они обычные: их видно в (doc ...) и можно передавать как значения.
Генерация API из данных
Следующий шаг это взять описание из данных. Таблица “имя в операцию” превращается в набор функций:
Обрати внимание на (eval tbl). Макрос получил символ operations, а ему нужно её содержимое, и вот здесь eval во время раскрытия уместен: таблица определена выше в том же файле, то есть уже существует на момент компиляции.
Это ровно та граница, о которой предупреждал первый раздел урока: верстальщик может заглянуть только в то, что уже лежит в типографии. С переменной, получающей значение во время выполнения, тот же приём провалится, и провалится не сразу, а когда таблица переедет в другой модуль или станет вычисляемой.
Есть способ обойтись без eval: передавать описание литералом прямо в вызов. Тогда макрос получает не символ, а саму структуру:
Правило выбора: eval только для имени, определённого выше в этом же файле; во всех остальных случаях передавай данные литералом в форму вызова, там им ничего не угрожает.
Макросы, порождающие макросы
Раз макрос возвращает код, он может вернуть и определение другого макроса. Синтаксис получается плотный:
Разберём ~(,',op ,;forms). Здесь три уровня, снимаем их слоями, снаружи внутрь:
- внешняя
~строит код определения макроса; - внутренняя
~это уже шаблон порождённого макроса; ,',opподставляет значениеop(символand) так, чтобы во внутреннем шаблоне он оказался процитированным, иначе внутренний шаблон попытался бы его вычислить.
Проверить себя всегда можно раскрытием: (macex1 '(all-and a b)) даёт (and a b).
Два уровня квазицитирования это предел, за которым код перестаёт читаться. Если тебе нужен третий, почти наверняка задачу стоит решать иначе: вынести генерацию в обычную функцию, которая строит форму, а макросу оставить только подстановку результата.
Условная компиляция
compwhen вычисляет условие при компиляции и, если оно ложно, просто не включает код в программу:
(compwhen (= :macos (os/which))
(def mac-only :yes))
На других системах этого определения в скомпилированном коде не будет вовсе. Не “не выполнится”, а не появится.
Чем это отличается от обычного if с тем же условием: ветка if для чужой платформы всё равно компилируется. Если внутри неё имя, которого на этой системе не существует (функция из нативного модуля, который собирается только на маке), компиляция упадёт с unknown symbol ещё до запуска, хотя ветка никогда бы не выполнилась. compwhen выкидывает код до этого этапа, и потому остаётся единственным способом держать платформенно-зависимые ветки в одном файле.
Диагностика собственных макросов
Макрос может сообщить компилятору предупреждение, так же как встроенные формы. Для этого есть maclintf:
(defmacro careful [x]
(when (number? x)
(maclintf :normal "аргумент %p это константа, макрос здесь не нужен" x))
~,x)
file.janet:9:8: compile warning (normal): аргумент 42 это константа, макрос здесь не нужен
Предупреждение приходит с точным местом вызова, тем самым, которое иначе было бы потеряно в раскрытии. Для собственных библиотек макросов это лучший способ подсказать пользователю, что он держит инструмент не тем концом.
Когда остановиться
Метапрограммирование затягивает: каждая новая абстракция кажется победой. Держи под рукой честные признаки того, что ты зашёл слишком далеко.
Раскрытие невозможно прочитать. Если macex1 выдаёт то, в чём ты сам разбираешься минуту, читатель кода не разберётся никогда.
Ошибки указывают в никуда. Когда сообщение ссылается на сгенерированный код, отладка превращается в археологию. maclintf помогает, но не спасает полностью.
Макрос понадобился в третьем месте по-другому. Значит, абстракция угадана неверно. Обычная функция с параметрами почти всегда гибче.
Ты пишешь макрос, чтобы сэкономить строки. Это худшая из причин. Экономия исчисляется десятками символов, а цена вечная.
Практическое правило. Сначала напиши нужный код руками, три-четыре раза. Если после этого повторение всё ещё раздражает, попробуй убрать его функцией. И только если функция не справляется (нужно отложить вычисление или добраться до исходного кода), бери макрос. Почти вся стандартная библиотека Janet устроена именно так: макросов там немного, и каждый существует по одной из этих двух причин.
Что запомнить
- Макрос видит форму, а не значение. Переменная времени выполнения ему недоступна.
upscopeвозвращает несколько определений наружу,doспрячет их в своей области.symbolсобирает имя из частей, отсюда генерация аксессоров и подобного.evalво время раскрытия уместен только для того, что уже существует при компиляции.- Макрос может породить макрос, но два уровня квазицитирования это предел.
compwhenвыкидывает код до попадания в байткод.maclintfдаёт предупреждение с точным местом вызова.- Порядок такой: руками, потом функцией, и только потом макросом.
Упражнения
Дальше
Язык пройден целиком. Дальше сквозной проект, который собирает изученное вместе: утилита подсчёта статистики по логам, от пустого каталога до установленной команды.
домашка