Перетворювачі

Трансформація даних

ClojureScript має великий набір інструментів для перетворення даних, що побудовані над абстракцією послідовності. Таким чином трансформація даних стає найбільш узагальненою та придатною до композиції. Подивимося, як поєднати кілька функцій для обробки колекцій в одну. У цьому розділі ми будемо використовувати як приклад наступне нескладне завдання: треба розділити грона винограду на окремі ягоди, відібрати зіпсовані та вимити решту. Ми маємо наступну колекцію грон винограду:

(def grape-clusters
  [{:grapes [{:rotten? false :clean? false}
             {:rotten? true :clean? false}]
    :color :green}
   {:grapes [{:rotten? true :clean? false}
             {:rotten? false :clean? false}]
    :color :black}])

Наша мета — підготувати виноград до вживання, а саме — розділити грона на окремі ягоди, відібрати зіпсовані та помити решту. З ClojureScript ми маємо багатий вибір інструментів для вирішення цієї задачі. Зокрема, ми можемо скористатися вже знайомими нам функціями map, filter та mapcat:

(defn split-cluster
  [c]
  (:grapes c))

(defn not-rotten
  [g]
  (not (:rotten? g)))

(defn clean-grape
  [g]
  (assoc g :clean? true))

(->> grape-clusters
     (mapcat split-cluster)
     (filter not-rotten)
     (map clean-grape))
;; => ({rotten? false :clean? true} {:rotten? false :clean? true})

У цьому прикладі ми успішно вирішили проблему відбору та миття ягід, і навіть можемо абстрагувати таку трансформацію шляхом поєднання операцій mapcat, filter та map за допомогою часткового застосування та композиції функцій.

(def process-clusters
  (comp
    (partial map clean-grape)
    (partial filter not-rotten)
    (partial mapcat split-cluster)))

(process-clusters grape-clusters)
;; => ({rotten? false :clean? true} {:rotten? false :clean? true})

Це чистий код, але він має певні вади. Наприклад, кожен виклик mapcat, filter та map споживає та створює послідовність, яка є лінивою, але тим не менш генерує проміжні результати, які не будуть використані. Кожна послідовність переходить у наступну фазу, що також повертає послідовність. Було б чудово виконати цю трансформацію за один обхід колекції grape-cluster, чи не так?

Друга проблема полягає у тому, що функція grape-cluster може працювати з будь-якою колекцією, але застосувати її до інших типів даних ми не можемо. Уявіть собі, що колекція грон винограду не буде зберігається у памʼяті — натомість вона надходить асинхронно у вигляді потоку. В такій ситуації ми не зможемо використати process-clusters, бо функції map, filter та mapcat мають конкретні імплементації залежно від типу.

Узагальнення до трансформацій процесу

Процес застосування функцій map, filter та mapcat не обовʼязково повʼязаний з конкретним типом, але ми можемо повторно реалізувати його для різних типів. Подивимося, як можна узагальнити такі процеси та зробити їх незалежними від контексту. Почнемо з реалізації наївної версії функцій map та filter та подивимося на її внутрішню реалізацію.

(defn my-map
  [f coll]
  (when-let [s (seq coll)]
    (cons (f (first s)) (my-map f (rest s)))))

(my-map inc [0 1 2])
;; => (1 2 3)

(defn my-filter
  [pred coll]
  (when-let [s (seq coll)]
    (let [f (first s)
          r (rest s)]
      (if (pred f)
        (cons f (my-filter pred r))
        (my-filter pred r)))))

(my-filter odd? [0 1 2])
;; => (1)

Як можна побачити, обидві функції очікують послідовність чи іншу структуру, що реалізує інтерфейс послідовності та повертають колекцію. Подібно до більшості рекурсивних функцій, вони можуть бути реалізовані за допомогою вже знайомої нам функції reduce. Зауважимо, що функції, які потрапляють до reduce, отримують акумулятор та певні дані та повертають новий акумулятор. З цього моменту подібні функції ми будемо називати функціями-редьюсерами.

