Раздел 31 · Janet на практике

Макро-шалости: генерация API и условная компиляция

senior~30 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Макро-шалости: генерация 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 даёт предупреждение с точным местом вызова.
  • Порядок такой: руками, потом функцией, и только потом макросом.

Упражнения

Дальше

Язык пройден целиком. Дальше сквозной проект, который собирает изученное вместе: утилита подсчёта статистики по логам, от пустого каталога до установленной команды.

домашка

Домашка