Эта статья поможет вам шаг за шагом (или даже
посимвольно ) через процесс написания макросов в Clojure. Я сосредоточусь на основных макроэкономических характеристиках, объясняя, что происходит за кулисами. Представьте, что вы собираетесь написать библиотеку утверждений для Clojure, аналогичную утверждениям FEST, утверждениям ScalaTest или Hamcrest . Конечно, такие существуют, но это только для образовательных целей. В первую очередь нам нужна функция assert-equals
используемая следующим образом:
1
2
|
( assert -equals (count (filter even? primes)) 1 ) |
Конечно, это более чем тривиально:
1
2
3
4
5
|
(defn assert -equals [actual expected] (when-not (= actual expected) ( throw (AssertionError. (str "Expected " expected " but was " actual))))) |
Быстрый тест с неправильно заданным вектором primes
:
1
2
3
4
5
|
user=> (def primes [ 0 2 3 5 7 11 ]) #'user/primes user=> ( assert -equals (count (filter even? primes)) 1 ) AssertionError Expected 1 but was 2 |
Круто, но представьте, что этот тест не пройден на сервере CI или вы видите это в своем терминале. Там нет контекста, может быть, вы получите тестовое имя, если вам повезет. « Expected 1 but was 2
» ничего не говорит нам о природе или первопричине проблемы. Не было бы замечательно увидеть:
1
|
AssertionError Expected '(count (filter even? primes))' to be 1 but was 2 |
Ты видишь это? Ошибка утверждения теперь дает нам полное выражение, которое дало неверный результат. С первой секунды мы видим, в чем может быть проблема. Однако есть проблема. Большой. К тому времени, когда мы генерируем AssertionError
, исходное выражение теряется. У нас есть
actual
значение в качестве аргумента, и мы понятия не имеем, откуда взялось это значение. Это может быть константа, результат выражения вроде (count (filter even? primes))
или даже случайное значение. Аргументы функции вычисляются с нетерпением, и нет никакого способа получить доступ к коду, который произвел эти аргументы.
Ввод макросов
Макросы и функции в Clojure не являются независимыми или ортогональными. На самом деле они практически одинаковы
- Функции выполняются во время выполнения , они принимают и генерируют данные (значения). Концептуально каждый (чистый) вызов функции можно заменить ее значением.
- Макросы выполняются во время компиляции , они берут и создают код . Концептуально можно заменить ( развернуть ) каждое вхождение макроса его значением.
Не так ли отличается? Более того, поскольку Clojure гомоичен , код Clojure можно представить в виде структур данных Clojure. Другими словами, и функции, и макросы принимают данные, но в случае макросов чаще всего можно видеть источник Clojure, представленный с использованием структур данных, таких как списки.
Что все это значит и как это может помочь нам? Давайте сразу же приступим к написанию нашего первого (неправильного) макроса и постепенно улучшим его, чтобы в итоге достичь желаемого результата. Чтобы сфокусировать образцы, я пропускаю выдачу AssertionError
и оставляю только условие равенства:
1
2
3
4
5
6
7
8
|
user=> (defmacro assert -equals [actual expected] (= expected actual)) #'user/ assert -equals user=> ( assert -equals 2 2 ) true user=> ( assert -equals 2 3 ) false |
Работает? На самом деле мы очень далеки от правильной версии:
1
2
3
4
5
6
7
|
user=> ( assert -equals (inc 5 ) 6 ) false user=> (def x 1 ) #'user/x user=> ( assert -equals (+ x 2 ) 3 ) false |
1 + 2
определенно равно 3
, но возвращает false. Чтобы оценить это поведение и назвать его « функцией », а не « ошибкой », мы должны глубоко понимать, что только что произошло. Помните, макросы выполняются во время компиляции, верно? И они почти обычные функции. Итак, компилятор выполняет assert-equals
. Однако во время компиляции он не может знать значения переменных, таких как x
, поэтому он не может охотно оценивать аргументы макроса. Мы даже не хотим этого, как вы увидите позже.
Вместо этого компилятор передает код Clojure, в буквальном смысле . actual
параметр (inc 5)
— буквально, список Clojure, содержащий два элемента: символ inc
и 5
число. Это все, что нужно сделать. expected
это просто число. Это означает, что внутри макроса у нас есть полный доступ к исходному коду Clojure, заключенному в этот макрос.
Так что, может быть, теперь вы можете догадаться, что происходит. Компилятор Clojure выполняет определение макроса, то есть (= expected actual)
. Что касается компилятора, actual
является список (inc 5)
то время как expected
номер 6
. Список никогда не может быть равен числу. Таким образом, макрос возвращает false
, как и любая другая функция может его вернуть. Позже компилятор Clojure заменяет выражение (assert-equals (inc 5) 6)
на результат макроса, который оказывается… false
. Ранее мы говорили, что макрос должен возвращать действительный код Clojure (представленный с использованием структур данных Clojure). false
— действительный код Clojure!
Теперь мы знаем, что вместо оценки (= expected actual)
компилятором (в конце концов, мы не хотим, чтобы компилятор запускал наши утверждения, мы только хотим скомпилировать их!), Мы просто хотим вернуть код, который представляет это утверждение. Это не так сложно!
1
|
(defmacro assert -equals [actual expected] (list '= expected actual)) |
Теперь наш макрос возвращает результат вычисления (list '= expected actual)
выражение. Результат оказывается… (= expected actual)
. Правильно, это похоже на действительный код Clojure, опять же. Добавлена дополнительная кавычка ( '=
), так что =
интерпретируется как необработанный символ, а не как ссылка на функцию. Давайте возьмем это для тест-драйва:
1
2
3
4
|
user=> ( assert -equals (inc 5 ) 6 ) true user=> (macroexpand '( assert -equals (inc 5 ) 6 )) (= 6 (inc 5 )) |
macroexpand
и macroexpand-1
— ваше оружие выбора при отладке макросов. Здесь вы видите, что (assert-equals (inc 5) 6)
фактически заменяется на (= 6 (inc 5))
. Этот процесс происходит во время компиляции, макросы не существуют во время выполнения . В вашем скомпилированном коде у вас осталось (= 6 (inc 5))
. Итак, давайте восстановим полную функциональность броска AssertionError
. Как вы уже знаете, наш макрос должен возвращать код Clojure, который включает проверку на равенство и выдачу исключения. Это становится немного громоздким:
1
2
3
4
5
|
(defmacro assert -equals [actual expected] (list 'when-not (list ' = actual expected) (list ' throw (list 'AssertionError. (list 'str "Expected " expected " but was " actual))))) |
Обратите внимание, как каждый символ должен быть экранирован ( 'when-not
'throw
, 'AssertionError.
,…), Иначе компилятор попытается оценить его во время компиляции. Кроме того, список в Clojure обозначает вызов функции, поэтому мы должны продолжить каждый литерал списка с помощью вызова (list ...)
функции. Если вы не знакомы с Clojure: (list 1 2)
возвращает список (1 2)
а (1 2)
сгенерирует исключение, поскольку 1
число не является функцией.
Гадкий или нет, это работает:
1
2
3
4
|
user=> ( assert -equals (inc 5 ) 6 ) nil user=> ( assert -equals 5 6 ) AssertionError Expected 6 but was 5 |
Мы едва воспроизвели то, что делала оригинальная функция assert-equals
, и первая команда написания макросов такова: не пишите макросы, если функции достаточно. Но прежде чем идти дальше, давайте очистим то, что имеем до сих пор. Типичное определение макроса состоит из большого количества кода Clojure, который должен быть экранирован, и не такого большого количества actual
значений, как actual
и expected
в нашем случае. Таким образом, существует разумное значение по умолчанию — вместо цитирования всего, кроме нескольких элементов, цитируйте все заранее и выборочно ставьте кавычки . Это называется синтаксическим цитированием (с использованием `символа), а снятие кавычек выполняется через оператор ~
. Посмотрите внимательно: мы синтаксически цитируем весь результат и выборочно снимаем цитаты с того, что ранее не цитировалось:
1
2
3
4
5
|
(defmacro assert -equals [actual expected] `(when-not (= ~actual ~expected) ( throw (AssertionError. (str "Expected " ~expected " but was " ~actual))))) |
Это эквивалентно предыдущему определению, но выглядит намного лучше, почти полностью как действительный код Clojure. Давайте используем macroexpand-1
чтобы увидеть, как расширяется наш макрос во время компиляции. macroexpand
будет работать, но, так как when-not
также является макросом (!), он будет рекурсивно расширен, загромождая вывод:
1
2
3
4
5
6
|
user=> (macroexpand- 1 '( assert -equals (inc 5 ) 6 )) (when-not (= (inc 5 ) 6 ) ( throw (java.lang.AssertionError. (str "Expected " 6 " but was " (inc 5 ))))) |
Это как язык шаблонов, встроенный в этот язык! Обратите внимание, как (inc 5)
кусок кода был вставлен вместо ~actual
дважды. Запомни. Также экспериментируйте, удалив символ кавычки ( ~
) здесь или там. Используйте macroexpand-1
чтобы выяснить, что происходит. Помните, нашей конечной целью было показать actual
выражение во всей его красе, а не только его ценность.
1
2
|
(AssertionError. (str "Expected '???' to be " ~expected " but was " actual-value#)))))) |
Что мы должны поставить вместо ???
напечатать строку « (inc 5)
». Мы знаем, что значение actual
не 6
а список из двух элементов: (inc 5)
. Можем ли мы как-нибудь процитировать этот список снова, чтобы он больше не оценивался во время выполнения, а вместо этого обрабатывался как структура данных? Конечно, мы умеем цитировать вещи!
1
2
3
4
5
6
|
(defmacro assert -equals [actual expected] `(let [~'actual-value ~actual] (when-not (= ~'actual-value ~expected) ( throw (AssertionError. (str "Expected '" '~actual "' to be " ~expected " but was " ~'actual-value)))))) |
'~actual
, о дорогой! цитата цитата актуальная . Это переводится как '(inc 5)
. Вот и все! Посмотрите, как описательные сообщения об ошибках утверждения:
1
2
3
4
5
|
user=> ( assert -equals (inc 5 ) 5 ) AssertionError Expected '(inc 5)' to be 5 but was 6 user=> ( assert -equals (count (filter even? primes)) 1 ) AssertionError Expected '(count (filter even? primes))' to be 1 but was 2 |
Развертывание этого макроса вручную показывает, как он переводится компилятором (отредактировано для улучшения читабельности):
1
2
3
4
5
6
|
user=> (macroexpand- 1 '( assert -equals (inc 5 ) 5 )) (when-not (= (inc 5 ) 5 ) ( throw (java.lang.AssertionError. (str "Expected '" (quote (inc 5 )) "' to be " 5 " but was " (inc 5 ))))) |
Здесь действительно нет магии, мы могли бы написать это сами. Но макросы избегают много повторяющихся работ.
Привязки в макросах
Наше решение пока имеет одну серьезную проблему. Представьте, что мы тестируем нечистую или медленную функцию следующим образом:
1
2
3
4
5
|
(def question "Answer to the Ultimate Question of Life, The Universe, and Everything" ) (defn answer [q] ( do (println "Computing for 7½ million years..." ) 41 )) |
Как вы можете видеть, он возвращает неправильный результат , который легко проверить в модульном тесте:
1
2
3
4
5
|
user=> ( assert -equals (answer question) 42 ) Computing for 7 ½ million years... Computing for 7 ½ million years... AssertionError Expected '(answer question)' to be 42 but was 41 |
Сообщение об ошибке в порядке, но обратите внимание, что выражение « Computing...
» было напечатано дважды. Понятно, потому что функция нечистого answer
была вызвана также дважды. Расширение макроса показывает, почему:
1
2
3
4
5
6
|
user=> (macroexpand- 1 '( assert -equals (answer question) 42 )) (when-not (= (answer question) 42 ) ( throw (java.lang.AssertionError. (str "Expected '" (quote (answer question)) "' to be " 42 " but was " (answer question))))) |
(answer question)
появляется дважды (не считая quote
d один), один раз во время сравнения и второй раз, когда мы генерируем сообщение подтверждения. Это редко желательно, особенно когда у тестируемой функции есть побочные эффекты. Решение простое: предварительно вычислить (answer question)
один раз, сохранить его где-нибудь и ссылаться, когда это необходимо. Но есть поворот: объявить привязки внутри макросов сложно. Иногда вы можете столкнуться с неожиданным затенением и переопределением имен, когда имена переменных внутри макроса сталкиваются с теми, которые используются в пользовательском коде. Не вдаваясь в подробности, достаточно использовать (gensym)
или удобный суффикс #
, чтобы обеспечить безопасность наших макросов. В обоих случаях компилятор Clojure выдаст уникальные имена, убедившись, что они не конфликтуют. Наше окончательное решение выглядит так:
1
2
3
4
5
6
7
|
(defmacro assert -equals [actual expected] `(let [actual-value# ~actual] (when-not (= actual-value# ~expected) ( throw (AssertionError. (str "Expected '" '~actual "' to be " ~expected " but was " actual-value#)))))) |
На этот раз привязка actual-value#
используется для вычисления actual
только один раз:
1
2
3
4
5
6
7
|
user=> (macroexpand- 1 '( assert -equals (answer question) 42 )) (let [actual-value__264__auto__ (answer question)] (when-not (= actual-value__264__auto__ 42 ) ( throw (java.lang.AssertionError. (str "Expected '" (quote (answer question)) "' to be " 42 " but was " actual-value__264__auto__))))) |
Замена суффикса символом #
гарантирует, что actual-value
не вступит в противоречие с любым другим символом.
Резюме
Наш assert-equals
не самый полный, как и этот урок. Но это дает вам некоторое представление о том, что могут делать макросы и как они работают. Если вам нужны дополнительные ресурсы, ознакомьтесь с этим замечательным учебником по макросам (части 2 и 3 ). Если вам нравится идея расширенных утверждений, Power Assertions в Groovy еще более всеобъемлющие. Но я уверен, что это поведение можно воспроизвести в макросах Clojure!