Зипперы в Clojure (часть 2). Автонавигация
Оглавление
- Зипперы (часть 1). Азы навигации
- Зипперы (часть 2). Автонавигация
- Зипперы (часть 3). XML-зипперы
- Зипперы (часть 4). Поиск в XML
- Зипперы (часть 5). Редактирование
- Зипперы (часть 6). Виртуальные деревья. Обмен валют
- Зипперы (часть 7). Обход в ширину. Улучшенный обмен валют
- Зипперы (часть 8). Заключение
Мы разобрались с тем, как перемещаться по коллекции. Однако у читателя возникнет вопрос: как мы узнаем заранее, куда двигаться? Откуда приходит путь?
Ответ покажется странным, но все же: ручная навигация по данным лишена всякого смысла. Если путь известен заранее, вам не нужен зиппер — это лишнее усложнение.
Clojure предлагает более простую работу с данными, структура которых
известна. Например, если мы точно знаем, что на вход поступил вектор, второй
элемент которого вектор, и нужно взять его второй элемент, воспользуемся
get-in
:
(def data [1 [2 3] 4])
(get-in data [1 1])
;; 3
То же самое касается других типов данных. Неважно, какую какую комбинацию
образуют списки и словари: если структура известна заранее, до элемента легко
добраться с помощью get-in
или стрелочного оператора. В данном случае зипперы
только усложнят код.
(def data {:users [{:name "Ivan"}]})
(-> data :users first :name)
;; "Ivan"
В чем же тогда преимущество зипперов? Свои сильные стороны они проявляют там,
где get-in
не работает. Речь о данных с неизвестной структурой. Представьте,
что на вход поступил произвольный вектор, и нужно найти в нём строку. Она может
быть как на поверхности вектора, так и вложена на три уровня. В этом случае
get-in
не поможет, потому что мы не знаем путь. Другой пример —
XML-документ. Нужный тег может располагаться где угодно, и нужно как-то его
найти. Таким образом, идеальный случай для зиппера — нечёткая структура данных,
о которой у нас только предположения.
Функции zip/up
, zip/down
и другие образуют универсальную zip/next
. Эта
функция передвигает указатель так, что рано или поздно мы обойдем всю
структуру. При обходе исключены повторы: мы побываем в каждом месте только
раз. Пример с вектором:
(def vzip (zip/vector-zip [1 [2 3] 4]))
(-> vzip zip/node)
;; [1 [2 3] 4]
(-> vzip zip/next zip/node)
;; 1
(-> vzip zip/next zip/next zip/node)
;; [2 3]
(-> vzip zip/next zip/next zip/next zip/node)
;; 2
Очевидно, мы не знаем, сколько раз вызывать zip/next
, поэтому пойдём на
хитрость. Функция iterate
принимает функцию f
и значение x
. Результатом
станет последовательность, где первый элемент x
, а каждый следующий — f(x)
от предыдущего. Для зиппера мы получим исходную локацию, затем zip/next
от
неё, затем zip/next
от прошлого шага и так далее.
Переменная loc-seq
ниже – это цепочка локаций исходного зиппера. Чтобы
получить узлы, мы берём шесть первых элементов (число взяли случайно) и вызываем
для каждого zip/node
.
(def loc-seq (iterate zip/next vzip))
(->> loc-seq
(take 6)
(map zip/node))
;; ([1 [2 3] 4]
;; 1
;; [2 3]
;; 2
;; 3
;; 4)
Iterate
порождает ленивую и бесконечную последовательность. Обе
характеристики важны. Ленивость означает, что очередной сдвиг (вызов zip/next
)
не произойдёт до тех пор, пока вы не дойдёте до элемента в
цепочке. Бесконечность означает, что zip/next
вызывается неограниченное число
раз. Понадобится признак, по которому мы остановим вызов zip/next
, иначе поток
локаций никогда не закончится.
Если исследовать loc-seq
, станет ясно, что в какой-то момент zip/next
уже не
сдвигает указатель. Возьмём наугад сотый и тысячный элементы итерации. Их узел
будет исходным вектором:
(-> loc-seq (nth 100) zip/node)
;; [1 [2 3] 4]
(-> loc-seq (nth 1000) zip/node)
;; [1 [2 3] 4]
Причина кроется в устройстве зиппера. Функция zip/next
работает по принципу
кольца. Когда она достигает исходной локации, цикл завершается. При этом локация
получит признак завершения, и дальнейший вызов zip/next
вернёт её
же. Проверить признак можно функцией zip/end?
:
(def loc-end
(-> [1 2 3]
zip/vector-zip
zip/next
zip/next
zip/next
zip/next))
loc-end
;; [[1 2 3] :end]
(zip/end? loc-end)
;; true
Чтобы получить конечную цепь локаций, будем сдвигать указатель до тех пор, пока
локация не последняя. Всё вместе даёт функцию iter-zip
:
(defn iter-zip [zipper]
(->> zipper
(iterate zip/next)
(take-while (complement zip/end?))))
Функция вернёт все локации от начальной до конечной не включая ее. Напомним, что
локация хранит узел (элемент данных), который можно извлечь с помощью
zip/node
. Код ниже показывает, как превратить локации в данные:
(->> [1 [2 3] 4]
zip/vector-zip
iter-zip
(map zip/node))
;; ([1 [2 3] 4]
;; 1
;; [2 3]
;; 2
;; 3
;; 4)
Теперь когда мы получили цепочку локаций, напишем поиск. Предположим, нужно
проверить, есть ли в векторе кейворд :error
. Сначала напишем предикат для
локации – равен ли её узел этому значению:
(defn loc-error? [loc]
(-> loc zip/node (= :error)))
Проверим, если ли среди локаций та, что подходит нашему предикату. Для этого
вызовем some
:
(def data [1 [2 3 [:test [:foo :error]]] 4])
(some loc-error?
(-> data zip/vector-zip iter-zip))
;; true
Из-за ленивости мы не сканируем вектор целиком. Если нужный узел нашелся на
середине, iter-zip
прекращает итерацию, и дальнейшие вызовы zip/next
не
произойдут.
Полезно знать, что zip/next
обходит дерево в глубину. При движении он
стремится вниз и вправо, а наверх поднимается лишь когда шаги в эти стороны
невозможны. Как мы увидим дальше, в некоторых случаях порядок обхода
важен. Попадаются задачи, где мы должны двигаться вширь. По умолчанию в
clojure.zip
нет других вариантов обхода, но мы напишем собственный. Также мы
рассмотрим задачу, где понадобится обход вширь.
Встроенный зиппер vector-zip
служит для вложенных векторов. Но гораздо чаще
встречаются вложенные словари. Напишем зиппер для обхода подобных данных:
(def map-data
{:foo 1
:bar 2
:baz {:test "hello"
:word {:nested true}}})
За основу возьмём знакомый нам vector-zip
. Зипперы похожи, разница лишь в типе
коллекции. Подумаем, как задать функции branch?
и children
. Сам по себе
словарь — это ветка, чью потомки — элементы MapEntry
. Тип MapEntry
выражает
пару ключа и значения. Если значение — словарь, получим из него цепочку
вложенных MapEntry
и так далее.
Для разминки напишем проверку на тип MapEntry
:
(def entry?
(partial instance? clojure.lang.MapEntry))
Зиппер map-zip
выглядит так:
(defn map-zip [mapping]
(zip/zipper
(some-fn entry? map?)
(fn [x]
(cond
(map? x)
(seq x)
(and (entry? x) (-> x val map?))
(-> x val seq)))
nil
mapping))
Поясним основные моменты. Композиция (some-fn ...)
вернёт истину, если хотя бы
один из предикатов сработает положительно. Иными словами, на роль ветки мы
рассматриваем только словарь или его узел (пару ключ-значение).
Во второй функции, которая ищет потомков, приходится делать перебор. Для словаря
(проверка map?
) получим потомков функцией seq
– она вернёт цепочку
элементов MapEntry
. Если текущий элемент – MapEntry
, проверим, является ли
его значение вложенным словарём (функция var
вернет второй элемент
MapEntry
). Если да, получим потомков той же функцией seq
.
Обход зиппера вернет все пары ключей и значений. Если значение — вложенный словарь, мы провалимся в него при обходе. Пример:
(->> {:foo 42
:bar {:baz 11
:user/name "Ivan"}}
map-zip
iter-zip
rest
(map zip/node))
;; ([:foo 42]
;; [:bar {:baz 11, :user/name "Ivan"}]
;; [:baz 11]
;; [:user/name "Ivan"])
Обратите внимание на функцию rest
после iter-zip
. Мы отбросили первую
локацию, в которой находятся исходные данные. Поскольку они известны, нет смысла
печатать их.
С помощью нашего map-zip
легко проверить, есть ли в словаре ключ :error
со
значением :auth
. По отдельности эти кейворды могут быть где угодно — и в
ключах, и в значениях на любом уровне. Однако нас интересует их комбинация. Для
этого напишем предикат:
(defn loc-err-auth? [loc]
(-> loc zip/node (= [:error :auth])))
Убедимся, что в первом словаре нет пары, даже не смотря на то, что значения встречаются по отдельности:
(->> {:response {:error :expired
:auth :failed}}
map-zip
iter-zip
(some loc-err-auth?))
;; nil
Но даже если пара вложена глубоко, мы найдём её:
(def data
{:response {:info {:message "Auth error"
:error :auth
:code 1005}}})
(->> data
map-zip
iter-zip
(some loc-err-auth?))
;; true
Предлагаем читателю несколько заданий для самостоятельной работы.
1) Зиппер map-zip
не учитывает случай, когда ключ словаря – другой словарь,
например:
{{:alg "MD5" :salt "***"} "deprecated"
{:alg "SHA2" :salt "****"} "deprecated"
{:alg "HMAC-SHA256" :key "xxx"} "ok"}
Такие коллекции хоть и редко, но встречаются в практике. Доработайте map-zip
,
чтобы он проверял не только значение MapEntry
, но и ключ (вместо val
используйте key
).
2) Мы рассмотрели зипперы для векторов и словарей по отдельности. На практике мы работаем со смешанными данными, когда словари и векторы вложены друг в друга. Напишите универсальный зиппер, который учитывает обе коллекции при обходе.
(Продолжение следует)
Оглавление
- Зипперы (часть 1). Азы навигации
- Зипперы (часть 2). Автонавигация
- Зипперы (часть 3). XML-зипперы
- Зипперы (часть 4). Поиск в XML
- Зипперы (часть 5). Редактирование
- Зипперы (часть 6). Виртуальные деревья. Обмен валют
- Зипперы (часть 7). Обход в ширину. Улучшенный обмен валют
- Зипперы (часть 8). Заключение
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter