Зипперы в 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/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/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
:
(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/next
. В противном случае, если в локации отрицательное
число, мы меняем содержимое с помощью zip/edit
. От изменённой локации мы
переходим к следующей. Это ключевой момент: в предпоследней строке вызов
zip/next
принимает результат zip/edit
, а не исходную локацию. Значит,
изменения в ней будут переданы на следующий шаг.
Примеры выше позволяют увидеть паттерны — повторяющиеся приёмы. Поместим их в отдельные функции, чтобы не тратить на них внимание в будущем.
Поиск локации по предикату. Принимает начальную локацию и предикат, начинает итерацию. Вернёт первую же локацию, которая подошла предикату:
(defn find-loc [loc loc-pred]
(->> loc
iter-zip
(find-first loc-pred)))
Прогон локаций с изменениями. Перебирает локации с помощью zip/next
и
loop/recur
. При переходе на следующий шаг оборачивает локацию в
функцию. Ожидается, что функция либо изменит локацию, либо вернёт её без
изменений. Это обобщённая версия loop
, что мы написали выше.
(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
. Она принимает узел (содержимое локации) и
должна изменить его атрибут. Второй аргумент функции — коэффициент, на который
нужно умножить текущую цену. Небольшая трудность в том, что атрибуты в 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