Как написать собственные макросы defn с помощью clojure.spec
clojure.spec
— это базовая библиотека, которая позволяет программистам задавать структуру своих данных, проверять и разрушать их, а также генерировать данные на основе спецификации.
Если вы не знакомы с clojure.spec
обязательно прочитайте спецификация Обоснование и руководство по спецификациям.
Если вы хотите получить более глубокие знания о Clojure, начните читать мой Получите программирование с Clojure забронируйте сегодня!
Одна из крутых особенностей clojure.spec
заключается в том, что мы можем анализировать аргументы функций и макросов в виде абстрактного синтаксического дерева (AST) вперед и назад, используя conform
а также unform
.
В этой статье мы собираемся показать, как можно написать свой собственный defn
-подобный макрос, использующий спецификации для defn
.
Сначала мы покажем, как:
- разобрать аргументы
defn
макрос в AST - изменяет дерево AST
- преобразует его обратно в формат
defn
ожидает.
Затем мы воспользуемся этой идеей, чтобы написать три пользовательских defn
как макросы:
defndoc
: автоматическое обогащение строки документацииdefnlog
: автоматическая регистрация вызовов функцийdefntry
: автоматический отлов исключений
соответствовать и формировать
С clojure.spec
мы можем анализировать аргументы функций и макросов в виде абстрактного синтаксического дерева (AST) назад и вперед, используя conform
а также unform
:
conform
получает данные и спецификации и деструктирует их в ASTunform
принимает AST и возвращает данные
Основная идея этой статьи заключается в том, что в clojure.spec
, conform
а также unform
взаимны в том смысле, что (unform spec (conform spec x))
равно x
.
Давайте посмотрим, какие примеры conform
а также unform
.
Во-первых, мы требуем clojure.spec
.
(require '[clojure.spec.alpha :as s])
давай поиграем с conform
а также unform
в простой спецификации, которая получает список из двух элементов, следующих за этой спецификацией:
- либо строка, либо ключевое слово
- число
(s/def ::str-or-kw (s/alt :str string? :kw keyword?))
(s/def ::my-spec (s/cat :first ::str-or-kw :second number?))
Давайте посмотрим, как conform
уничтожает данные:
(s/conform ::my-spec '(:a 1))
{:first [:kw :a]
:second 1}
И когда мы звоним unform
мы возвращаем исходные данные:
(->> (s/conform ::my-spec '(:a 1)) (s/unform ::my-spec))
(:a 1)
Зацепы с соответствием/неформой
Иногда conform
а также unform
не полностью встроены.
Взгляните на это:
(->> (s/conform ::my-spec [:a 1]) (s/unform ::my-spec))
;; (:a 1)
[:a 1]
является действительным ::my-spec
но он не сформирован как список, а не как вектор.
Один из способов исправить это — использовать spec/conformer
как это:
(s/def ::my-spec-vec (s/and vector?
(s/conformer vec vec)
(s/cat
:first ::str-or-kw
:second number?)))
В настоящее время, [:a 1]
не сформирован как вектор:
(->> (s/conform ::my-spec-vec [:a 1]) (s/unform ::my-spec-vec))
;; [:a 1]
Теперь перейдем к defn
вещи…
аргументы макроса defn
spec
за defn
аргументы предоставлены clojure.core.specs
пространство имен и называется :clojure.core.specs/defn-args
:
(require '[clojure.core.specs.alpha :as specs])
(s/describe ::specs/defn-args)
(cat
:fn-name
simple-symbol?
:docstring
(? string?)
:meta
(? map?)
:fn-tail
(alt
:arity-1
:clojure.core.specs.alpha/params+body
:arity-n
(cat
:bodies
(+ (spec :cljojuremy.my..core.specs.alpha/params+body))
:attr-map
(? map?))))
::specs/defn-args
указывает список, состоящий из:
- имя функции (
:fn-name
), который должен быть символом - необязательно: строка документа (
:docstring
) это должна быть строка - необязательно: метаданные (`:meta), которые должны быть картой
- хвост функции (
:fn-tail
), который должен быть либо арности 1, либо кратности
Но с этим есть проблема defn-args
спецификация: unform
а также conform
не полностью встроены (unform
возвращает списки вместо векторов).
Круто то, что мы можем патч обезьяны :defn-args
чтобы unform
а также conform
полностью встроены. Этот код вдохновлен кодом Марка Энглеберга. репозиторий в лучшем состоянии.
Если следующий фрагмент кода сбивает вас с толку, не стесняйтесь пропустить его — это не помешает вам понять остальную часть статьи.
(s/def ::specs/seq-binding-form
(s/and vector?
(s/conformer identity vec)
(s/cat :elems (s/* ::specs/binding-form)
:rest (s/? (s/cat :amp #{'&} :form ::specs/binding-form))
:as (s/? (s/cat :as #{:as} :sym ::specs/local-name)))))
(defn arg-list-unformer [a]
(vec
(if (and (coll? (last a)) (= '& (first (last a))))
(concat (drop-last a) (last a))
a)))
(s/def ::specs/param-list
(s/and
vector?
(s/conformer identity arg-list-unformer)
(s/cat :args (s/* ::specs/binding-form)
:varargs (s/? (s/cat :amp #{'&} :form ::specs/binding-form)))))
Теперь давайте посмотрим ::specs/defn-args
в действии.
Представьте, что у нас есть простая функция foo
:
(defn foo [a b]
(+ a b))
В этом случае доводы defn
макрос представляет собой список из трех элементов: (foo [a b] (+ a b))
.
Преобразуем этот список в AST с conform
:
(s/conform ::specs/defn-args '(foo [[a b]] (+ a b)))
{:fn-name foo,
:fn-tail
[:arity-1
{:params
{:args
[[:seq-destructure
{:elems [[:local-symbol a] [:local-symbol b]]}]]},
:body [:body [(+ a b)]]}]}
А теперь с довольно сложной функцией bar
:
(defn bar "bar is a multi-arity variadic function" {:private true}
([a b & c] (+ a b (first c)))
([] (bar 1 1 3)))
Обратите внимание на некоторые факты о bar
:
- он имеет множественность
- это вариационная функция
- он предоставляет строку документации
- у него есть метаданные.
Независимо от того, насколько сложна функция, мы можем преобразовать определение функции в AST с помощью conform
:
(s/conform ::specs/defn-args '(bar "bar is a multi-arity variadic function" {:private true} ([a b & c] (+ a b (first c))) ([] (bar 1 3))))
{:fn-name foo,
:fn-tail
[:arity-1
{:params
{:args
[[:seq-destructure
{:elems [[:local-symbol a] [:local-symbol b]]}]]},
:body [:body [(+ a b)]]}]}
Теперь, когда у нас есть AST, мы можем манипулировать им, как любой другой картой Clojure. Например, мы можем изменить строку документации, используя assoc
на :docstring
ключ:
(def args-ast
(s/conform ::specs/defn-args
'(bar "bar is a multi-arity variadic function"
{:private true}
([a b & c] (+ a b (first c)))
([] (bar 1 3)))))
(def the-new-args-ast
(assoc args-ast :docstring "bar has a cool docstring"))
Вот как выглядит новый AST:
{:fn-name bar,
:docstring "bar has a cool docstring",
:meta {:private true},
:fn-tail
[:arity-n
{:bodies
[{:params
{:args [[:local-symbol a] [:local-symbol b]],
:varargs {:amp &, :form [:local-symbol c]}},
:body [:body [(+ a b (first c))]]}
{:params {}, :body [:body [(bar 1 3)]]}]}]}
И мы получаем список для defn
с использованием unform
:
(s/unform ::specs/defn-args the-new-args-ast)
(bar
"bar has a cool docstring"
{:private true}
([a b & c] (+ a b (first c)))
([] (bar 1 3)))
Теперь мы можем создать defn
оператор с измененными аргументами, просто добавив defn
к списку аргументов:
(cons `defn (s/unform ::specs/defn-args the-new-args-ast))
(defn
bar
"bar has a cool docstring"
{:private true}
([a b & c] (+ a b (first c)))
([] (bar 1 3)))
Clojure — это диалект LISP: мы можем делать много интересных вещей, просто манипулируя списками!
Теперь все детали готовы для создания трех пользовательских defn
макросы:
Автоматическое обогащение строки документации
Допустим, мы хотим написать defn
как макрос с изюминкой: строка документации автоматически будет содержать имя функции, которая определена в данный момент. Без clojure.spec
вам придется вручную извлечь необязательную строку документации и повторно вставить ее в defn
. С clojure.spec
мы можем сделать намного лучше, если:
- Преобразование аргументов в дерево
- Изменение
:docstring
часть дерева - Расформирование спины
Вот код в действии:
(defmacro defndoc [& args]
(let [conf (s/conform ::specs/defn-args args)
fn-name (:fn-name conf)
new-conf (update conf :docstring #(str fn-name " is a cool function. " %))
new-args (s/unform ::specs/defn-args new-conf)]
(cons `defn new-args)))
Если строка документации не указана, создается строка документации:
(defndoc foo [a b] (+ a b))
(:doc (meta #'foo))
;; foo is a cool function.
Когда предоставляется строка документации, создается расширенная строка документации:
(defndoc foo "sum of a and b." [a b] (+ a b))
(:doc (meta #'foo))
;; foo is a cool function. sum of a and b.
Это было довольно просто, потому что нам нужно было иметь дело только со строкой документации. Следующий более сложный — мы будем иметь дело с телом функции…
Автоматическая регистрация вызовов функций
defnlog
— это макрос, определяющий функцию, которая автоматически печатает журнал при каждом вызове.
Другими словами, мы собираемся написать макрос, который изменяет тело функции. Это довольно просто, так как clojure — гомоиконичный язык: код — это данные, и им можно манипулировать как обычным списком.
Наша первая часть будет функцией prepend-log
который получает тело и имя функции и добавляет к нему вызов (print func-name "has been called)
:
(defn prepend-log [name body]
(cons `(println ~name "has been called.") body))
Наша вторая часть — это функция update-conf
который обновляет тело согласованного ::specs/defn-args
. Это немного сложно, потому что форма конфомированного объекта отличается, если функция является функцией одинарной или множественной.
Давайте посмотрим на форму ::specs/defn-args
для одной функции арности:
(s/conform ::specs/defn-args '(foo [a b] (* a b)))
{:fn-name foo,
:fn-tail
[:arity-1
{:params {:args [[:local-symbol a] [:local-symbol b]]},
:body [:body [(* a b)]]}]}
Путь тела: [:fn-tail 1 :body]
.
А теперь для функции множественности:
(s/conform ::specs/defn-args '(bar ([] (* 10 12)) ([a b] (* a b))))
{:fn-name bar,
:fn-tail
[:arity-n
{:bodies
[{:params {}, :body [:body [(* 10 12)]]}
{:params {:args [[:local-symbol a] [:local-symbol b]]},
:body [:body [(* a b)]]}]}]}
Путь тела: [:fn-tail 1 :bodies]
.
Обратите внимание, что в обоих случаях тип арности расположен по адресу [:fn-tail 0]
.
Давайте напишем update-conf
:
- В случае одиночной арности обновляем тело
- В случае мультариев, обновляем все тела
Обратите внимание, как мы разрушаем conf
чтобы получить арность.
(defn update-conf [{[arity] :fn-tail :as conf} body-update-fn]
(case arity
:arity-1 (update-in conf [:fn-tail 1 :body 1] body-update-fn)
:arity-n (update-in conf [:fn-tail 1 :bodies] (fn [bodies]
(prn bodies)
(map (fn [body] (update-in body [:body 1] body-update-fn)) bodies)))))
Все части на месте, чтобы написать наш defnlog
макрос:
(defmacro defnlog [& args]
(let [{:keys [fn-name] :as conf} (s/conform ::specs/defn-args args)
new-conf (update-conf conf (partial prepend-log (str fn-name)))
new-args (s/unform ::specs/defn-args new-conf)]
(cons `defn new-args)))
Посмотрим defnlog
в действии.
Сначала определим простую функцию fooz
:
(defnlog fooz "a very simple function" [a b] (+ a b))
И когда мы вызываем его, печатается журнал:
(fooz 55 200)
;; fooz has been called.
;; 255
Он отлично работает с деструктурированием:
(defnlog baz "a simple function" [{:keys [a b]}] (+ a b))
(baz {:a 55 :b 200})
;; baz has been called.
;; 255
А также с функциями мультиарности:
(defnlog bar ([] (* 10 12)) ([a b] (* a b))) (bar)
(bar 12 3)
;; bar has been called.
;; 36
Автоматическая попытка/поймать
Мы можем использовать точно такую же технику для создания defntry
макрос, который оборачивает тело в try/catch
block — и выбрасывает исключение с именем функции. (Особенно полезно в clojurescript
с расширенной компиляцией, где имена функций больше не доступны во время выполнения!)
Во-первых, давайте напишем wrap-try
функция, которая оборачивает тело в try/catch
блокировать:
(defn wrap-try [name body]
`((try ~@body
(catch :default ~'e
(throw (str "Exception caught in function " ~name ": " ~'e))))))
А теперь код defntry
макрос:
(defmacro defntry [& args]
(let [{:keys [fn-name] :as conf} (s/conform ::specs/defn-args args)
new-conf (update-conf conf (partial wrap-try (str fn-name)))
new-args (s/unform ::specs/defn-args new-conf)]
(cons `defn new-args)))
Давайте посмотрим на это в действии — с kool
функция, которая получает функцию и вызывает ее.
(defntry kool "aa" [a] (a))
Теперь, если мы передаем что-то, что не является функцией, мы получим красивое исключение с именем kool
функция:
(kool 2)
;; Execution error.
;; ERROR: Exception caught in function kool: TypeError:
Как прекрасно…
И так просто (но точно не легко)…
clojure.spec рулит!
Если вы хотите получить более глубокие знания о Clojure, начните читать мой Получите программирование с Clojure забронируйте сегодня!
К тому времени, когда вы закончите свой окончательный проект, вы будете разрабатывать функции, приложения и библиотеки Clojure, как профессионал!