Абстракції та поліморфізм
Ви, напевно, не раз були в такій ситуації: ви створили абстракцію (наприклад, за допомогою інтерфейсів) для бізнес–логіки, з часом у вас виникає потреба працювати з іншим модулем, який ви не контролюєте, тому скоріш за все ви вирішили зробити адаптер, проксі, чи застосувати інші підходи, які приносять багато допоміжної складності.
Деякі динамічні мови дозволяють робити так званий «monkey-patching»; мови, в яких класи відкриті і будь-який метод може бути створений чи переписаний у будь-який час. Ця техніка є дуже поганою практикою.
Ми не можемо довіряти мовам, що дозволяють переписувати методи, які ви використовуєте у себе, іншими бібліотеками; коли таке трапляється, поведінка програми може бути непередбаченою.
Такі симптоми зазвичай називають проблемою виразу (expression problem); дізнайтеся більше за посиланням: http://en.wikipedia.org/wiki/Expression_problem
Протоколи
В ClojureScript примітив для створення «інтерфейсів» називається протокол. Протокол складається з імені та набору функцій. Усі аргументи функцій мають хоча б один аргумент, що відповідає об'єкту this
у JavaScript або self
у Python.
Протоколи забезпечують поліморфізм на основі типів. Тип завжди визначається по першому аргументу (об'єкт еквівалентний this
у JavaScript, як було зазначено раніше).
Протокол виглядає наступним чином:
(ns myapp.testproto)
(defprotocol IProtocolName
"Рядок документації, який описує протокол."
(sample-method [this] "Рядок документації для функції."))
ЗАУВАЖЕННЯ: Префікс «I» часто використовується для розрізнення протоколів та типів за іменем. В громаді Clojure–розробників існує багато різних думок на рахунок того, як повинен використовуватись префікс «I». На нашу думку, це прийнятне рішення для запобігання конфлікту імен. У той же час, не використовувати префікс «I» не є поганою практикою.
З точки зору користувача, функції протоколів — це звичайні функції, що створені у тому ж просторі імен, де і сам протокол. Це дає простий та легкий спосіб уникнення конфліктів між протоколами з однаковими іменами функцій, що імплементовані для одного типу.
Давайте створимо протокол IInvertible
для даних, що можуть бути «інвертовані». Він матиме один метод invert
.
(defprotocol IInvertible
"Це протокол для типів даних, що можуть бути «інвертовані»"
(invert [this] "Інвертує передане значення."))
Розширення існуючих типів
Однією з переваг протоколів є те, що вони дозволяють розширювати існуючі та зовнішні типи. Це можна зробити декількома способами.
Майже завжди ви будете використовувати макроси extend-protocol
або extend-type
. Ось як виглядає синтаксис extend-type
:
(extend-type TypeA
ProtocolA
(function-from-protocol-a [this]
;; імплементація тут
)
ProtocolB
(function-from-protocol-b-1 [this parameter1]
;; імплементація тут
)
(function-from-protocol-b-2 [this parameter1 parameter2]
;; імплементація тут
))
Ви можете побачити, що вираз extend-type
розширює один тип довільною кількістю протоколів.
Давайте використаємо раніше створений протокол IInvertible
:
(extend-type string
IInvertible
(invert [this] (apply str (reverse this))))
(extend-type cljs.core.List
IInvertible
(invert [this] (reverse this)))
(extend-type cljs.core.PersistentVector
IInvertible
(invert [this] (into [] (reverse this))))
Ви могли помітити, що ми використали символ string
замість js/String
, коли розширювали протоколом тип–рядок. Це тому, що типи з JavaScript не можна розширювати. Якщо ви спробуєте розширити тип js/String
, компілятор сповістить про це показавши помилку.
Тому, якщо ви хочете розширити типи з JavaScript, замість js/Number
, js/String
, js/Object
, js/Array
, js/Boolean
та js/Function
треба використати спеціальні символи: number
, string
, object
, array
, boolean
and function
.
Тепер можна випробувати нашу імплементацію протоколу:
(invert "abc")
;; => "cba"
(invert 0)
;; => 0
(invert '(1 2 3))
;; => (3 2 1)
(invert [1 2 3])
;; => [3 2 1]
extend-protocol
працює навпаки; маючи протокол, цей макрос дозволяє додати імплементацію до декількох типів одразу. Ось, як виглядає синтаксис:
(extend-protocol ProtocolA
TypeA
(function-from-protocol-a [this]
;; імплементація тут
)
TypeB
(function-from-protocol-a [this]
;; імплементація тут
))
Попередній приклад можна переписати наступним чином:
(extend-protocol IInvertible
string
(invert [this] (apply str (reverse this)))
cljs.core.List
(invert [this] (reverse this))
cljs.core.PersistentVector
(invert [this] (into [] (reverse this))))
Використання протоколів з ClojureScript
ClojureScript побудована на абстракціях, що створені за допомогою протоколів. Майже будь-яка поведінка в мові може бути адаптована до зовнішніх бібліотек. Давайте розглянемо реальний приклад.
У минулих частинах ми розглянули різні типи колекцій. В цьому прикладі ми використаємо set
. Подивіться на цей код:
(def mynums #{1 2})
(filter mynums [1 2 4 5 1 3 4 5])
;; => (1 2 1)
Що тут відбувається? У цьому випадку тип set
імплементує протокол IFn
, що є абстракцією для функцій або всього, що може бути викликаним як функція. Таким чином set
, може бути використаний як предиката.
Добре, та що, якщо ми хочемо використати регулярний вираз як предикату для фільтрування колекції рядків:
(filter #"^foo" ["haha" "foobar" "baz" "foobaz"])
;; TypeError: Cannot call undefined
Ми одержали помилку, бо тип RegExp
не імплементує протокол IFn
, тому він і не може бути використаний як функція. Але це можна легко змінити:
(extend-type js/RegExp
IFn
(-invoke
([this a]
(re-find this a))))
Давайте проаналізуємо цей приклад: ми розширили тип js/RegExp
імплементацією функції invoke
у протоколі IFn
. Щоб регулярний вираз а
поводився як функція, в імплементації нам треба виконати операцію re-find
з об'єктом виразу та патерном.
Тепер ви можете використати регулярний вираз як предикату, наприклад, в функції filter
:
(filter #"^foo" ["haha" "foobar" "baz" "foobaz"])
;; => ("foobar" "foobaz")
Аналіз за допомогою протоколів
В ClojureScript є корисна функція для аналізу (introspection) даних через протоколи: satisfies?
. За допомогою цієї функції можна перевірити об'єкт (інстанс типу) на наявність імплементації конкретного протоколу.
Наприклад, ось так ми можемо перевірити, що set
імплементує протокол IFn
:
(satisfies? IFn #{1})
;; => true
Мультиметоди
Раніше ми говорили про протоколи, що вирішують проблему поліморфізму, яка трапляється доволі часто — визначення за типом. Та за деяких обставин протоколи можуть обмежувати. Для таких випадків існують мультиметоди.
Мультиметоди не обмежені визначенням по типу; вони дозволяють визначати за типом декількох аргументів та значенню. Вони також дозволяють утворювати спеціалізовані (ad-hoc) ієрархії. Як і протоколи, мультиметоди — це відкрита система, в якій зовнішні бібліотеки можуть розширювати мультиметоди для нових типів.
Для створення мультиметодів використовують форми defmulti
та defmethod
. Мультиметод створюється за допомогою defmulti
і приймає функцію, що визначає метод. Ось, як це виглядає:
(defmulti say-hello
"Поліморфна функція, що повертає повідомлення з вітанням
в залежності від значення ключа `:locale`,
у якого встановлено значення за замовчуванням `:en`"
(fn [param] (:locale param))
:default :en)
Анонімна функція у defmulti
— функція, що визначає, який з методів повинен бути викликаний. Ця функція буде викликана кожного разу, коли ми викликаємо say-hello
і повинна повертати деяке значення, що буде використане для визначення методу. У нашому прикладі це значення по ключу :locale
в першому аргументі.
І нарешті, ми можемо імплементувати декілька методів. Використаємо для цього defmethod
:
(defmethod say-hello :en
[person]
(str "Привіт " (:name person "Анонімний")))
(defmethod say-hello :es
[person]
(str "Привет " (:name person "Анонимный")))
Якщо викликати нашу функцію і передати в неї мапу з ключами :locale
та :name
, мультиметод спочатку викличе визначаючу функцію, щоб отримати визначаюче значення, а потім знайде імплементацію для цього значення і викличе знайдену функцію. Якщо такої імплементації немає, то мультиметод спробує викликати імплементацію за замовчуванням, якщо така є.
(say-hello {:locale :es})
;; => "Привет Анонимный"
(say-hello {:locale :en :name "Роман"})
;; => "Привіт Роман"
(say-hello {:locale :fr})
;; => "Привіт Анонімний"
Якщо імплементації за замовчуванням немає, буде виведена помилка з повідомленням про те, що для використаного значення відсутня імплементація в мультиметоді.
Ієрархії
В ClojureScript ієрархії використовуються для побудови відносин у системі. Ієрархії створюють відносини між такими об'єктами як символи, ключові слова та типи.
Залежно від ваших потреб, ієрархії можна будувати локально чи глобально. Як і мультиметоди ієрархії не обмежені одним простором імен. Їх можна розширювати з будь-якого простору імен.
Глобальний простір імен більш обмежений, що зроблено навмисно. Ключові слова та символи, що не прив'язані до простору імен, не можуть бути використані у глобальній ієрархії. Така поведінка допомагає уникнути несподіваних ситуацій, коли декілька зовнішніх бібліотек використовують один і той же символ по-різному.
Створення ієрархії
Ієрархічний зв'язок створюється за допомогою функції derive
:
(derive ::circle ::shape)
(derive ::box ::shape)
Ми створили набір зв'язків між іменованими ключовими словами. У цьому випадку, ::circle
та ::box
є дочірніми для ::shape
.
ПІДКАЗКА: Синтаксис ключового слова
::circle
— це скорочений запис:current.ns/circle
. Якщо обчислити його у REPL ви побачите, що::circle
насправді поверне:cljs.user/circle
.
Ієрархії та аналіз
В ClojureScript є декілька спеціальних функцій для аналізу глобальних та локальних ієрархій. Це функції isa?
, ancestors
та descendants
.
Давайте розглянемо приклад з використанням цих функцій:
(ancestors ::box)
;; => #{:cljs.user/shape}
(descendants ::shape)
;; => #{:cljs.user/circle :cljs.user/box}
(isa? ::box ::shape)
;; => true
(isa? ::rect ::shape)
;; => false
Локальні ієрархії
Як ми вже говорили раніше, в ClojureScript також є локальні ієрархії. Вони створюються за допомогою функції make-hierarchy
. Ось код з минулого прикладу переписаний з використанням ієрархій:
(def h (-> (make-hierarchy)
(derive :box :shape)
(derive :circle :shape)))
Для аналізу локальних ієрархій теж використовується функція isa?
:
(isa? h :box :shape)
;; => true
(isa? :box :shape)
;; => false
Як ви можете бачити, в локальних ієрархіях ми можемо використовувати нормальні (неіменовані) ключові слова, а функція isa?
повертає false
, якщо в неї не передати ієрархію.
Ієрархії у мультиметодах
Однією зі значних переваг ієрархій є те, що вони дуже добре працюють з мультиметодами. Мультиметоди використовують функцію isa?
на останньому етапі визначення методу.
Розглянемо приклад, щоб зрозуміти, що це означає. Спочатку ми створюємо мультиметод за допомогою форми defmulti
:
(defmulti stringify-shape
"Функція, що виводить текстовий опис форми."
identity
:hierarchy #'h)
Ключове слово :hierarchy
вказує мультиметоду, яку ієрархію ми хочемо використовувати; без ієрархії мультиметод буде використовувати глобальну ієрархію.
Далі ми створюємо імплементації мультиметоду, як за звичай, за допомогою defmethod
:
(defmethod stringify-shape :box
[_]
"Форма коробки")
(defmethod stringify-shape :shape
[_]
"Загальна форма")
(defmethod stringify-shape :default
[_]
"Несподіваний об'єкт")
Тепер подивимось, що відбудеться, коли ми викличемо мультиметод:
(stringify-shape :box)
;; => "Форма коробки"
Все працює, як і очікувалось; мультиметод викликає імплементацію по ключовому слову :box
. Тепер подивимось, що буде, якщо викликати мультиметод з ключовим словом :circle
, для якого імплементація відсутня:
(stringify-shape :circle)
;; => "Загальна форма"
Мультиметод автоматично визначає імплементацію за допомогою ієрархії. :circle
є нащадком :shape
, тому викликається імплементація :shape
.
Нарешті, якщо передати у мультиметод ключове слово, для якого нема імплементації ні в ієрархії, ні створеної напряму, спрацює імплементація за замовчуванням, :default
:
(stringify-shape :triangle)
;; => "Несподіваний об'єкт"