(defn my-mapr
  [f coll]
  (reduce (fn [acc input]         ;; reducing function
            (conj acc (f input)))
          []                      ;; initial value
          coll))                  ;; collection to reduce

(my-mapr inc [0 1 2])
;; => [1 2 3]

(defn my-filterr
  [pred coll]
  (reduce (fn [acc input]         ;; reducing function
            (if (pred input)
              (conj acc input)
              acc))
          []                      ;; initial value
          coll))                  ;; collection to reduce

(my-filterr odd? [0 1 2])
;; => [1]

Попередні версії ми робили більш узагальненими, бо використання reduce дозволяє нашим функціям працювати з будь-якою структурою даних, яку можна передавати функції reduce, а не тільки з послідовностями. Але ви можете побачити, що функції my-mapr та my-filterr не знають нічого про джерело (coll), але вони тим не менш привʼязані до результату, який створюють (вектор) через вихідне значення reduce ([]) та записану в тілі функції-редьюсера операцію conj. Ми могли б зібрати результати в іншу структуру даних, наприклад, у ліниву послідовність, але для цього функції доведеться переписати.

Як зробити такі функції по-справжньому узагальненими? Вони не мають знати ані про джерело надходження даних, які трансформуватимуть, ані про результат, який створюватимуть. Чи помітили ви, що conj — це просто ще одна функція, що реалізує інтерфейс reduce? Ця функція очікує акумулятор та дані і повертає новий акумулятор. Таким чином, якщо ми визначимо параметри функції, яку використовують my-mapr та my-filterr, то ці функції не будуть знати про тип власного результату. Давайте спробуємо:

(defn my-mapt
  [f]                         ;; function to map over inputs
  (fn [rfn]                   ;; parameterised reducing function
    (fn [acc input]           ;; transformed reducing function, now it maps `f`!
      (rfn acc (f input)))))

(def incer (my-mapt inc))

(reduce (incer conj) [] [0 1 2])
;; => [1 2 3]

(defn my-filtert
  [pred]                      ;; predicate to filter out inputs
  (fn [rfn]                   ;; parameterised reducing function
    (fn [acc input]           ;; transformed reducing function, now it discards values based on `pred`!
      (if (pred input)
        (rfn acc input)
        acc))))

(def only-odds (my-filtert odd?))

(reduce (only-odds conj) [] [0 1 2])
;; => [1]

Функцій вищого порядку стає все більше, тож давайте розберемося детальніше у тому, що відбувається. Пройдемо кроки роботи my-mapt. Механізм для my-filtert схожий, тому на нього не будемо зараз витрачати час.

По-перше, my-mapt очікує функцію, що реалізує відображення значень. В нашому прикладі ми передаємо функцію inc та отримуємо нову функцію. Замінимо f на inc і подивимося, що відбувається:

(def incer (my-mapt inc))
;; (fn [rfn]
;;   (fn [acc input]
;;     (rfn acc (inc input))))
;;               ^^^

Отримана функція все ще очікує на функцію-редьюсер, якому вона передасть дані для обробки. Що ж буде, якщо ми передамо їй conj?

(incer conj)
;; (fn [acc input]
;;   (conj acc (inc input)))
;;    ^^^^

Ми отримали функцію-редьюсер, яка використовує функцію inc для обробки даних та функцію conj як редьюсер для отримання акумульованого результату. В цілому, ми визначили відображення як трансформацію для функції-редьюсера. Функція, що трансформує один редьюсер в інший, в ClojureScript називається перетворювачем.

Для ілюстрації загальності перетворювачів давайте використаємо різні джерела та призначення у виклику reduce:

(reduce (incer str) "" [0 1 2])
;; => "123"

