В этой статье мы поговорим о различных способах условной гидратации/декорирования существующей map дополнительными данными. Мы рассмотрим различные подходы и то, как они влияют на читаемость и производительность кода.
Вдохновением для этой статьи послужил этот замечательный доклад и его концепция возможности визуализации формы ваших данных.
Давайте же начнем с данных, которые нам нужно будет гидратировать:
(def heavy-ship-data
{:ship-class "Heavy"
:name "Thunder"
:main-systems {:engine {:type "Ion"}}})
(def light-ship-data
{:ship-class "Light"
:name "Lightning"
:main-systems {:engine {:type "Flux"}}})
С условной гидратации данных нам может помочь макрос cond->
:
(defn ready-ship-cond->
[{class :ship-class :as ship-data
{{engine-type :type} :engine} :main-systems}]
(cond-> ship-data
(= class "Heavy") (assoc-in [:main-systems :shield :type]
"Heavy shield")
(= engine-type "Flux") (assoc-in [:main-systems :engine :fuel]
"Fusion cells")
(= engine-type "Flux") (assoc-in [:name] "Fluxmaster")
true (assoc-in [:main-systems :engine :upgrade]
"Neutron spoils")
true (assoc-in [:main-systems :turret]
{:type "Auto plasma incinerator"})))
Правда здесь есть несколько субъективных минусов. Во-первых, неочевидна форма данных, во-вторых, дублирование путей для элементов, у которых часть пути общая.
Но это достаточно хорошо работает. Мы делаем условный assoc-in
значений в map.
(ready-ship-cond-> heavy-ship-data)
=>
{:ship-class "Heavy",
:name "Thunder",
:main-systems
{:engine {:type "Ion", :upgrade "Neutron spoils"},
:shield {:type "Heavy shield"},
:turret {:type "Auto plasma incinerator"}}}
(ready-ship-cond-> light-ship-data)
=>
{:ship-class "Light",
:name "Fluxmaster",
:main-systems
{:engine
{:type "Flux", :fuel "Fusion cells", :upgrade "Neutron spoils"},
:turret {:type "Auto plasma incinerator"}}}
А что, если мы захотим сделать так, чтобы этот код больше походил на форму данных, которые он на самом деле представляет. Давайте представим функцию foo-merge
, которая будет вызываться следующим образом:
(foo-merge
ship-data
{:main-systems {:turret {:type "Auto plasma incinerator"}
:engine {:upgrade "Neutron spoils"
:fuel (when (= engine-type "Flux")
"Fusion cells")}
:shield {:type (when (= class "Heavy")
"Heavy shield")}}
:name (when (= engine-type "Flux") "Fluxmaster")})
Лично я нахожу это более удобочитаемым. Мы избавились от дублирующихся путей, и теперь наш ввод соответствует форме данных.
Также для foo-merge
нам нужно реализовать функцию deep-merge
, которая может объединять вложенные map:
(defn deep-merge
[& maps]
(if (every? map? maps) (apply merge-with deep-merge maps) (last maps)))
Нам также нужно реализовать функцию, которая удаляет нулевые значения. Поскольку поведение cond->
подразумевает, что он не будет ассоциировать нулевые значения:
(defn remove-nils
[m]
(clojure.walk/postwalk
(fn [x]
(if (map? x)
(->> (keep (fn [[k v]] (when (nil? v) k)) x)
(apply dissoc x))
x))
m))
Мы наконец можем реализовать deep-merge-no-nils
, который будет иметь желаемое поведение:
(defn deep-merge-no-nils
[& maps]
(apply deep-merge (remove-nils maps)))
А вот и новая реализация нашего гидратора ready-ship:
(defn ready-ship-deep-merge-no-nils
[{class :ship-class :as ship-data
{{engine-type :type} :engine} :main-systems}]
(deep-merge-no-nils
ship-data
{:main-systems {:turret {:type "Auto plasma incinerator"}
:engine {:upgrade "Neutron spoils"
:fuel (when (= engine-type "Flux")
"Fusion cells")}
:shield {:type (when (= class "Heavy")
"Heavy shield")}}
:name (when (= engine-type "Flux") "Fluxmaster")}))
Она работает не совсем так, как мы ожидаем, поскольку приводит к вставке пустых map в некоторых случаях :shield {}
:
(= (ready-ship-cond-> heavy-ship-data)
(ready-ship-deep-merge-no-nils heavy-ship-data))
=> true
(= (ready-ship-cond-> light-ship-data)
(ready-ship-deep-merge-no-nils light-ship-data))
=> false
(clojure.data/diff
(ready-ship-cond-> light-ship-data)
(ready-ship-deep-merge-no-nils light-ship-data))
=>
(nil
{:main-systems {:shield {}}}
{:main-systems
{:turret {:type "Auto plasma incinerator"},
:engine
{:type "Flux", :fuel "Fusion cells", :upgrade "Neutron spoils"}},
:name "Fluxmaster",
:ship-class "Light"})
Прежде чем мы рассмотрим способы решения этой пограничной ситуации, давайте выясним, какова производительность ready-ship-deep-merge-no-nils
по сравнению с оригинальной реализацией ready-ship-cond->
.
Для этого мы используем criterium — отличную библиотеку для проведения замеров производительности в clojure:
(require '[criterium.core :as c])
(c/bench (ready-ship-cond-> heavy-ship-data))
=>
...
Execution time mean : 738.743093 ns
...
(c/bench (ready-ship-deep-merge-no-nils heavy-ship-data))
=>
...
Execution time mean : 16.707967 µs
...
Оказалось, что deep-merge
и clojure.walk/postwalk
стоят недешево, и это привело к тому, что реализация ready-ship-deep-merge-no-nils
оказалась в 22 раза медленнее, чем реализация ready-ship-cond->
.
Вот мы и подобрались к самому интересному. Когда у вас есть визуальное представление кода, которое вам нравится, и реализация, которая выглядит не так красиво, но более производительна, вы можете использовать макрос, чтобы получить лучшее из обеих реализаций. Макросы позволяют переписывать код во время компиляции, переходя от представления, которое вам нравится, к реализации, которая хорошо работает.
Как нам перейти от нашего представления map к реализации cond->
и assoc-in
? Сначала нам понадобятся пути к каждому терминальному (листовому) узлу в нашей map:
(defn all-paths [m]
(letfn [(all-paths [m path]
(lazy-seq
(when-let [[[k v] & xs] (seq m)]
(cond (and (map? v) (not-empty v))
(into (all-paths v (conj path k))
(all-paths xs path))
:else
(cons [(conj path k) v]
(all-paths xs path))))))]
(all-paths m [])))
Эта функция возвращает список кортежей, содержащих путь и значение для каждого листового значения во вложенной map.
(all-paths {:ship-class "Heavy"
:name "Thunder"
:main-systems {:engine {:type "Ion"}
:shield {:type "Phase"}}}
=>
([[:ship-class] "Heavy"]
[[:name] "Thunder"]
[[:main-systems :shield :type] "Phase"]
[[:main-systems :engine :type] "Ion"])
Затем мы можем написать макрос, который создает список let-биндингов и условий, которые можно передать в let
и cond->
:
(defmacro cond-merge [m1 m2]
(assert (map? m2))
(let [path-value-pairs (all-paths m2)
symbol-pairs (map (fn [pair] [(gensym) pair]) path-value-pairs)
let-bindings (mapcat (fn [[s [_ v]]] [s v]) symbol-pairs)
conditions (mapcat (fn [[s [path _]]]
[`(not (nil? ~s)) `(assoc-in ~path ~s)])
symbol-pairs)]
`(let [~@let-bindings]
(cond-> ~m1
~@conditions))))
Проще понять, что происходит в этом макросе, можно используя macroexpand-1
:
(macroexpand-1 '(cond-merge {:a 1} {:b (when true 3) :c false }))
(clojure.core/let
[G__26452 (when true 3) G__26453 false]
(clojure.core/cond->
{:a 1}
(clojure.core/not (clojure.core/nil? G__26452))
(clojure.core/assoc-in [:b] G__26452)
(clojure.core/not (clojure.core/nil? G__26453))
(clojure.core/assoc-in [:c] G__26453)))
По сути, мы присваиваем значения m1
только в том случае, если значение не равно nil
, где значение может быть результатом выражения:
(defn ready-ship-cond-merge
[{class :ship-class :as ship-data
{{engine-type :type} :engine} :main-systems}]
(cond-merge
ship-data
{:main-systems {:turret {:type "Auto plasma incinerator"}
:engine {:upgrade "Neutron spoils"
:fuel (when (= engine-type "Flux")
"Fusion cells")}
:shield {:type (when (= class "Heavy")
"Heavy shield")}}
:name (when (= engine-type "Flux") "Fluxmaster")}))
Мало того, что реализация ready-ship-cond-merge
дает точно такой же результат, как и ready-ship-cond->
:
(= (ready-ship-cond-> heavy-ship-data)
(ready-ship-cond-merge heavy-ship-data))
=> true
(= (ready-ship-cond-> light-ship-data)
(ready-ship-cond-merge light-ship-data))
=> true
Она при этом не уступает ей в производительности!
(c/bench (ready-ship-cond-merge heavy-ship-data))
=>
...
Execution time mean : 775.762294 ns
...
Хотя стоит отметить, что макрос cond-merge
имеет некоторые ограничения/неожиданное поведение, когда дело доходит до вложенных условий и условий, возвращающих map’ы. Это может привести к перезаписи данных, а не к их объединению. В приведенном ниже примере :b
больше не содержит :e
3. Это то, что может сделать assoc-in
, но не может сделать deep-merge.
(cond-merge {:a 1
:b {:e 3}}
{:b (when true {:c 1 :d 2})
:c false})
=>
{:a 1
:b {:c 1 :d 2}
:c false}
Если вы разделите условия для каждого значения, то получите ожидаемый результат.
(cond-merge {:a 1
:b {:e 3}}
{:b {:c (when true 1)
:d (when true 2)}
:c false})
=>
{:a 1
:b {:e 3
:c 1
:d 2}
:c false}
В этой статье мы рассмотрели, как представить код как данные и использовать макросы для создания более читабельного представления, которое передает форму наших выходных данных. Это улучшение эргономики без ущерба для производительности. Мы также узнали, что правильно понять семантику макросов может быть не всегда просто.
Материал подготовлен в преддверии старта онлайн-курса "Clojure Developer".