Оглавление

До сих пор мы игнорировали другую возможность зипперов. Во время обхода можно не только читать, но и менять локации. В широком плане нам доступны все операции 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 не будет частично изменённым.

(Продолжение следует)

Оглавление