Полиморфизм

Полиморфизм дает возможность писать один код для работы с многими типами. Полиморфизм можно грубо разделить на динамический и статический:

  • Динамический полиморфизм — это про абстрактные классы, интерфейсы, утиную типизацию, т.е. только в рантайме будет понятно, с каким типом будет работать наш код.
  • Статический полиморфизм — это в основном про шаблоны (generics). Когда уже на этапе компиляции из одного шаблонного кода генерируется код специфичный для каждого используемого типа.

Здесь и далее я буду понимать под полиморфизмом только динамический полиморфизм.

Мультиметоды

(defmulti foo identity)
;; identity - стандартная функция вида (fn [x] x)

(defmethod foo :a [x]
  [:a x])

(defmethod foo :default [x]
  [:default x])

(assert (= [:a :a] (foo :a)))
(assert (= [:default :b] (foo :b)))

defmulti - объявляет мультиметод foo с функцией диспетчеризации identity. Т.к. identity принимает один аргумент, то и наш метод будет также принимать один аргумент. Функция диспетчеризации на основе аргументов вычисляет значение диспетчеризации, по которому будет выбираться нужная реализация.

defmethod - объявляет реализацию для соответствующего значения диспетчеризации. В нашем случае объявляется метод для :a и метод по умолчанию, который будет обрабатывать оставшиеся случаи.

Рассмотрим пример посложнее. Будем моделировать игру в камень-ножницы-бумага:

(defmulti winner (fn [x y] (set [x y])))