(reduce (only-odds str) "" '(0 1 2))
;; => "1"

Нова версія map та filter, яка є перетворювачем, перетворює процес, що передає дані від джерела до призначення, але не знає нічого про те, звідки взялися дані, і куди потраплять потім. Імплементація таких функцій демонструє те, до чого ми прагнемо, а саме - незалежність від контексту.

Тепер ми знаємо більше про перетворювачі, тож спробуємо реалізувати власну версію mapcat. Ми вже маємо основну частину, а саме - перетворювач map. Завдання mapcat — обробити вхідні дані та повернути спрощену на один рівень структуру. Давайте реалізуємо катенацію перетворювача:

(defn my-cat
  [rfn]
  (fn [acc input]
    (reduce rfn acc input)))

(reduce (my-cat conj) [] [[0 1 2] [3 4 5]])
;; => [0 1 2 3 4 5]

Перетворювач my-cat повертає функцію-редьюсер, що поєднує вхідні дані з акумулятором, а саме - застосовує функцію rfn до input та використовує акумулятор (acc) як вихідне значення. mapcat — це просто композиція map та cat. Порядок, у якому проходить композиція перетворювачів, може здатися дивним, але ми згодом пояснимо, чому так відбувається.

(defn my-mapcat
  [f]
  (comp (my-mapt f) my-cat))

(defn dupe
  [x]
  [x x])

(def duper (my-mapcat dupe))

(reduce (duper conj) [] [0 1 2])
;; => [0 0 1 1 2 2]

Перетворювачі в стандартній бібліотеці ClojureScript

Певні функції стандартної бібліотеки ClojureScript, а саме - map, filter та mapcat мають унарну версію, що повертає перетворювач. Переглянемо наше визначення process-cluster та визначимо цю функцію на основі перетворювачів:

(def process-clusters
  (comp
    (mapcat split-cluster)
    (filter not-rotten)
    (map clean-grape)))

З моменту попереднього оголошення process-cluster дещо змінилося. По-перше, ми використовуємо версії mapcat, filter and map, що повертають перетворювачі, замість часткового застосування стандартних версій для роботи з послідовностями.

Також ви могли помітити, що порядок композиції змінився на протилежний. Функції зʼявляються у тому порядку, в якому виконуються. Зауважимо, що усі функції map, filter and mapcat повертають перетворювач. Функція filter трансформує функцію, що її повертає map, шляхом застосування фільтру до подальшого виконання коду, mapcat трансформує функцію, яку повертає filter, шляхом застосування відображення та катенації до подальшого виконання коду.

Однією з переваг перетворювачів є те, що їх можна комбінувати за допомогою звичайної композиції функцій. Ще більш елегантним є те, що композиція різних перетворювачів — це також перетворювач! Це означає, що написаний нами process-cluster — це перетворювач, тому ми визначили алгоритмічну, незалежну від контексту трансформацію, яку можна компонувати.

Серед функцій стандартної бібліотеки ClojureScript є багато таких, що можуть бути викликані з перетворювачами у якості аргументів. Розглянемо кілька прикладів і скористуємося для цього щойно створеним process-cluster:

(into [] process-clusters grape-clusters)
;; => [{:rotten? false, :clean? true} {:rotten? false, :clean? true}]

(sequence process-clusters grape-clusters)
;; => ({:rotten? false, :clean? true} {:rotten? false, :clean? true})

(reduce (process-clusters conj) [] grape-clusters)
;; => [{:rotten? false, :clean? true} {:rotten? false, :clean? true}]

Використання функції reduce із функцією-редьюсером як результатом перетворювача зустрічається дуже часто, тому є окрема функція, що проводить редукцію та трансформацію. Вона називається transduce. Тепер ми можемо переписати попередній виклик і провести редукцію за допомогою transduce:

(transduce process-clusters conj [] grape-clusters)
;; => [{:rotten? false, :clean? true} {:rotten? false, :clean? true}]

Ініціалізація

В останньому прикладі ми передали вихідне значення функції transduce ([]), але ми можемо цього не робити і отримати той самий результат:

(transduce process-clusters conj grape-clusters)
;; => [{:rotten? false, :clean? true} {:rotten? false, :clean? true}]

Що відбувається? Звідки функція transduce знає, яке вихідне значення використати для накопичувача, якщо ми цього значення не задавали? Ви зрозумієте, що відбувається, якщо спробуєте викликати функцію conj без аргументів.

(conj)
;; => []

Функція conj має варіант із нульовою арністью, який повертає порожній вектор. Але це не єдина функція-перетворювач, що може бути викликана без жодних аргументів. Подивимося на інші:

(+)
;; => 0

(*)
;; => 1

(str)
;; => ""

(= identity (comp))
;; => true

Функція-редьюсер з перетворювача має підтримувати також нульову арність; найчастіше при цьому буде виконуватися делегування до трансформованої функції-редьюсера. Не існує жодної реалізації нульової арності для перетворювача, який ми реалізовували до цього моменту, тому ми просто викличемо функцію-редьюсер без аргументів. Ось, як може виглядати модифікована функція my-mapt:

(defn my-mapt
  [f]
  (fn [rfn]
    (fn
      ([] (rfn))                ;; arity 0 that delegates to the reducing fn
      ([acc input]
        (rfn acc (f input))))))

При виклику версії функції-редьюсера, створеної перетворювачем, із нульовою арністю, буде здійснюватися виклик кожної вкладеної функції у версії з нульовою арністю. На певному етапі буде викликана перша функція-редьюсер. Подивимося на це на прикладі перетворювача process-clusters:

((process-clusters conj))
;; => []

Виклик версії функції із нульовою арністю проходить весь стек перетворювача та врешті-решт викликає (conj).

Перетворювачі, що зберігають стан

До цього моменту ми розглядали тільки чисто функціональні перетворювачі. Вони не мають неявного стану і поводяться передбачувано. Але існує багато функцій для трансформації даних, що мають стан, наприклад take. take очікує кількість n елементів, яку слід залишити та певну колекцію, а повертає колекцію, що містить не більш ніж n елементів.

(take 10 (range 100))
;; => (0 1 2 3 4 5 6 7 8 9)

Давайте звернемо увагу на завчасне припинення виконання функції reduce. Ми можемо огорнути акумулятор у тип reduced і сповістити таким чином функцію reduce про те, що процес обробки має бути негайно припинений у певний момент. Розглянемо приклад, в якому вхідні дані складаються в колекцію, а процес завершується як тільки акумулятор містить 10 елементів:

(reduce (fn [acc input]
          (if (= (count acc) 10)
            (reduced acc)
            (conj acc input)))
         []
         (range 100))
;; => [0 1 2 3 4 5 6 7 8 9]

Через те, що перетворювачі є модифікаціями функцій-редьюсерів, вони також використовують reduced для завчасного припинення виконання. Зверніть увагу на те, що перетворювачі, що зберігають стан, можуть бути змушені видалити більше даних перед завершенням процесу, тому мають підтримувати арність "1" у якості закінчуючого кроку. Зазвичай, як і з нульовою арністью, така арність делегує виконання до трансформованої функції-редьюсера.

Таким чином, ми можемо писати перетворювачі, такі як take. Ми будемо використовувати змінювані стани для відслідковування кількості даних, що вже надійшли, а також огорнемо акумулятор у reduced, як тільки побачимо достатньо елементів.

(defn my-take
  [n]
  (fn [rfn]
    (let [remaining (volatile! n)]
      (fn
        ([] (rfn))
        ([acc] (rfn acc))
        ([acc input]
          (let [rem @remaining
                nr (vswap! remaining dec)
                result (if (pos? rem)
                         (rfn acc input)   ;; we still have items to take
                         acc)]             ;; we're done, acc becomes the result
            (if (not (pos? nr))
              (ensure-reduced result)      ;; wrap result in reduced if not already
              result)))))))

Це спрощена версія функції take зі стандартної бібліотеки ClojureScript. Необхідно відмітити кілька нюансів, тому давайте вивчимо її роботу більш детально.

Перше, на що треба звернути увагу: ми створюємо змінюване значення в середині перетворювача. Ми створюємо це значення, як тільки отримуємо функцію-редьюсер для трансформації. Якщо б ми створили це значення раніше, ми змогли б використати функцію my-take лише один раз. Перетворювач отримує функцію-редьюсер наново кожен раз при його використанні, тому ми використовуємо редьюсер повторно та змінюване значення строюється щоразу.

(fn [rfn]
  (let [remaining (volatile! n)] ;; make sure to create mutable variables inside the transducer
    (fn
      ;; ...
)))

(def take-five (my-take 5))

(transduce take-five conj (range 100))
;; => [0 1 2 3 4]

(transduce take-five conj (range 100))
;; => [0 1 2 3 4]

Розберемо функцію-редьюсер, створену my-take. Перш за все, ми застосовуємо функцію deref до волатайла та отримуємо кількість елементів, що лишаються, та зменшуємо її для отримання наступного залишку. Якщо елементи ще лишилися, викликаємо функцію rfn і передаємо акумулятор та вхідні дані. Інакше ми вважаємо, що отримали фінальний результат.

([acc input]
  (let [rem @remaining
        nr (vswap! remaining dec)
        result (if (pos? rem)
                 (rfn acc input)
                 acc)]
    ;; ...
))

Тіло функції my-take має бути вам вже зрозумілим. Ми перевіряємо, чи лишилися необроблені елементи за допомогою (nr). Якщо ні, то огортаємо результат у reduced за допомогою функції ensure-reduced. Ця функція повертає результат або огортає значення у reduced за необхідності. Якщо обробка не закінчена, функція повертає акумульований result для подальшої обробки.

(if (not (pos? nr))
  (ensure-reduced result)
  result)

Ми побачили приклад перетворювача, що зберігає стан, але він нічого не робив на завершуючому кроці роботи. Розглянемо приклад перетворювача, який видаляє накопичене значення на останньому кроці. Реалізуємо спрощену версію partition-all, що отримує число елементів n та конвертує вхідні дані у вектори розміру n. Для кращого розуміння подивимося, що ми отримаємо від версії з арністю 2, якщо передати такій функції число та колекцію:

(partition-all 3 (range 10))
;; => ((0 1 2) (3 4 5) (6 7 8) (9))

Перетворювач, що повертає функцію з partition-all, отримує число n, та повертає перетворювач, що групує вхідні дані у вектори розміру n. Завершуючий крок перевіряє, чи є акумульований результат, та додає до результату. Ось спрощена версія функції partition-all зі стандартної бібліотеки ClojureScript, де array-list — це обгортка змінюваного масиву з JavaScript:

(defn my-partition-all
  [n]
  (fn [rfn]
    (let [a (array-list)]
      (fn
        ([] (rfn))
        ([result]
          (let [result (if (.isEmpty a)                  ;; no inputs accumulated, don't have to modify result
                         result
                         (let [v (vec (.toArray a))]
                           (.clear a)                    ;; flush array contents for garbage collection
                           (unreduced (rfn result v))))] ;; pass to `rfn`, removing the reduced wrapper if present
            (rfn result)))
        ([acc input]
          (.add a input)
          (if (== n (.size a))                           ;; got enough results for a chunk
            (let [v (vec (.toArray a))]
              (.clear a)
              (rfn acc v))                               ;; the accumulated chunk becomes input to `rfn`
            acc))))))

(def triples (my-partition-all 3))

(transduce triples conj (range 10))
;; => [[0 1 2] [3 4 5] [6 7 8] [9]]

Едукції

Едукції — це спосіб поєднання колекції та одної чи більше трансформацій, що їх можна спрощувати та перебирати, при цьому щоразу застосовуються трансформації. Якщо ми маємо колекцію, яку потрібно обробити, та трансформацію цієї колекції, що її потрібно розширити, можна передати їм едукцію і таким чином ізолювати вихідну колекцію та трансформацію. Едукції створюються за допомогою функції eduction:

(def ed (eduction (filter odd?) (take 5) (range 100)))

(reduce + 0 ed)
;; => 25

(transduce (partition-all 2) conj ed)
;; => [[1 3] [5 7] [9]]

Інші перетворювачі у стандартній бібліотеці ClojureScript

Ми познайомилися з функціями map, filter, mapcat, take та partition-all, але у ClojureScript є значно більше перетворювачів. Наведемо неповний список вартих уваги перетворювачів:

  • drop — це доповнення до take, що пропускає n значень перед тим, як передати вхідні дані до функції-редьюсера
  • distinct дозволяє лише вхідні дані, що не повторюються
  • dedupe видаляє послідовні дублікати з вхідних даних

Рекомендуємо переглянути документацію стандартної бібліотеки ClojureScript та подивитися, які ще перетворювачі представлені у мові.

Визначення власних перетворювачів

Перед написанням власних перетворювачів слід звернути увагу на певні нюанси, тому в цьому розділі ми дізнаємося, як реалізовувати перетворювачі правильно. Перш за все, ми вивчили, що у загальному випадку структура перетворювача така:

(fn [xf]
  (fn
    ([]          ;; init
      ...)
    ([r]         ;; completion
      ...)
    ([acc input] ;; step
      ...)))

Зазвичай від одного перетворювача до наступного змінюється лише код, позначений у прикладі знаком .... Ось інваріанти, що мають зберігатися з кожною арністю результуючої функції:

  • арність 0(init): має викликати нульову арність вкладеної функції xf
  • арність 1 (completion): використовується для створення фінального значення та можливого видалення станів, слід викликати арність 1 вкладеної функції xf лише один раз
  • арність 2 (step): функція-редьюсер, що викличе арність 2 вкладеної функції xf нуль, один або більше разів.

Процеси, що можуть бути перетворені

Процесом, що може бути перетворений, називають будь-який процес, що можна визначити як послідовність кроків з переробки вхідних даних. Джерело надходження вхідних даних може бути різним залежно від процесу. Більшість наведених прикладів демонстрували надходження даних з колекції або з лінивої послідовності, але дані можуть також надходити з асинхронного потоку або з каналу core.async. Результати роботи кожного кроку процесу також можуть бути різні. into створює колекцію з кожного результату перетворювача, sequence повертає ліниву послідовність, а асинхронні потоки вірогідно відправлять результат своїм слухачам.

Для покращення розуміння процесів, що можуть бути перетворені, реалізуємо необмежену чергу, бо додавання значень до неї можна представити як послідовні кроки з обробки вхідних даних. Перш за все, визначимо протокол та тип даних, що реалізує необмежену чергу:

(defprotocol Queue
  (put! [q item] "put an item into the queue")
  (take! [q] "take an item from the queue")
  (shutdown! [q] "stop accepting puts in the queue"))

(deftype UnboundedQueue [^:mutable arr ^:mutable closed]
  Queue
  (put! [_ item]
    (assert (not closed))
    (assert (not (nil? item)))
    (.push arr item)
    item)
  (take! [_]
    (aget (.splice arr 0 1) 0))
  (shutdown! [_]
    (set! closed true)))

Ми визначили протокол Queue, і ви можете помітити, що реалізація UnboundedQueue не знає про перетворювачі. Натомість для неї визначена операція put!, і ми реалізуємо процес, що може бути трансформований, на базі цього інтерфейсу:

(defn unbounded-queue
  ([]
   (unbounded-queue nil))
  ([xform]
   (let [put! (completing put!)
         xput! (if xform (xform put!) put!)
         q (UnboundedQueue. #js [] false)]
     (reify
       Queue
       (put! [_ item]
         (when-not (.-closed q)
           (let [val (xput! q item)]
             (if (reduced? val)
               (do
                 (xput! @val)  ;; call completion step
                 (shutdown! q) ;; respect reduced
                 @val)
               val))))
       (take! [_]
         (take! q))
       (shutdown! [_]
         (shutdown! q))))))

Ви можете побачити, що конструктор unbounded-queue використовує екземпляр UnboundedQueue, перехоплює виклики take! і shutdown! ті реалізує логіку процесу перетворення у функції put!. Розглянемо детальніше кожен крок.

(let [put! (completing put!)
      xput! (if xform (xform put!) put!)
      q (UnboundedQueue. #js [] false)]
  ;; ...
)

Перш за все, ми застосовуємо функцію completing для того, щоб додати функції put! протоколу Queue арність 0 та арність 1. Це забезпечить безпроблемну роботу з перетворювачами, якщо передати цю функцію-редьюсер функції xform для отримання іншої. Якщо перетворювач (xform) визначений, то після цього ми створюємо похідну функцію-редьюсер шляхом застосування перетворювача до put!. Якщо перетворювач відсутній, використовуємо put!. q - це внутрішній екземпляр UnboundedQueue.

(reify
  Queue
  (put! [_ item]
    (when-not (.-closed q)
      (let [val (xput! q item)]
        (if (reduced? val)
          (do
            (xput! @val)  ;; call completion step
            (shutdown! q) ;; respect reduced
            @val)
          val))))
  ;; ...
)

Операція put! виконується лише у тому випадку, коли черга не завершилася. Зауважимо, що реалізація put! з UnboundedQueue використовує конструкцію assert, щоб переконатися у можливості додавання подальших даних і не варто порушувати цю поведінку. Якщо черга не завершилася, до неї можна додавати значення. Для цього можна скористатися xput!.

Якщо результатом операції put стане очікуване значення, ми зрозуміємо, що час припиняти перетворений процес. У цьому випадку це означає закриття черги таким чином, що вона не приймає подальші значення. Якщо ми не отримали очікуване значення, то продовжуємо отримувати нові дані.

Подивимося на поведінку черги без перетворювачів:

(def q (unbounded-queue))
;; => #<[object Object]>

(put! q 1)
;; => 1
(put! q 2)
;; => 2

(take! q)
;; => 1
(take! q)
;; => 2
(take! q)
;; => nil

Поведінка відповідає нашим очікуванням. Спробуємо тепер застосувати перетворювач без стану:

(def incq (unbounded-queue (map inc)))
;; => #<[object Object]>

(put! incq 1)
;; => 2
(put! incq 2)
;; => 3

(take! incq)
;; => 2
(take! incq)
;; => 3
(take! incq)
;; => nil

Щоб переконатися у тому, що ми реалізували процес, що піддається перетворенню, використаємо перетворювач, що зберігає стан. Ми використаємо перетворювач, що приймає значення, не рівні чотирьом, та розбиває вхідні дані на блоки по два елемента:

(def xq (unbounded-queue (comp
                           (take-while #(not= % 4))
                           (partition-all 2))))

(put! xq 1)
(put! xq 2)
;; => [1 2]
(put! xq 3)
(put! xq 4) ;; shouldn't accept more values from here on
(put! xq 5)
;; => nil

(take! xq)
;; => [1 2]
(take! xq) ;; seems like `partition-all` flushed correctly!
;; => [3]
(take! xq)
;; => nil

Приклад з чергою зʼявився завдяки використанню перетворювачів каналами core.async у внутрішнії процесах. Про канали та про те, як вони використовують перетворювачі, поговоримо у наступних розділах.

Процеси, що піддаються перетворенню, мають поважати reduced як спосіб подання сигналу про передчасне завершення роботи. Наприклад, створення колекції зупиняється на значенні reduced, а канали core.async із перетворювачами закриті. Значення має бути розгорнуте за допомогою функції deref та передане до завершального кроку, що його буде викликано лише один раз.

Процеси, що піддаються перетворенню, мають ховати функцію-редьюсер, яку створюють, інакше це може привести до появи стану та стане небезпечним для використання.

results matching ""

    No results matching ""