Возможен ли "прозрачный" макроклет? - PullRequest
14 голосов
/ 30 августа 2011

Я хотел бы написать макрос Clojure with-test-tags, который обернет множество форм и добавит некоторые метаданные к имени каждой формы deftest - в частности, добавит некоторые вещи к ключу :tags, чтобыЯ могу поиграть с инструментом для запуска тестов с определенным тегом.

Одна очевидная реализация для with-test-tags - это рекурсивный обход всего тела, изменение каждой формы deftest, как я ее нахожу.Но я недавно читал Let Over Lambda , и он делает хороший вывод: вместо того, чтобы самостоятельно обходить код, просто оберните код в macrolet и позвольте компилятору пройти его за вас.Что-то вроде:

(defmacro with-test-tags [tags & body]
  `(macrolet [(~'deftest [name# & more#]
                `(~'~'deftest ~(vary-meta name# update-in [:tags] (fnil into []) ~tags)
                   ~@more#))]
     (do ~@body)))

(with-test-tags [:a :b] 
  (deftest x (...do tests...)))

Однако существует очевидная проблема: макрос deftest продолжает рекурсивно расширяться вечно.Я мог бы вместо этого развернуть его до clojure.test/deftest, что позволило бы избежать дальнейших рекурсивных расширений, но тогда я не смогу с пользой вкладывать экземпляры with-test-tags для маркировки подгрупп тестов.

На данный момент, особеннодля чего-то такого простого, как deftest, похоже, что сам ход кода будет проще.Но мне интересно, знает ли кто-нибудь метод написания макроса, который «слегка модифицирует» некоторые подвыражения, не возвращаясь навсегда.

Для любопытных: я рассмотрел некоторые другие подходы, такие как время компиляции bindingпеременная, которую я устанавливаю, когда я иду вверх и вниз по коду, и использую эту переменную, когда я наконец вижу deftest, но поскольку каждый макрос возвращает только одно расширение, его привязки не будут установлены для следующего вызоваmacroexpand.

Edit

Я только что реализовал postwalk, и пока он работает, он не учитывает специальные формы, такие как quote - он расширяется и внутри них.

(defmacro with-test-tags [tags & body]
  (cons `do
        (postwalk (fn [form]
                    (if (and (seq? form)
                             (symbol? (first form))
                             (= "deftest" (name (first form))))
                      (seq (update-in (vec form) [1]
                                      vary-meta update-in [:tags] (fnil into []) tags))
                      form))
                  body)))

(Также, извините за возможный шум на теге common-lisp - я подумал, что вы могли бы помочь с более странными макросами даже при минимальном опыте Clojure.)

1 Ответ

5 голосов
/ 31 августа 2011

(Это новый подход, без eval - и binding. Как обсуждалось в комментарии к этому ответу, использование eval проблематично, потому что это предотвращает закрытие тестов над лексической средой, которой они кажутся определяется в (т. е. (let [x 1] (deftest easy (is (= x 1)))) нет дольше работает). Я оставляю оригинальный подход в нижней половине ответ, ниже горизонтального правила.)

macrolet подход

Осуществление

Протестировано с Clojure 1.3.0-бета2; вероятно, он должен работать с 1.2.x как хорошо.

(ns deftest-magic.core
  (:use [clojure.tools.macro :only [macrolet]]))

(defmacro with-test-tags [tags & body]
  (let [deftest-decl
        (list 'deftest ['name '& 'body]
              (list 'let ['n `(vary-meta ~'name update-in [:tags]
                                         (fnil into #{}) ~tags)
                          'form `(list* '~'clojure.test/deftest ~'n ~'body)]
                    'form))
        with-test-tags-decl
        (list 'with-test-tags ['tags '& 'body]
              `(list* '~'deftest-magic.core/with-test-tags
                      (into ~tags ~'tags) ~'body))]
    `(macrolet [~deftest-decl
                ~with-test-tags-decl]
       ~@body)))

Использование

... лучше всего продемонстрировать набор (проходящих) тестов:

(ns deftest-magic.test.core
  (:use [deftest-magic.core :only [with-test-tags]])
  (:use clojure.test))

;; defines a test with no tags attached:
(deftest plain-deftest
  (is (= :foo :foo)))

(with-test-tags #{:foo}

  ;; this test will be tagged #{:foo}:
  (deftest foo
    (is true))

  (with-test-tags #{:bar}

    ;; this test will be tagged #{:foo :bar}:
    (deftest foo-bar
      (is true))))

;; confirming the claims made in the comments above:
(deftest test-tags
  (let [plaintest-tags (:tags (meta #'plain-deftest))]
    (is (or (nil? plaintest-tags) (empty? plaintest-tags))))
  (is (= #{:foo} (:tags (meta #'foo))))
  (is (= #{:foo :bar} (:tags (meta #'foo-bar)))))

;; tests can be closures:
(let [x 1]
  (deftest lexical-bindings-no-tags
    (is (= x 1))))

;; this works inside with-test-args as well:
(with-test-tags #{:foo}
  (let [x 1]
    (deftest easy (is true))
    (deftest lexical-bindings-with-tags
      (is (= #{:foo} (:tags (meta #'easy))))
      (is (= x 1)))))

Примечания к дизайну:

  1. Мы хотим сделать дизайн на основе macrolet, описанный в работа над текстом вопроса. Мы заботимся о возможности гнездиться with-test-tags и сохранение возможности определения тестов чьи тела близко к лексической среде они определены в.

  2. Мы будем macrolet TING deftest, чтобы расширить до clojure.test/deftest форма с соответствующими метаданными, прикрепленными к название теста. Важной частью здесь является то, что with-test-tags вставляет соответствующий набор тегов прямо в определение пользовательский локальный deftest внутри формы macrolet; однажды компилятор приступает к расширению форм deftest, наборы тегов будет встроен в код.

  3. Если оставить все как есть, тесты, определенные внутри вложенного with-test-tags будет помечен только тегами, переданными внутренняя with-test-tags форма. Таким образом, мы также имеем with-test-tags macrolet символ with-test-tags сам ведёт себя очень похоже местный deftest: расширяется до вызова на высшем уровне with-test-tags макрос с соответствующими тегами, введенными в множества ярлыков.

  4. Намерение состоит в том, что внутренняя with-test-tags форма в

    (with-test-tags #{:foo}
      (with-test-tags #{:bar}
        ...))
    

    развернуть до (deftest-magic.core/with-test-tags #{:foo :bar} ...) (если действительно deftest-magic.core это пространство имен with-test-tags определяется в). Эта форма сразу превращается в знакомую Форма macrolet, с символами deftest и with-test-tags локально привязан к макросам с правильными наборами тегов, встроенными внутри их.


(Оригинальный ответ обновлен некоторыми примечаниями по дизайну, некоторые перефразирование, переформатирование и т. д. Код не изменился.)

Подход binding + eval.

(см. Также https://gist.github.com/1185513 для версии дополнительно используя macrolet, чтобы избежать пользовательского верхнего уровня deftest.)

Осуществление

Следующие тестируются для работы с Clojure 1.3.0-бета2; с ^:dynamic часть удалена, она должна работать с 1.2:

(ns deftest-magic.core)

(def ^:dynamic *tags* #{})

(defmacro with-test-tags [tags & body]
  `(binding [*tags* (into *tags* ~tags)]
     ~@body))

(defmacro deftest [name & body]
  `(let [n# (vary-meta '~name update-in [:tags] (fnil into #{}) *tags*)
         form# (list* 'clojure.test/deftest n# '~body)]
     (eval form#)))

Использование

(ns example.core
  (:use [clojure.test :exclude [deftest]])
  (:use [deftest-magic.core :only [with-test-tags deftest]]))

;; defines a test with an empty set of tags:
(deftest no-tags
  (is true))

(with-test-tags #{:foo}

  ;; this test will be tagged #{:foo}:
  (deftest foo
    (is true))

  (with-test-tags #{:bar}

    ;; this test will be tagged #{:foo :bar}:
    (deftest foo-bar
      (is true))))

Примечания к дизайну

Я думаю, что в этом случае разумное использование eval приводит к полезное решение. Базовый дизайн (на основе "binding -able Var" идея) состоит из трех компонентов:

  1. Динамически связываемый Var - *tags* - который связывается при компиляции время для набора тегов, которые будут использоваться deftest формами для украшения тесты определяются. Мы не добавляем теги по умолчанию, поэтому его начальный значение #{}.

  2. Макрос with-test-tags, который устанавливает соответствующий для *tags*.

  3. Пользовательский макрос deftest, который расширяется до let формы, напоминающей это (следующее расширение, слегка упрощенное для ясность):

    (let [n    (vary-meta '<NAME> update-in [:tags] (fnil into #{}) *tags*)
          form (list* 'clojure.test/deftest n '<BODY>)]
      (eval form))
    

    <NAME> и <BODY> - аргументы, переданные обычаю deftest, вставленный в соответствующие места через кавычки соответствующие части шаблона расширения с синтаксическими кавычками.

Таким образом, расширение пользовательского deftest представляет собой let форму, в которой во-первых, название нового теста готовится путем украшения символ с метаданными :tags; тогда форма clojure.test/deftest используя это украшенное имя построено; и, наконец, последняя форма вручается eval.

Ключевым моментом здесь является то, что выражения (eval form) здесь оценивается всякий раз, когда пространство имен, в котором они содержатся, является AOT-скомпилированным или требуется в первый раз во время жизни JVM, работающей с этим код. Это точно так же, как (println "asdf") вверхний уровень (def asdf (println "asdf")), который будет печатать asdf всякий раз, когда пространство имен AOT-компилируется или требуется в первый раз ;на самом деле, (println "asdf") верхнего уровня действует аналогично.

Это объясняется тем, что в Clojure компиляция - это просто оценка всех форм верхнего уровня.В (binding [...] (deftest ...), binding является формой верхнего уровня, но она возвращается только тогда, когда deftest делает, и наш пользовательский deftest расширяется до формы, которая возвращается, когда eval.(С другой стороны, способ require выполняет код верхнего уровня в уже скомпилированных пространствах имен - так что если в вашем коде есть (def t (System/currentTimeMillis)), значение t будет зависеть от того, когда вам требуется ваше пространство имен, а нечем когда он был скомпилирован, что можно определить, экспериментируя с AOT-скомпилированным кодом - это именно то, как работает Clojure. Используйте read-eval, если хотите, чтобы в код были встроены фактические константы.пользовательский deftest запускает компилятор (через eval) во время выполнения макрокоманды во время компиляции.Fun.

Наконец, когда форма deftest помещается внутри формы with-test-tags, form из (eval form) будет подготовлен с привязками, установленными with-test-tags на месте.Таким образом, определяемый тест будет украшен соответствующим набором тегов.

На REPL

user=> (use 'deftest-magic.core '[clojure.test :exclude [deftest]])
nil
user=> (with-test-tags #{:foo}
         (deftest foo (is true))
         (with-test-tags #{:bar}
           (deftest foo-bar (is true))))
#'user/foo-bar
user=> (meta #'foo)
{:ns #<Namespace user>,
 :name foo,
 :file "NO_SOURCE_PATH",
 :line 2,
 :test #<user$fn__90 user$fn__90@50903025>,
 :tags #{:foo}}                                         ; <= note the tags
user=> (meta #'foo-bar)
{:ns #<Namespace user>,
 :name foo-bar,
 :file "NO_SOURCE_PATH",
 :line 2,
 :test #<user$fn__94 user$fn__94@368b1a4f>,
 :tags #{:foo :bar}}                                    ; <= likewise
user=> (deftest quux (is true))
#'user/quux
user=> (meta #'quux)
{:ns #<Namespace user>,
 :name quux,
 :file "NO_SOURCE_PATH",
 :line 5,
 :test #<user$fn__106 user$fn__106@b7c96a9>,
 :tags #{}}                                             ; <= no tags works too

И просто для уверенности, что рабочие тесты определены ...

user=> (run-tests 'user)

Testing user

Ran 3 tests containing 3 assertions.
0 failures, 0 errors.
{:type :summary, :pass 3, :test 3, :error 0, :fail 0}
...