(defmethod winner #{:rock}     [_ _] :drawn-game)
(defmethod winner #{:paper}    [_ _] :drawn-game)
(defmethod winner #{:scissors} [_ _] :drawn-game)

(defmethod winner #{:rock  :paper}    [_ _] :paper)
(defmethod winner #{:rock  :scissors} [_ _] :rock)
(defmethod winner #{:paper :scissors} [_ _] :scissors)

(assert (= :drawn-game (winner :rock :rock)))
(assert (= :paper (winner :rock :paper)))
(assert (= :paper (winner :paper :rock))) ;; симметричный случай

В clojure множества создаются функцией set, которая принимает коллекцию. Чтобы объявить множество пользуются конструкцией: #{1 2 3} - множество из 1, 2 и 3.

[_ _] запись означает, что функция принимает 2 аргумента, но мы их не будем использовать.

Ключевой момент - функция диспетчеризации (fn [x y] (set [x y])). В данном случае значение диспетчеризации - множество, это обстоятельство позволяет не объявлять симметричные случаи, т.к. множество не поддерживает порядок.

Допустим, мы хотим добавить новый тип и получить игру камень-ножницы-бумага-пистолет. Для этого нам не нужно модифицировать предыдущий код. Для этого нам нужно просто объявить соответствующие методы для новых значений диспетчеризации:

(defmethod winner #{:gun} [_ _] :drawn-game)
(defmethod winner #{:gun :rock}     [_ _] :gun)
(defmethod winner #{:gun :paper}    [_ _] :gun)
(defmethod winner #{:gun :scissors} [_ _] :gun)

Причем таким образом можно расширять мультиметоды, объявленные в другом пространстве имен или даже в другой библиотеке.

Мультиметоды поддерживают иерархии, которые позволяют реализовывать наследование, в том числе множественное.

;; keyword могут иметь пространство имен
;; ::a - краткое объявление кейворда в текущем пространстве имен
;; текущее пространство - user
(assert ::a :user/a)

(defmulti foo identity)
(defmethod foo ::a [_] "implementation for ::a")

(defmulti bar identity)
(defmethod bar ::b [_] "implementation for ::b")

;; множественное наследование
;; x - производная a и b
(derive ::x ::a)
(derive ::x ::b)

(assert (= "implementation for ::a" (foo ::x)))
(assert (= "implementation for ::b" (bar ::x)))

Наследование работает и в случае, если значение диспетчеризации - вектор:

(defmulti foo (fn [x y] [x y]))
(defmethod foo [::a ::b] [_ _] true)

(derive ::x ::a)
(derive ::x ::b)

(assert (foo ::a ::b))
(assert (foo ::x ::x))
(assert (foo ::a ::x))
(assert (foo ::x ::b))

Т.к. мультиметоды используют функцию диспетчеризации и поддерживают значение по умолчанию, то это ООП, реализованное на принципах отправки сообщений (Alan Kay).

Протоколы

В большинстве случаев достаточно диспетчеризации по классу первого аргумента:

(defmulti foo (fn [this x y z] (class this)))

Для подобных случаев в clojure появились протоколы. Но прежде, нужно познакомиться с записями:

(defrecord User [id name])

(let [user (->User 1 "Alice")]
  (assert (= 1 (:id user)))
  (assert (= "Alice" (:name user)))
  (assert (= User (class user))))

Запись - это java класс, реализующий интерфейсы ассоциативных массивов(map). Атрибуты записи реализованы как соответствующие поля java класса. Кроме заранее указанных полей, запись может хранить произвольные:

(let [user (->User 1 "Alice")
      user (assoc user :additional "some value")]
  (assert (= "some value" (:additional user)))
  (assert (= User (class user))))

Запись - это надстройка над Типом. Тип - простой java класс не реализующий каких-либо интерфейсов. Как правило, пользуются Записями, а Типы используют, когда нужны «чистые» объекты.

(deftype T [attr])

(.-attr (->T 1)) ;;=> 1

Допустим, кроме записи User, у нас есть еще запись Admin и мы хотим проверять может ли кто-то создавать пользователей:

(defrecord User [id name])

(defrecord Admin [id name])

(defprotocol CreateUserAbility
  (can-create-user? [this]))

(extend User
  CreateUserAbility
  {:can-create-user? (fn [_] false)})

(extend Admin
  CreateUserAbility
  {:can-create-user? (fn [_] true)})

(let [user (->User 1 "Alice")]
  (assert (not (can-create-user? user))))

(let [admin (->Admin 1 "Bob")]
  (assert (can-create-user? admin)))

Протоколы могут содержать любое количество методов, как обычные java интерфейсы, но не поддерживают наследование. Протоколы могут расширять любую запись и любой java класс.

Кроме функции extend есть макросы extend-type и extend-protocol, которые делают запись более удобной:

(extend-type User
  CreateUserAbility
  (can-create-user? [_] false)
  OtherProtocol
  (some-method [_] :ok))

(extend-protocol CreateUserAbility
  User
  (can-create-user? [_] false)
  Admin
  (can-create-user? [_] true))

Если вы указываете реализацию протокола сразу при объявлении записи, то запись будет реализовывать java интерфейс, что повысит производительность. Эта же форма позволяет Записи реализовывать не протокол, а просто java интерфейс.

(defprotocol CreateUserAbility
  (can-create-user? [this]))

(defrecord User [id name]
  CreateUserAbility
  (can-create-user? [_] false))

(defrecord Admin [id name]
  CreateUserAbility
  (can-create-user? [_] true))

(let [user (->User 1 "Alice")]
  (assert (not (can-create-user? user))))

(let [admin (->Admin 1 "Bob")]
  (assert (can-create-user? admin)))

Стоит отметить, что если вы хотите добавить метод для работы с конкретной записью, то вам не нужен полиморфизм, и достаточно воспользоваться обычной функцией:

(defrecord User [id name])

(defn present [this]
  (str (:id this) " - " (:name this)))

(let [user (->User 1 "Alice")]
  (assert (= "1 - Alice" (present user))))

Записи и протоколы не поддерживают наследование, но при расширении типа протоколом можно воспользоваться функцией extend которая оперирует обычными ассоциативными массивами и функциями. Таким образом может быть реализовано наследование, примеси и т.п.:

(defrecord A [])
(defrecord B [])

(defprotocol Proto
  (method [this]))

(let [impl {:method (fn [_] :some-body)}]
  (extend A Proto impl)
  (extend B Proto impl))

(assert (= (method (->A))
           (method (->B))))

Кроме всего этого, есть возможность создать анонимную реализацию протокола или java интерфейса с помощью reify. Это удобно для тестирования или взаимодействия с java кодом. Для этого reify поддерживает замыкания:

(defprotocol Proto
  (method [this]))

(let [val      :val
      instance (reify Proto
                 (method [_] val))]
  (assert (= val (method instance))))

Функции и методы

Мультиметоды и протоколы позволяют расширять существующий «тип». Это позволяют делать и другие языки, например, в js можно добавить метод экземплярам:

String.prototype.foo = function() { return "foo" };
"any string".foo() //=> "foo"

Но что, если 2 библиотеки добавят метод с одним названием? Победит последний.

Благодаря префиксной записи вызов (мульти)метода не отличается от вызова функции:

(ns definitions)

(defn example-fn [x] :fn)

(defmulti example-multimethod identity)

(defprotocol P
  (example-method [this]))

;; ~~~~~~~~~~~~~~~~
(ns usage
  (:require [definitions]))

(deftype T [])

(extend-type T
  definitions/P
  (example-method [this] :method))

(defmethod definitions/example-multimethod :default [_] :multimethod)

(def instance (T.))
(definitions/example-fn instance)
(definitions/example-multimethod instance)
(definitions/example-method instance)

Таким образом расширения «типа» доступны через неймспейс и не конфликтуют между собой. Однако, это не относится к реализации протокола напрямую через deftype или defrecord, т.к. методы java класса не имеют неймспейса.

Benchmark

Бенчмарк с помощью criterium. Исходники в виде проекта можно получить тут.

(ns bench.bench
  (:require
   [criterium.core :as criterium]
   [clojure.template :as template]))

(defprotocol Proto
  (proto-method [this]))

(deftype A []
  Proto
  (proto-method [_] :ok))

(deftype B [])

(extend-type B
  Proto
  (proto-method [_] :ok))

(def c (reify
         Proto
         (proto-method [_] :ok)))

(deftype D [])
(defmulti multi-method class)
(defmethod multi-method D [_] :ok)

(defn bench []
  (template/do-template [method obj-expr]
                        (do
                          (prn '(method obj-expr))
                          (let [obj obj-expr]
                            (criterium/quick-bench (method obj)))
                          (print "\n\n\n"))
                        proto-method (->A)
                        proto-method (->B)
                        proto-method c
                        multi-method (->D)))
(proto-method (->A))
Evaluation count : 132892038 in 6 samples of 22148673 calls.
             Execution time mean : 2.863123 ns
    Execution time std-deviation : 0.019320 ns
   Execution time lower quantile : 2.838807 ns ( 2.5%)
   Execution time upper quantile : 2.879423 ns (97.5%)
                   Overhead used : 1.666364 ns



(proto-method (->B))
Evaluation count : 97196952 in 6 samples of 16199492 calls.
             Execution time mean : 4.596519 ns
    Execution time std-deviation : 0.064386 ns
   Execution time lower quantile : 4.548777 ns ( 2.5%)
   Execution time upper quantile : 4.701984 ns (97.5%)
                   Overhead used : 1.666364 ns

Found 1 outliers in 6 samples (16.6667 %)
    low-severe   1 (16.6667 %)
 Variance from outliers : 13.8889 % Variance is moderately inflated by outliers



(proto-method c)
Evaluation count : 131449896 in 6 samples of 21908316 calls.
             Execution time mean : 2.857191 ns
    Execution time std-deviation : 0.018021 ns
   Execution time lower quantile : 2.843163 ns ( 2.5%)
   Execution time upper quantile : 2.885828 ns (97.5%)
                   Overhead used : 1.666364 ns



(multi-method (->D))
Evaluation count : 15134856 in 6 samples of 2522476 calls.
             Execution time mean : 38.633649 ns
    Execution time std-deviation : 0.717109 ns
   Execution time lower quantile : 37.971764 ns ( 2.5%)
   Execution time upper quantile : 39.594540 ns (97.5%)
                   Overhead used : 1.666364 ns

Как видно, протоколы на порядок быстрее мультиметодов. Реализация протокола в deftype, defrecord или reify в 2 раза быстрее extend.

Expression problem

Этот пункт необязателен, и для интересующихся я оставлю ссылку на соответствующую статью