Зипперы в Clojure (часть 5). Редактирование
Оглавление
- Зипперы (часть 1). Азы навигации
- Зипперы (часть 2). Автонавигация
- Зипперы (часть 3). XML-зипперы
- Зипперы (часть 4). Поиск в XML
- Зипперы (часть 5). Редактирование
- Зипперы (часть 6). Виртуальные деревья. Обмен валют
- Зипперы (часть 7). Обход в ширину. Улучшенный обмен валют
- Зипперы (часть 8). Заключение
До сих пор мы игнорировали другую возможность зипперов. Во время обхода можно не только читать, но и менять локации. В широком плане нам доступны все операции CRUD (Create, Read, Update, Delete), знакомые из веб-разработки. Ниже мы разберем, как они работают в зипперах.
Напомним, зиппер принимает третью функцию make-node
, в которую до сих пор мы
передавали nil
. В ней не было нужды, потому что мы только читали
данные. Зиппер вызовет функцию в момент, когда мы просим вернуть данные с учётом
изменений, которые внесли в локации. Функция принимает два параметра: ветку и
потомков. Ее задача — соединить их должным образом.
Для простых коллекций вроде вектора функция проста — нужно только обернуть
потомков в vec
, чтобы получить из последовательности вектор. В vector-zip
функция чуть сложнее, потому что учитывает метаданные. Приведём код этого
зиппера без сокращений:
(defn vector-zip
[root]
(zipper vector?
seq
(fn [node children]
(with-meta (vec children) (meta node)))
root))
Видим, что новый вектор (форма (vec children)
) копирует метаданные прежнего
вектора (переменная node
). Если вы дополняете оригинал через assoc
или
conj
, метаданные сохраняются. В случае с vector-zip
мы строим новый вектор,
поэтому оборачиваем его в with-meta
. Если убрать with-meta
, на выходе
получим вектор без метаданных, что может повлиять на логику программы.
У XML-зиппера сборка иная: потомков помещают в поле :content
.
(fn [node children]
(assoc node :content (and children (apply vector children))))
Для нашего map-zip
, который работает со словарями, функция сборки выглядела бы
как assoc
или into
с набором пар MapEntry
.
Зиппер неявно вызывает эту функцию, если находит изменённые узлы. Для изменения
служат функции zip/edit
, zip/replace
и другие. Но перед тем, как рассмотреть
их, объясним, как именно протекают изменения в зиппере.
Особенность в том, что изменения сказываются не на исходных данных, а на
локациях. Если изменить текущую локацию, она помечается флагом :changed?
. Это
сигнал к пересборке данных с помощью функции zip/root
, о которой скажем чуть
позже.
Рассмотрим пример с вектором [1 2 3]
. Переместимся на двойку и удвоим её с
помощью функции zip/edit
. Она принимает локацию, функцию и остаточные
аргументы – подход, знакомый вам из атомов (swap!
) и коллекций (update
). По
аналогии с ними, локация получит новое значение, которое вернула функция на базе
прежнего.
Локация до изменений:
(-> [1 2 3]
zip/vector-zip
zip/down
zip/right)
[2 {:l [1] :pnodes [[1 2 3]] :ppath nil :r (3)}]
и после. Обратите внимание ключ :changed?
:
(def loc-2
(-> [1 2 3]
zip/vector-zip
zip/down
zip/right
(zip/edit * 2)))
[4 {:l [1] :pnodes [[1 2 3]] :ppath nil :r (3)
:changed? true}]
Далее нам бы хотелось получить изменённый вектор [1 4 3]
. Сделаем это вручную:
(-> loc-2
zip/up
zip/node)
;; [1 4 3]
То же самое делает функция zip/root
, которая принимает локацию с
изменениями. Её алгоритм следующий:
- подняться до первичной локации, повторно вызывая
zip/up
; - вернуть узел.
Чтобы получить результат за один проход, добавим zip/root
на конец стрелочного
оператора:
(-> [1 2 3]
zip/vector-zip
zip/down
zip/right
(zip/edit * 2)
zip/root)
;; [1 4 3]
Основная работа происходит в функции zip/up
, которую мы вызвали вручную или
неявно в zip/root
. При подъёме вверх она проверяет, была ли изменена локация,
и если да, перестраивает её с помощью make-node
. Приведём её код в сокращении:
(defn up
[loc]
(let [[node {... changed? :changed? :as path}] loc]
(when pnodes
(let [pnode (peek pnodes)]
(with-meta (if changed?
[(make-node loc pnode (concat l ...))
(and ppath (assoc ...))]
[pnode ppath])
(meta loc))))))
Множественные изменения
При изменении одной локации проблем не возникает. Однако мы редко изменяем одну локацию – на практике это делают по признаку, то есть пакетно.
Ранее мы раскладывали зиппер в цепочку локаций с помощью iter-zip
, а затем
пропускали через серию map
, filter
и других функций. Для редактирования этот
метод не подходит. Предположим, мы выбрали второй элемент из результата
zip-iter
и исправили его:
(def loc-seq
(-> [1 2 3]
zip/vector-zip
iter-zip))
(-> loc-seq (nth 2) (zip/edit * 2))
;; [4 {:l [1] :pnodes [[1 2 3]] :ppath nil :r (3)
;; :changed? true}]
Функция zip-iter
устроена так, что каждая следующая локация получается из
предыдущей. Вызов zip/edit
на одном из элементов не повлияет на
последующие. Если подняться вверх от последней локации, получим вектор без
изменений.
(-> loc-seq last zip/up zip/node)
;; [1 2 3]
Как видно из примера, выражение с zip/edit
не повлияло на результат.
При редактировании зипперов применяют следующие паттерны.
Изменяется один элемент. В этом случае мы итерируем зиппер до тех пор, пока
не встретим нужную локацию в цепочке. Затем меняем её и вызываем zip/root
.
Изменяются многие элементы. С помощью loop
и zip/next
мы вручную
итерируем зиппер. При этом задана функция, которая либо меняет локацию, любо
оставляет нетронутой. В форму recur
попадает zip/next
от её результата. Если
изменения были, zip/next
оттолкнётся от новой, а не исходной локации.
Для изменения локаций служат функции:
zip/replace
— буквальная замена текущего узла на другой.zip/edit
— Редактирование узла. По аналогии сupdate
иswap!
принимает функцию и добавочные аргументы. Первым аргументом функция получит текущей узел. Результат заменит содержимое локации.zip/remove
— Удаляет локацию и перемещает указатель на родителя.
Функции для вставки соседей и потомков:
zip/insert-left
— добавить соседа слева от текущей локации;zip/insert-right
— добавить соседа справа;zip/insert-child
— добавить текущей локации потомка в начало;zip/append-child
— добавить потомка в конец.
Разница между соседом и потомком в иерархии. Сосед находится на одном уровне с
локацией, а потомок ниже. В центре диаграммы находится локация с вектором [2
3]
. Её соседи – числа 1 и 4, а потомки – 2 и 3.
┌─────────────┐
│ [1 [2 3] 4] │
└─────────────┘
▲
│
┌───────┐ ┏━━━━━━━━━━━┓ ┌───────┐
│ 1 │◀───┃ [2 3] ┃───▶│ 4 │
└───────┘ ┗━━━━━━━━━━━┛ └───────┘
│
┌─────┴─────┐
▼ ▼
┌───────┐ ┌───────┐
│ 2 │ │ 3 │
└───────┘ └───────┘
Рассмотрим функции на простых примерах. Предположим, в глубине вложенных
векторов находится ключ :error
, и нужно исправить его на :ok
. Сперва добавим
предикат для поиска:
(defn loc-error? [loc]
(some-> loc zip/node (= :error)))
Теперь ищем локацию, исправляем её и поднимаемся к корню:
(def data [1 2 [3 4 [5 :error]]])
(def loc-error
(->> data
zip/vector-zip
iter-zip
(find-first loc-error?)))
(-> loc-error
(zip/replace :ok)
zip/root)
;; [1 2 [3 4 [5 :ok]]]
Другой пример — поменять во вложеном векторе все nil
на 0, чтобы обезопасить
математические расчеты. На этот раз локация может быть не одна, поэтому
понадобится обход через loop
. На каждом шаге мы проверяем, подходит ли
локация, и если да, передаём в recur
вызов zip/next
от изменённой версии:
(def data [1 2 [5 nil 2 [3 nil]] nil 1])
(loop [loc (zip/vector-zip data)]
(if (zip/end? loc)
(zip/node loc)
(if (-> loc zip/node nil?)
(recur (zip/next (zip/replace loc 0)))
(recur (zip/next loc)))))
;; [1 2 [5 0 2 [3 0]] 0 1]
То же самое, но заменить все отрицательные числа по модулю. Для начала объявим
функцию abs
(с версии 1.11 она встроена в Clojure):
(defn abs [num]
(if (neg? num)
(- num)
num))
Обход похож на предыдущий, но теперь вместо zip/replace
мы вызываем
zip/edit
, который обновляет содержимое локации, отталкиваясь от прежнего
значения:
(def data [-1 2 [5 -2 2 [-3 2]] -1 5])
(loop [loc (zip/vector-zip data)]
(if (zip/end? loc)
(zip/node loc)
(if (and (-> loc zip/node number?)
(-> loc zip/node neg?))
(recur (zip/next (zip/edit loc abs)))
(recur (zip/next loc)))))
В обоих случаях цикл выглядит одинаково. Если это конечная локация, вернём её
узел. В противном случае, если локация подходит предикату, меняем ее содержимое
с помощью zip/edit
. От изменённой локации переходим к следующей. Это ключевой
момент: в предпоследней строке вызов zip/next
принимает результат zip/edit
,
а не исходную локацию. Поэтому изменения будут переданы в следующий шаг loop
.
Примеры выше образуют паттерны — повторяющиеся приёмы. Поместим их в отдельные функции, чтобы не тратить на них внимание в будущем.
Поиск локации по предикату. Принимает начальную локацию и предикат, начинает итерацию. Вернёт первую же локацию, которая подошла предикату:
(defn find-loc [loc loc-pred]
(->> loc
iter-zip
(find-first loc-pred)))
Обход локаций с изменениями. Перебирает локации с помощью zip/next
и
loop/recur
. При переходе на следующий шаг оборачивает локацию в
функцию. Ожидается, что функция либо изменит локацию, либо вернёт её без
изменений. Это обобщённая версия цикла, что мы написали выше.
(defn alter-loc [loc loc-fn]
(loop [loc loc]
(if (zip/end? loc)
loc
(-> loc loc-fn zip/next recur))))
Перепишем примеры с новыми функциями. Найдём в векторе локацию, чей узел равен двойке:
(defn loc-2? [loc]
(-> loc zip/node (= 2)))
(def loc-2
(-> [1 2 3]
zip/vector-zip
(find-loc loc-2?)))
Удвоим её и выйдем на конечный вектор:
(-> loc-2 (zip/edit * 2) zip/root)
;; [1 4 2]
Изменим отрицательные числа по модулю. Для этого заведём функцию loc-abs
. Если
в узле отрицательное число, вернём исправленную локацию, а иначе — исходную:
(defn loc-abs [loc]
(if (and (-> loc zip/node number?)
(-> loc zip/node neg?))
(zip/edit loc abs)
loc))
Осталось передать её в alter-loc
:
(-> [-1 2 [5 -2 2 [-3 2]] -1 5]
zip/vector-zip
(alter-loc loc-abs)
zip/node)
;; [1 2 [5 2 2 [3 2]] 1 5]
Как только мы вынесли часть кода в функции, преобразования стали короче и понятней. Как правило, любое действие над зиппером можно обобщить и поместить в функцию, тем самым упростив логику.
Цены в XML
Перейдём к практическим примерам с XML и товарами. Подготовим следующий файл
products-price.xml
:
<?xml version="1.0" encoding="UTF-8"?>
<catalog>
<organization name="re-Store">
<product type="fiber" price="8.99">VIP Fiber Plus</product>
<product type="iphone" price="899.99">iPhone 11 Pro</product>
</organization>
<organization name="DNS">
<branch name="Office 2">
<bundle>
<product type="fiber" price="9.99">Premium iFiber</product>
<product type="iphone" price="999.99">iPhone 11 Pro</product>
</bundle>
</branch>
</organization>
</catalog>
Обратите внимание, что у товаров появились цены – характеристика, которая часто меняется.
Напомним, что с точки зрения Clojure XML – это вложенные словари с ключами
:tag
, :attrs
и :content
. После изменений мы бы хотели видеть его в
привычном, текстовом виде. Понадобится обратное действие — из структуры данных
получить XML в виде текста. Для этого импортируем модуль clojure.xml
. Его
функция emit
выводит XML на печать.
Часто emit
оборачивают в with-out-str
— макрос для перехвата печати в
строку. В примерах ниже мы просто выводим XML в консоль. Emit
не поддерживает
отступы, поэтому мы добавили их вручную для ясности.
Первая задача — сделать скидку 10% на все айфоны. У нас готовы почти все абстракции, так что опишем решение сверху вниз:
(require '[clojure.xml :as xml])
(-> "products-price.xml"
->xml-zipper
(alter-loc alter-iphone-price)
zip/node
xml/emit)
Этих пяти строк достаточно для нашей задачи. Под вопросом только функция
alter-iphone-price
. Ожидается, что для локации-айфона она вернёт её же, но с
другим атрибутом price
. Локация другого типа останется без изменений. Опишем
функцию:
(defn alter-iphone-price [loc]
(if (loc-iphone? loc)
(zip/edit loc alter-attr-price 0.9)
loc))
Предикат loc-iphone?
проверяет локацию на “айфонность”. Мы уже писали его в
прошлом разделе:
(defn loc-iphone? [loc]
(let [node (zip/node loc)]
(and (-> node :tag (= :product))
(-> node :attrs :type (= "iphone")))))
Осталась функция alter-attr-price
. Она принимает узел (содержимое локации) и
должна изменить его атрибут :price
. Второй аргумент функции — коэффициент, на
который нужно умножить текущую цену. Небольшая трудность в том, что атрибуты в
XML — строки. Чтобы выполнить умножение, нужно вывести число из строки, умножить
на коэффициент, а результат привести в строку с округлением до двух цифр. Все
вместе даёт нам функцию:
(defn alter-attr-price [node ratio]
(update-in node [:attrs :price]
(fn [price]
(->> price
read-string
(* ratio)
(format "%.2f")))))
Быстрая проверка этой функции:
(alter-attr-price {:attrs {:price "10"}} 1.1)
;; {:attrs {:price "11.00"}}
Теперь когда известны все компоненты, запустим первое выражение и получим XML:
<?xml version="1.0" encoding="UTF-8"?>
<catalog>
<organization name="re-Store">
<product price="8.99" type="fiber">VIP Fiber Plus</product>
<product price="809.99" type="iphone">iPhone 11 Pro</product>
</organization>
<organization name="DNS">
<branch name="Office 2">
<bundle>
<product price="9.99" type="fiber">Premium iFiber</product>
<product price="899.99" type="iphone">iPhone 11 Pro</product>
</bundle>
</branch>
</organization>
</catalog>
Видим, что цена на айфоны изменилась на 10%, а у остальных товаров осталась прежней.
Более сложная задача – во все наборы (бандлы) добавить новый товар — гарнитуру. Опять же, опишем решение сверху вниз:
(-> "products-price.xml"
->xml-zipper
(alter-loc add-to-bundle)
zip/node
xml/emit)
Решение отличается только функций add-to-bundle
. Её логика следующая: если
текущая локация — набор, добавить ему потомка, а если нет, просто вернуть
локацию.
(defn add-to-bundle [loc]
(if (loc-bundle? loc)
(zip/append-child loc node-headset)
loc))
Проверка на набор:
(defn loc-bundle? [loc]
(some-> loc zip/node :tag (= :bundle)))
Функция zip/append-child
добавляет значение в конец потомков локации. В данном
случае это узел node-headset
, который вынесли в константу:
(def node-headset
{:tag :product
:attrs {:type "headset"
:price "199.99"}
:content ["AirPods Pro"]})
Итоговый XML, где в наборах появился новый товар:
<?xml version="1.0" encoding="UTF-8"?>
<catalog>
<organization name="re-Store">
<product price="8.99" type="fiber">VIP Fiber Plus</product>
<product price="899.99" type="iphone">iPhone 11 Pro</product>
</organization>
<organization name="DNS">
<branch name="Office 2">
<bundle>
<product price="9.99" type="fiber">Premium iFiber</product>
<product price="999.99" type="iphone">iPhone 11 Pro</product>
<product price="199.99" type="headset">AirPods Pro</product>
</bundle>
</branch>
</organization>
</catalog>
Третья задача — упразднить все наборы. Возможно, руководство решило, что
продавать товары в наборах невыгодно. Что ж, бывает. Для программиста это
значит, что из XML уходят все теги <bundle>
, однако их товары должны перейти в
организацию.
И в третий раз решение отличается лишь целевой функцией:
(-> "products-price.xml"
->xml-zipper
(alter-loc disband-bundle)
zip/node
xml/emit)
Опишем алгоритм disband-bundle
. Если текущий узел — набор, сохраним его
потомков (товары) в переменную, чтобы не потерять их. Затем удалим
набор. Функция удаления вернёт предка локации, в нашем случае
организацию. Присоединим к ней товары и вернем её.
(defn disband-bundle [loc]
(if (loc-bundle? loc)
(let [products (zip/children loc)
loc-org (zip/remove loc)]
(append-childs loc-org products))
loc))
Функция append-childs
– это обёртка над встроенной
zip/append-child
. Последняя присоединяет только один элемент, что
неудобно. Чтобы присоединить их список, напишем свёртку:
(defn append-childs [loc items]
(reduce (fn [loc item]
(zip/append-child loc item))
loc
items))
Финальный XML без наборов, но с теми же товарами:
<?xml version="1.0" encoding="UTF-8"?>
<catalog>
<organization name="re-Store">
<product price="8.99" type="fiber">VIP Fiber Plus</product>
<product price="899.99" type="iphone">iPhone 11 Pro</product>
</organization>
<organization name="DNS">
<branch name="Office 2">
<product price="9.99" type="fiber">Premium iFiber</product>
<product price="999.99" type="iphone">iPhone 11 Pro</product>
</branch>
</organization>
</catalog>
Надеемся, этих примеров достаточно, чтобы читатель понял, как редактировать зипперы. Обратите внимание, что кода получилось немного: для каждой задачи мы писали в среднем три функции. Другое преимущество в том, что нет состояния. Все функции чистые, и их вызов не сказывается на данных. Если на середине выскочит исключение, дерево XML не будет частично изменённым.
(Продолжение следует)
Оглавление
- Зипперы (часть 1). Азы навигации
- Зипперы (часть 2). Автонавигация
- Зипперы (часть 3). XML-зипперы
- Зипперы (часть 4). Поиск в XML
- Зипперы (часть 5). Редактирование
- Зипперы (часть 6). Виртуальные деревья. Обмен валют
- Зипперы (часть 7). Обход в ширину. Улучшенный обмен валют
- Зипперы (часть 8). Заключение
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter