Оглавление

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

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

Оглавление