Протоколи стандартної бібліотеки

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

Функції

Як ми дізналися з попередніх розділів, у ClojureScript викликати можна не лише функції. Вектори — це функції від власних індексів, мапи — функції від власних ключів, а множини — функції від значень.

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

(extend-type PersistentQueue
  IFn
  (-invoke
    ([this idx]
      (nth this idx))))

(def q #queue[:a :b :c])
;; => #queue [:a :b :c]

(q 0)
;; => :a

(q 1)
;; => :b

(q 2)
;; => :c

Виведення на друк

Щоб познайомитися з деякими протоколами зі стандартної бібліотеки, визначимо тип Pair, який містить пару значень.

(deftype Pair [fst snd])

Для виведення типів на друк у бажаному вигляді ми можемо реалізувати протокол IPrintWithWriter. Цей протокол визначає функцію під назвою -pr-writer, якій передається значення для друку, обʼєкт запису та опції. Ця функція використовує обʼєкт запису -writeдля запису бажаного рядкового представлення типу Pair:

(extend-type Pair
  IPrintWithWriter
  (-pr-writer [p writer _]
    (-write writer (str "#<Pair " (.-fst p) "," (.-snd p) ">"))))

Послідовності

З попереднього розділу ми дізналися про послідовності — одну з основних абстракцій мови ClojureScript. Згадайте функції first та rest для роботи з послідовностями. Вони визначені у протоколі ISeq, тому ми можемо розширити типи та додати підтримку таких функцій:

(extend-type Pair
  ISeq
  (-first [p]
    (.-fst p))

  (-rest [p]
    (list (.-snd p))))

(def p (Pair. 1 2))
;; => #<Pair 1,2>

(first p)
;; => 1

(rest p)
;; => (2)

Інша корисна функція для роботи з послідовностями — це next. Хоча next працює з будь-яким аргументом, що є послідовністю, ми можемо реалізувати це явно за допомогою протоколу INext:

(def p (Pair. 1 2))

(next p)
;; => (2)

(extend-type Pair
  INext
  (-next [p]
    (println "Our next")
    (list (.-snd p))))

(next p)
;; Our next
;; => (2)

Зрештою ми можемо створити власні типи, що реалізують протокол ISeqable. Це означає, що ми можемо передавати їх функції seq та отримувати послідовність.

ISeqable

(def p (Pair. 1 2))

(extend-type Pair
  ISeqable
  (-seq [p]
    (list (.-fst p) (.-snd p))))

(seq p)
;; => (1 2)

Тепер тип Pair може працювати з великою кількістю функцій ClojureScript для обробки послідовностей:

(def p (Pair. 1 2))
;; => #<Pair 1,2>

(map inc p)
;; => (2 3)

(filter odd? p)
;; => (1)

(reduce + p)
;; => 3

Колекції

Функції для роботи з колекціями також визначені на основі протоколів. Для прикладу, у цьому розділі ми змусимо нативні рядки з JavaScript працювати, як колекції.

Найважливіша функція для роботи з колекціями — це conj, що її визначено у протоколі ICollection. Рядки — це єдиний тип, до яких має сенс застосовувати операцію conj, тому операція conj для рядків буде просто конкатенацією:

(extend-type string
  ICollection
  (-conj [this o]
    (str this o)))

(conj "foo" "bar")
;; => "foobar"

(conj "foo" "bar" "baz")
;; => "foobarbaz"

Інша зручна функція для роботи з колекціями — empty, що є частиною протоколу IEmptyableCollection. Реалізуємо її для рядків:

(extend-type string
  IEmptyableCollection
  (-empty [_]
    ""))

(empty "foo")
;; => ""

Ми використовували спеціальний символ string для розширення нативних рядків з JavaScript. За більш детальною інформацією рекомендуємо звернутися до розділу про розширені типи JavaScript.

Особливості колекцій

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

Для колекцій, обсяг яких може бути злічений за постійний час за допомогою функції count, ми можемо визначити протокол ICounted. Реалізувати такий протокол для типу Pair нескладно:

(extend-type Pair
  ICounted
  (-count [_]
    2))

(def p (Pair. 1 2))

(count p)
;; => 2

Певні типи колекцій (вектори, переліки) можуть бути проіндексовані за порядковим номером за допомогою функції nth. До проіндексованих типів можна застосувати протокол IIndexed:

(extend-type Pair
  IIndexed
  (-nth
    ([p idx]
      (case idx
        0 (.-fst p)
        1 (.-snd p)
        (throw (js/Error. "Index out of bounds"))))
    ([p idx default]
      (case idx
        0 (.-fst p)
        1 (.-snd p)
        default))))

(nth p 0)
;; => 1

(nth p 1)
;; => 2

(nth p 2)
;; Error: Index out of bounds

(nth p 2 :default)
;; => :default

Асоціативні структури

Існує багато структур даних, що відображають ключі на значення. Такі структури називаються асоціативними. Ми вже познайомилися з великою кількістю таких структур та функцій, що працюють з ними, зокрема get, assoc та dissoc. Розглянемо протоколи, на яких ґрунтуються ці функції.

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

(extend-type Pair
  ILookup
  (-lookup
    ([p k]
      (-lookup p k nil))
    ([p k default]
      (case k
        0 (.-fst p)
        1 (.-snd p)
        default))))

(get p 0)
;; => 1

(get p 1)
;; => 2

(get p :foo)
;; => nil

(get p 2 :default)
;; => :default

Для застосування функції assoc до структури даних її має реалізовувати протокол IAssociative. Для типу Pair дозволяється лише два значення ключів — 0 та 1. IAssociative також має функцію для запиту інформації щодо наявності певного ключа.

(extend-type Pair
  IAssociative
  (-contains-key? [_ k]
    (contains? #{0 1} k))

  (-assoc [p k v]
    (case k
      0 (Pair. v (.-snd p))
      1 (Pair. (.-fst p) v)
      (throw (js/Error. "Can only assoc to 0 and 1 keys")))))

(def p (Pair. 1 2))
;; => #<Pair 1,2>

(assoc p 0 2)
;; => #<Pair 2,2>

(assoc p 1 1)
;; => #<Pair 1,1>

(assoc p 0 0 1 1)
;; => #<Pair 0,1>

(assoc p 2 3)
;; Error: Can only assoc to 0 and 1 keys

Функція, що є доповненням до assoc, це dissoc. dissoc є частиною протоколу IMap. Для нашого типу Pair ця функція не дуже корисна, але ми її реалізуємо. dissoc при застосуванні до 0 чи 1 встановлює значення nil у таку позицію, що й дозволяє ігнорувати недійсні ключі.

(extend-type Pair
  IMap
  (-dissoc [p k]
    (case k
      0 (Pair. nil (.-snd p))
      1 (Pair. (.-fst p) nil)
      p)))

(def p (Pair. 1 2))
;; => #<Pair 1,2>

(dissoc p 0)
;; => #<Pair ,2>

(dissoc p 1)
;; => #<Pair 1,>

(dissoc p 2)
;; => #<Pair 1,2>

(dissoc p 0 1)
;; => #<Pair ,>

Асоціативні струтури даних складаються з ключів та значень, які попарно називаються записами. Функції key та val дозволяють робити запити за ключем або значенням такого запису і ґрунтуються на протоколі IMapEntry. Розглянемо кілька прикладів функцій key та val, та подивимося, як записи можна використовувати для побудови відображень:

(key [:foo :bar])
;; => :foo

(val [:foo :bar])
;; => :bar

(into {} [[:foo :bar] [:baz :xyz]])
;; => {:foo :bar, :baz :xyz}

Пари також можуть бути відображеннями. Ми розглядаємо перші елементи як ключі, а другі — як значення:

(extend-type Pair
  IMapEntry
  (-key [p]
    (.-fst p))

  (-val [p]
    (.-snd p)))

(def p (Pair. 1 2))
;; => #<Pair 1,2>

(key p)
;; => 1

(val p)
;; => 2

(into {} [p])
;; => {1 2}

Порівняння

Для перевірки еквівалентності значень через = слід реалізувати протокол IEquiv. Зробимо це для типу Pair:

(def p  (Pair. 1 2))
(def p' (Pair. 1 2))
(def p'' (Pair. 1 2))

(= p p')
;; => false

(= p p' p'')
;; => false

(extend-type Pair
  IEquiv
  (-equiv [p other]
    (and (instance? Pair other)
         (= (.-fst p) (.-fst other))
         (= (.-snd p) (.-snd other)))))

(= p p')
;; => true

(= p p' p'')
;; => true

Типи також можна порівнювати. Функція compare отримує два значення та повертає відʼємне число, якщо перше значення менше за друге; 0, якщо значення рівні, та 1 — якщо перше більше за друге. Для порівняння типів необхідно реалізувати протокол IComparable.

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

(extend-type Pair
  IComparable
  (-compare [p other]
    (let [fc (compare (.-fst p) (.-fst other))]
      (if (zero? fc)
        (compare (.-snd p) (.-snd other))
        fc))))

(compare (Pair. 0 1) (Pair. 0 1))
;; => 0

(compare (Pair. 0 1) (Pair. 0 2))
;; => -1

(compare (Pair. 1 1) (Pair. 0 2))
;; => 1

(sort [(Pair. 1 1) (Pair. 0 2) (Pair. 0 1)])
;; => (#<Pair 0,1> #<Pair 0,2> #<Pair 1,1>)

Метадані

Функції meta та with-meta також ґрунтуються на протоколах, а саме — IMeta та IWithMeta. Для того, щоб наші типи також підтримували можливість додання метаданих, слід реалізувати додання додаткового поля для запису метаданих та реалізацію обох протоколів.

Реалізуємо версію типу Pair із метаданими:

(deftype Pair [fst snd meta]
  IMeta
  (-meta [p] meta)

  IWithMeta
  (-with-meta [p new-meta]
    (Pair. fst snd new-meta)))


(def p (Pair. 1 2 {:foo :bar}))
;; => #<Pair 1,2>

(meta p)
;; => {:foo :bar}

(def p' (with-meta p {:bar :baz}))
;; => #<Pair 1,2>

(meta p')
;; => {:bar :baz}

Взаємодія з JavaScript

ClojureScript є гостьовою мовою у віртуальній машині JavaScript, тому часто виникає необхідність конвертації структур даних ClojureScript у відповідні структури JavaScript та навпаки. Також може зʼявитися необхідність участі типів JavaScript в абстракціях, представлених як протоколи.

Розширення типів JavaScript

Коли необхідно розширити обʼєкти JavaScript, слід використовувати спеціальні символи замість глобальних обʼєктів js/String, js/Dateтощо. Таке обмеження захищає глобальні обʼєкти від небажаних мутацій.

Символи для розширення типів JavaScript — object, array, number, string, function, boolean та nil. Останній використовується для обʼєкта null. Розміщення протоколу на обʼєкти використовує функцію goog.typeOf бібліотеки Google Closure. Є Спеціальний символ default для стандартних реалізацій протоколу будь-якого типу.

Продемонструємо розширення типів JavaScript: визначимо протокол MaybeMutable, що має єдину функцію — предикат mutable?. Змінюваність — це стандартна поведінка у JavaScript, тому розширимо стандартний тип JavaScript, що повертає true в результаті виклику функції mutable?:

(defprotocol MaybeMutable
  (mutable? [this] "Returns true if the value is mutable."))

(extend-type default
  MaybeMutable
  (mutable? [_] true))

;; object
(mutable? #js {})
;; => true

;; array
(mutable? #js [])
;; => true

;; string
(mutable? "")
;; => true

;; function
(mutable? (fn [x] x))
;; => true

На щастя, не всі значення обʼєктів JavaScript є змінюваними, тому ми можемо змінити реалізацію MaybeMutable таким чином, що виклик буде повертати значення false для рядків та функцій.

(extend-protocol MaybeMutable
  string
  (mutable? [_] false)

  function
  (mutable? [_] false))


;; object
(mutable? #js {})
;; => true

;; array
(mutable? #js [])
;; => true

;; string
(mutable? "")
;; => false

;; function
(mutable? (fn [x] x))
;; => false

Для дат з JavaScript не існує спеціального символу, тому доведеться розширювати js/Date напряму. Те саме стосується решти типів, які можна знайти у глобальному просторі js.

Конвертація даних

Для конвертації даних з типів ClojureScript до типів JavaScript та навпаки ми використовуємо функції clj->js та js->clj, що базуються на протоколах IEncodeJS та IEncodeClojure.

Наприклад, скористаємося тип Set, що зʼявився у версії ES6. На сьогодні, цей тип наявний не в кожному рантаймі.

З ClojureScript до JS

Перш за все, розширимо тип "множина" (set) з ClojureScript так, що його можна буде конвертувати у JS. За замовчування множини конвертуються у масиви:

(clj->js #{1 2 3})
;; => #js [1 3 2]

Давайте це виправимо. Функція clj->js має конвертувати значення рекурсивно, тому переконаємося у тому, що весь зміст множини сконвертований, та створимо нову множину із конвертованими даними:

(extend-type PersistentHashSet
  IEncodeJS
  (-clj->js [s]
    (js/Set. (into-array (map clj->js s)))))

(def s (clj->js #{1 2 3}))
(es6-iterator-seq (.values s))
;; => (1 3 2)

(instance? js/Set s)
;; => true

(.has s 1)
;; => true
(.has s 2)
;; => true
(.has s 3)
;; => true
(.has s 4)
;; => false

Функція es6-iterator-seq — експериментальна функція в стандартній бібліотеці ClojureScript для отримання послідовності з типів ES6, які можна ітерувати.

З JS до ClojureScript

Час розширити тип set з JS та конвертувати його у ClojureScript. Подібно до функції clj->js, функція js->clj рекурсивно конвертує значення структури даних:

(extend-type js/Set
  IEncodeClojure
  (-js->clj [s options]
    (into #{} (map js->clj (es6-iterator-seq (.values s))))))

(= #{1 2 3}
   (js->clj (clj->js #{1 2 3})))
;; => true

(= #{[1 2 3] [4 5] [6]}
   (js->clj (clj->js #{[1 2 3] [4 5] [6]})))
;; => true

Зауважимо, що не існує однозначних відповідностей між значенням ClojureScript та JavaScript. Наприклад, ключові слова ClojureScript при конвертації за допомогою clj->js перетворюються на рядки.

Редукції

Функція reduce базується на протоколі IReduce, який дозволяє проводити редукцію сторонніх типів. Окрім використання таких типів із reduce, вони також будуть працювати з transduce, завдяки чому ми зможемо реалізувати редукцію із перетворювачем.

Масиви з JS у ClojureScript вже підтримують редукцію:

(reduce + #js [1 2 3])
;; => 6

(transduce (map inc) conj [] [1 2 3])
;; => [2 3 4]

Але нові типи, що зʼявилися у версії ES6, не надають такої можливості, тому для них ми реалізуємо протокол IReduce. Ми отримаємо ітератор за допомогою функції values множини та конвертуємо його за допомогою функції es6-iterator-seq у послідовність. Після цього ми делегуємо редукцію отриманої послідовності оригінальній функції reduce.

(extend-type js/Set
  IReduce
  (-reduce
   ([s f]
     (let [it (.values s)]
       (reduce f (es6-iterator-seq it))))
   ([s f init]
     (let [it (.values s)]
       (reduce f init (es6-iterator-seq it))))))

(reduce + (js/Set. #js [1 2 3]))
;; => 6

(transduce (map inc) conj [] (js/Set. #js [1 2 3]))
;; => [2 3 4]

До асоційованої структури даних можна застосовувати функцію reduce-kv, яка базується на протоколі IKVReduce. Основна відмінність між reduce та reduce-kv полягає у тому, що остання використовує у якості редʼюсера функцію, що очікує три аргументи.

Розглянемо приклад. Ми проведемо редукцію мапи на вектор пар. Зауважте, що вектори поєднують індекси та значення, тому редукцію векторів також можна проводити за допомогою reduce-kv.

(reduce-kv (fn [acc k v]
             (conj acc [k v]))
           []
           {:foo :bar
            :baz :xyz})
;; => [[:foo :bar] [:baz :xyz]]

Розширимо новий тип map так, що він буде забезпечувати підтримку reduce-kv. Для цього отримаємо послідовність пар "ключ-значення" та викличемо функцію-редʼюсер із акумулятором, а ключі та значення передамо як позиційні аргументи:

(extend-type js/Map
  IKVReduce
  (-kv-reduce [m f init]
   (let [it (.entries m)]
     (reduce (fn [acc [k v]]
               (f acc k v))
             init
             (es6-iterator-seq it)))))

(def m (js/Map.))
(.set m "foo" "bar")
(.set m "baz" "xyz")

(reduce-kv (fn [acc k v]
             (conj acc [k v]))
           []
           m)
;; => [["foo" "bar"] ["baz" "xyz"]]

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

Асинхронність

Існують типи, що передбачають асинхронні обчислення. Значення, яке представляють такі типи, може не бути реалізованим у певний момент часу. Дізнатися стан реалізації значення можна за допомогою предиката realized?.

Продемонструємо це на прикладі типу Delay, який отримує обчислення та виконує його у той момент, коли зʼявляється потреба у результаті. При запиті значення обчислення відбувається, і відстрочене значення реалізується.

(defn computation []
  (println "running!")
  42)

(def d (Delay. computation nil))

(realized? d)
;; => false

(deref d)
;; running!
;; => 42

(realized? d)
 ;; => true

@d
;; => 42

Обидві функції базуються на протоколах, а саме IPending та IDeref.

Стандарт ES6 представив тип, що уособлює поняття асинхронного обчислення, що може привести до помилки — проміс. Проміс представляє значення, що буде реалізоване через невідомий час, і може знаходитися в одному з трьох станів:

  • pending(очікування) — значення недоступне для обчислення
  • rejected(відмова) — сталася помилка, проміс містить значення, що вказує на помилку
  • resolved(вирішення) — обчислення пройшло успішно, проміс містить значення результату

Інтерфейс промісів, визначений у стандарті ES6, не дозволяє дізнатися стан промісу у певний час, тому ми скористаємося промісами з бібліотеки Bluebird. Проміси Bluebird можна використовувати з бібліотекою Promesa.

Почнемо з того, що додамо можливість перевірки, чи проміс здійснився (має стан resolved або rejected) за допомогою предиката realized?. Необхідно реалізувати протокол IPending:

(require '[promesa.core :as p])

(extend-type js/Promise
  IPending
  (-realized? [p]
    (not (.isPending p))))


(p/promise (fn [resolve reject]))
;; => #<Promise {:status :pending}>

(realized? (p/promise (fn [resolve reject])))
;; => false

(p/resolved 42)
;; => #<Promise {:status :resolved, :value 42}>

(realized? (p/resolved 42))
;; => true

(p/rejected (js/Error. "OH NO"))
;; => #<Promise {:status :rejected, :error #object[Error Error: OH NO]}>

(realized? (p/rejected (js/Error. "OH NO")))
;; => true

Тепер розширимо тип промісів таким чином, що матимемо змогу отримувати значення проміса. Якщо проміс знаходиться у нереалізованому стані, результатом буде спеціальне ключове слово :promise/pending. Інакше отримаємо значення проміса, тобто помилку або результат:

(require '[promesa.core :as pro])

(extend-type js/Promise
  IDeref
  (-deref [p]
    (cond
      (.isPending p)
      :promise/pending

      (.isRejected p)
      (.reason p)

      :else
      (.value p))))

@(p/promise (fn [resolve reject]))
;; => :promise/pending

@(p/resolved 42)
;; => 42

@(p/rejected (js/Error. "OH NO"))
;; => #object[Error Error: OH NO]

Стан

Конструкти стану у ClojureScript(атоми, волатайли) мають різні характеристики та семантику, а операції на таких конструктах, такі як add-watch, reset! або swap!, визначаються протоколами.

Атом

Для демонстрації роботи таких протоколів реалізуємо власну спрощену версію Atom. Наша версія не матиме підтримки валідаторів та метаданих. Натомість будуть такі функції:

  • deref - отримати актуально значення атома
  • reset! - присвоїти попереднє значення
  • swap! - замінити на функцію для зміни стану

Функція deref базується на протоколі IDeref, reset! — на IReset, а swap!, відповідно, на ISwap. Почнемо з визначення типу даних та конструктора для нашої реалізації атома:

(deftype MyAtom [^:mutable state ^:mutable watches]
  IPrintWithWriter
  (-pr-writer [p writer _]
    (-write writer (str "#<MyAtom " (pr-str state) ">"))))

(defn my-atom
  ([]
    (my-atom nil))
  ([init]
    (MyAtom. init {})))

(my-atom)
;; => #<MyAtom nil>

(my-atom 42)
;; => #<MyAtom 42>

Зауважте, що ми позначили поточний стан атома (state) та мапу спостерігачів (watches) метаданими {:mutable true}. Ми будемо змінювати ці дані, і вказуємо це явно за допомогою анотацій.

Наш тип MyAtom поки що не дуже корисний. Почнемо з реалізації протоколу IDeref для отримання поточного значення атому:

(extend-type MyAtom
  IDeref
  (-deref [a]
    (.-state a)))

(def a (my-atom 42))

@a
;; => 42

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

(extend-type MyAtom
  IWatchable
  (-add-watch [a key f]
    (let [ws (.-watches a)]
      (set! (.-watches a) (assoc ws key f))))

  (-remove-watch [a key]
    (let [ws (.-watches a)]
      (set! (.-watches a) (dissoc ws key))))

  (-notify-watches [a oldval newval]
    (doseq [[key f] (.-watches a)]
      (f key a oldval newval))))

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

(extend-type MyAtom
  IReset
  (-reset! [a newval]
    (let [oldval (.-state a)]
      (set! (.-state a) newval)
      (-notify-watches a oldval newval)
      newval)))

Переконаємося у правильності результату. Додамо спостерігач, змінимо значення атома, викличемо спостерігач та видалимо його:

(def a (my-atom 41))
;; => #<MyAtom 41>

(add-watch a :log (fn [key a oldval newval]
                    (println {:key key
                              :old oldval
                              :new newval})))
;; => #<MyAtom 41>

(reset! a 42)
;; {:key :log, :old 41, :new 42}
;; => 42

(remove-watch a :log)
;; => #<MyAtom 42>

(reset! a 43)
;; => 43

Реалізуємо також протокол ISwap. Метод -swap! може отримувати один, два, три або чотири аргументи:

(extend-type MyAtom
  ISwap
  (-swap!
   ([a f]
    (let [oldval (.-state a)
          newval (f oldval)]
      (reset! a newval)))

   ([a f x]
     (let [oldval (.-state a)
           newval (f oldval x)]
       (reset! a newval)))

   ([a f x y]
     (let [oldval (.-state a)
           newval (f oldval x y)]
       (reset! a newval)))

   ([a f x y more]
     (let [oldval (.-state a)
           newval (apply f oldval x y more)]
       (reset! a newval)))))

ми отримали власну реалізацію абстракції атома. Перевіримо її роботу у REPL та переконаємося, що вона має очікувану поведінку:

(def a (my-atom 0))
;; => #<MyAtom 0>

(add-watch a :log (fn [key a oldval newval]
                    (println {:key key
                              :old oldval
                              :new newval})))
;; => #<MyAtom 0>

(swap! a inc)
;; {:key :log, :old 0, :new 1}
;; => 1

(swap! a + 2)
;; {:key :log, :old 1, :new 3}
;; => 3

(swap! a - 2)
;; {:key :log, :old 3, :new 1}
;; => 1

(swap! a + 2 3)
;; {:key :log, :old 1, :new 6}
;; => 6


(swap! a + 4 5 6)
;; {:key :log, :old 6, :new 21}
;; => 21

(swap! a * 2)
;; {:key :log, :old 21, :new 42}
;; => 42

(remove-watch a :log)
;; => #<MyAtom 42>

Вийшло! Ми реалізували версію атома ClojureScript без підтримки метаданих чи валідаторів. Пропонуємо нашим читачам додати ці можливості самостійно для вправи. Зауважте, що вам доведеться змінювати тип MyAtom для збереження метаданих та валідатора.

Волатайли

Волатайли простіші за атоми, бо не підтримують спостереження за змінами. Усі зміни переписують попередні значення, як змінювані сутності, що присутні майже у кожній мові. Волатайли грунтуються на протоколі IVolatile, що визначає лише метод для vreset!, бо vswap! реалізований як макрос.

Створимо власний тип та конструктор:

(deftype MyVolatile [^:mutable state]
  IPrintWithWriter
  (-pr-writer [p writer _]
    (-write writer (str "#<MyVolatile " (pr-str state) ">"))))

(defn my-volatile
  ([]
    (my-volatile nil))
  ([v]
    (MyVolatile. v)))

(my-volatile)
;; => #<MyVolatile nil>

(my-volatile 42)
;; => #<MyVolatile 42>

MyVolatile все ще має забезпечити підтримку дереферування та повернення до попереднього значення. Реалізуємо IDeref та IVolatile, що дозволить нам використовувати deref, vreset! та vswap!.

(extend-type MyVolatile
  IDeref
  (-deref [v]
    (.-state v))

  IVolatile
  (-vreset! [v newval]
    (set! (.-state v) newval)
    newval))

(def v (my-volatile 0))
;; => #<MyVolatile 42>

(vreset! v 1)
;; => 1

@v
;; => 1

(vswap! v + 2 3)
;; => 6

@v
;; => 6

Мутація

З розділу про перехідні струтури даних ми дізналися про змінювані аналоги незмінних та стійких структур даних, що існують у ClojureScript. такі структури даних є змінюваними, а операції з такими структурами позначаються окличним знаком (!) на кінці назви. Як ви могли здогадатися, кожна операція зі змінюваними структурами даних грунтується на протоколі.

Від стійких структур до перехідних та навпаки

Ми дізналися, що можна трансформувати структури даних за допомогою функції transient, яка ґрунтується на протоколі IEditableCollection. Для трансформації у стійку структуру використовується persistent!, що грунтується на ITransientCollection.

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

Приклад: бібліотека hodgepodge

Бібліотека hodgepodge призначена для управління локальним сховищем та сесіями браузера як перехідними структурами даних у ClojureScript. Ця бібліотека дозволяє вставляти, читати та видаляти структури даних ClojureScript без необхідності кодування та декодування таких даних.

Сховище браузера — це просте сховище, що зберігає пари "ключ-значення" у вигляді рядків. Завдяки тому, що будь-які структури даних ClojureScript можна зберегти як рядки та читати за допомогою the reader, ми можемо зберігати дані на ClojureScript у сховищі. Також можливо розширювати reader таким чином, що стає можливим читання користувацьких типів даних. Завдяки цьому ми можемо зберігати дані, а кодування та декодування довірити бібліотеці hodgepodge.

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

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

Створимо для цих функцій більш ідіоматичний для ClojureScript інтерфейс:

(defn contains-key?
  [^js/Storage storage ^string key]
  (let [ks (.keys js/Object storage)
        idx (.indexOf ks key)]
    (>= idx 0)))

(defn get-item
  ([^js/Storage storage ^string key]
     (get-item storage key nil))
  ([^js/Storage storage ^string key ^string default]
     (if (contains-key? storage key)
       (.getItem storage key)
       default)))

(defn set-item
  [^js/Storage storage ^string key ^string val]
  (.setItem storage key val)
  storage)

(defn remove-item
  [^js/Storage storage ^string key]
  (.removeItem storage key)
  storage)

(defn length
  [^js/Storage storage]
  (.-length storage))

(defn clear!
  [^js/Storage storage]
  (.clear storage))

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

(require '[cljs.reader :as reader])

(defn serialize [v]
  (binding [*print-dup* true
            *print-readably* true]
    (pr-str v)))

(def deserialize
  (memoize reader/read-string))

Функція serialize використовується для конвертації структури даних з ClojureScript у рядок за допомогою функції pr-str та конфігурування певних динамічних змінних для забезпечення бажаної поведінки:

  • *print-dup* призначено значення true для збереження типу обʼєктів при подальшому читанні
  • *print-readably* призначено значення true для запобігання конвертації не-альфачислових знаків

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

Тепер ми можемо розширювати вбудований тип Storage браузера до поведінки перехідної структури. Не забувайте про те, що тип Storage доступний лише у середовищі браузера. Почнемо з реалізації протоколу ICounted для підрахунку сутностей у сховищі. Звернемося до функції length, що її ми визначили раніше.

(extend-type js/Storage
  ICounted
  (-count [^js/Storage s]
   (length s)))

Ми б хотіли застосовувати функцію assoc! and dissoc! для додання та видалення пар "ключ-значення" зі сховища, а також для забезпечення можливості читання. Реалізуємо протокол ITransientAssociative для assoc!, ITransientMap для dissoc! та ILookup для читання ключів зі сховища.

(extend-type js/Storage
  ITransientAssociative
  (-assoc! [^js/Storage s key val]
    (set-item s (serialize key) (serialize val))
    s)

  ITransientMap
  (-dissoc! [^js/Storage s key]
    (remove-item s (serialize key))
    s)

  ILookup
  (-lookup
    ([^js/Storage s key]
       (-lookup s key nil))
    ([^js/Storage s key not-found]
       (let [sk (serialize key)]
         (if (contains-key? s sk)
           (deserialize (get-item s sk))
           not-found)))))

Тепер ми можем виконати деякі операції зі сховищем сесії та з локальним сховищем. Спробуємо це зробити:

(def local-storage js/localStorage)
(def session-storage js/sessionStorage)

(assoc! local-storage :foo :bar)

(:foo local-storage)
;; => :bar

(dissoc! local-storage :foo)

(get local-storage :foo)
;; => nil

(get local-storage :foo :default)
;; => :default

Насамкінець ми хочемо використовувати функцію conj! та persistent! із локальним сховищем, тому слід реалізувати протокол ITransientCollection:

(extend-type js/Storage
  ITransientCollection
  (-conj! [^js/Storage s ^IMapEntry kv]
    (assoc! s (key kv) (val kv))
    s)

  (-persistent! [^js/Storage s]
    (into {}
          (for [i (range (count s))
                :let [k (.key s i)
                      v (get-item s k)]]
            [(deserialize k) (deserialize v)]))))

Функція conj! отримує ключ та значення з запису та передає ці дані функції assoc!. Функція persistent! десеріалізує кожну пару "ключ-значення" у сховищі та повертає незмінну копію сховища у вигляді мапи.

(clear! local-storage)

(persistent! local-storage)
;; => {}

(conj! local-storage [:foo :bar])
(conj! local-storage [:baz :xyz])

(persistent! local-storage)
;; => {:foo :bar, :baz :xyz}
Перехідні вектори та множини

Ми познайомилися з більшістю протоколів для перехідних даних, окрім двох — ITransientVector, що дозволяє використовувати функцію assoc! зі змінюваними векторами, та ITransientSet — для використання disj! з множинами.

Продемонструємо протокол ITransientVector: розширимо тип масиву так, що він стане перехідною структурою даних:

(extend-type array
  ITransientAssociative
  (-assoc! [arr key val]
    (if (number? key)
      (-assoc-n! arr key val)
      (throw (js/Error. "Array's key for assoc! must be a number."))))

  ITransientVector
  (-assoc-n! [arr n val]
    (.splice arr n 1 val)
    arr))

(def a #js [1 2 3])
;; => #js [1 2 3]

(assoc! a 0 42)
;; => #js [42 2 3]

(assoc! a 1 43)
;; => #js [42 43 3]

(assoc! a 2 44)
;; => #js [42 43 44]

Для демонстрації протоколу ITransientSet розширимо тип Set зі стандарту ES6 до перехідної структури, що підтримує операції conj!, disj! та persistent!. Зауважимо, що попередньо ми розширили тип Set таким чином, що його можна перетворити на структуру ClojureScript.

(extend-type js/Set
  ITransientCollection
  (-conj! [s v]
    (.add s v)
    s)

  (-persistent! [s]
   (js->clj s))

  ITransientSet
  (-disjoin! [s v]
    (.delete s v)
    s))

(def s (js/Set.))

(conj! s 1)
(conj! s 1)
(conj! s 2)
(conj! s 2)

(persistent! s)
;; => #{1 2}

(disj! s 1)

(persistent! s)
;; => #{2}

results matching ""

    No results matching ""