Как написать собственные макросы defn с помощью clojure.spec

clojure.spec — это базовая библиотека, которая позволяет программистам задавать структуру своих данных, проверять и разрушать их, а также генерировать данные на основе спецификации.

Если вы не знакомы с clojure.specобязательно прочитайте спецификация Обоснование и руководство по спецификациям.

Если вы хотите получить более глубокие знания о Clojure, начните читать мой Получите программирование с Clojure забронируйте сегодня!

Одна из крутых особенностей clojure.spec заключается в том, что мы можем анализировать аргументы функций и макросов в виде абстрактного синтаксического дерева (AST) вперед и назад, используя conform а также unform.

В этой статье мы собираемся показать, как можно написать свой собственный defn-подобный макрос, использующий спецификации для defn.

Сначала мы покажем, как:

  1. разобрать аргументы defn макрос в AST
  2. изменяет дерево AST
  3. преобразует его обратно в формат defn ожидает.

Затем мы воспользуемся этой идеей, чтобы написать три пользовательских defn как макросы:

  • defndoc: автоматическое обогащение строки документации
  • defnlog: автоматическая регистрация вызовов функций
  • defntry: автоматический отлов исключений

Дерево

соответствовать и формировать

С clojure.spec мы можем анализировать аргументы функций и макросов в виде абстрактного синтаксического дерева (AST) назад и вперед, используя conform а также unform:

  1. conform получает данные и спецификации и деструктирует их в AST
  2. unform принимает AST и возвращает данные

Основная идея этой статьи заключается в том, что в clojure.spec, conform а также unform взаимны в том смысле, что (unform spec (conform spec x)) равно x.

Давайте посмотрим, какие примеры conform а также unform.

Во-первых, мы требуем clojure.spec.

(require '[clojure.spec.alpha :as s])

давай поиграем с conform а также unform в простой спецификации, которая получает список из двух элементов, следующих за этой спецификацией:

  1. либо строка, либо ключевое слово
  2. число
(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 указывает список, состоящий из:

  1. имя функции (:fn-name), который должен быть символом
  2. необязательно: строка документа (:docstring) это должна быть строка
  3. необязательно: метаданные (`:meta), которые должны быть картой
  4. хвост функции (: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:

  1. он имеет множественность
  2. это вариационная функция
  3. он предоставляет строку документации
  4. у него есть метаданные.

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

  1. Преобразование аргументов в дерево
  2. Изменение :docstring часть дерева
  3. Расформирование спины

Вот код в действии:

(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, как профессионал!

Похожие записи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *