Одно из самых больших изменений в Clojure версии 1.2 — введение в язык новых артефактов: протоколов (protocols) и типов данных (datatypes). Данные изменения позволяют улучшить производительность программ по сравнению с мультиметодами, что в будущем даст возможность написать Clojure на Clojure (в данный момент протоколы и типы данных уже активно используются при реализации Clojure).
Протоколы и типы данных — два связанных друг с другом понятия. Протоколы используются для определения полиморфных функций, которые затем могут быть реализованы для конкретных типов данных (в том числе и из других библиотек).
Существует несколько причин введения протоколов и типов данных в новую версию языка:
isa/instanceof
);Также как и интерфейсы, протоколы позволяют объединить объявление нескольких полиморфных функций (или одной функции) в один объект3. Отличием от интерфейсов является то, что вы не можете унаследовать новый протокол от существующего протокола.
В отличии от имеющегося в Clojure gen-interface
(и соответствующих proxy/gen-class
)
определение протоколов и типов не требует AOT (ahead-of-time) компиляции исходного кода,
что упрощает распространение программ на Clojure. Однако при определении протокола,
Clojure автоматически создает соответствующий интерфейс, который будет доступен для кода,
написанного на Java.
Типы данных, определенные с помощью deftype
или defrecord
позволяют программисту на
Clojure определять свои структуры данных, вместо использования обычных отображений и
структур, но об этом ниже.
Важно помнить, что протоколы и типы данных с одним и тем же именем могут быть определены в разных пространствах имен, так что стоит быть осторожным и не наделать ошибок при импорте определений и последующей реализации протоколов!
Протоколом называется именованный набор функций с определенными сигнатурами. Для определения используется макрос, применение которого выглядит следующим образом:
(defprotocol название "описание" & сигнатуры)
название
— единственный обязательный параметр, хотя определение протокола без функций не
имеет особого смысла. В описании вы можете описать ваш протокол, и это описание будет
показываться при вызове функции doc
для вашего протокола. Для протокола вы можете указать
одну или несколько сигнатур функций, где каждая сигнатура выглядит следующим образом:
(имя [аргументы+]+ "описание")
Вы можете определять одну функцию, которая будет принимать различное количество
параметров, но первым аргументом функции всегда является объект, на основании которого
будет выполняться диспатчеризация, и к которому эта функция будет применяться. Вы можете
рассматривать его как this
в Java и C++. В дополнение к сигнатурам, вы можете описать
вашу функцию, но это необязательно.
Давайте посмотрим на стандартный пример:
(defprotocol AProtocol "A doc string for AProtocol abstraction" (bar [a b] "bar docs") (baz [a] [a b] [a b c] "baz docs"))
Данный протокол определяет две функции: bar
— с двумя параметрами, и baz
— с одним,
двумя или тремя параметрами.
defprotocol
также создаст соответствующий интерфейс, с тем же самым именем что и протокол.
Данный интерфейс будет иметь те же самые функции, что и протокол.
Протокол сам по себе ни на что не влияет — чтобы использовать его, мы должны добавить его
специализации для типов данных или классов JVM. Для этого может использоваться функция
extend
, использование которой выглядит следующим образом:
(extend тип-или-класс протокол-1 {:метод-1 уже-определенная-функция :метод-2 (fn [a b] ...) :метод-3 (fn ([a]...) ([a b] ...)...)} протокол-2 {...} ...)
Для этой функции вы указываете имя типа данных или класса (или nil
), и передаете список
состоящий из названий протоколов (протокол-1
и т.д.) и отображений, которые связывают
функции протокола (метод-1
и т.д.) с их реализациями — анонимными или именованными
функциями.
Стоит отметить, что функция extend
является низкоуровневым инструментом реализации
протоколов. Кроме этого, в состав языка введены макросы extend-protocol
& extend-type
,
которые немного упрощают реализацию протоколов4. Протокол также может быть реализован
непосредственно при объявлении типа данных.
Использование extend-type
выглядит практически также как и использование extend
, но
пользователь записывает реализации в более удобном виде (extend-type
раскрывается в
соответствующий вызов extend
):
(extend-type тип-или-класс
протокол-1
(метод-2 [a b] ...)
(метод-3 ([a]...)
([a b] ...)...)
протокол-2
(....)
...)
Макрос extend-protocol
использоваться в тех случаях, если вы хотите реализовать один
протокол для нескольких типов данных или классов. В общем виде использование
extend-protocol
выглядит следующим образом:
(extend-protocol название-протокола
Тип-или-Класс-1
(метод-1 ...)
(метод-2 ...)
Тип-или-Класс-2
(метод-1 ...)
(метод-2 ...)
...)
При использовании, extend-protocol
раскрывается в серию вызовов extend-type
для каждого из
используемых типов.
Давайте рассмотрим небольшой пример. Пусть мы объявим следующий простой протокол:
(defprotocol Hello "Test of protocol" (hello [this] "hello function"))
Мы можем использовать extend
, extend-protocol
, или extend-type
для его специализации для
класса String
:
(extend String Hello {:hello (fn [this] (str "Hello " this "!"))}) (extend-protocol Hello String (hello [this] (str "Hello " this "!"))) (extend-type String Hello (hello [this] (str "Hello " this "!")))
При использовании любой из этих реализаций для объекта класса String
мы получим один и тот
же ответ:
user> (hello "world") "Hello world!"
Стоит отметить, что если вы не реализовали протокол для определенного типа данных, то при
вызове функции будет сгенерировано исключение. В том случае, если вам необходима
"реализация по умолчанию", то вы можете специализировать протокол для класса Object
.
В Clojure 1.2 введены два метода определения новых именованных типов данных (deftype
и
defrecord
), которые реализуют абстракции, определенные протоколами и/или интерфейсами (к
типам данных относится также reify
, который описан ниже).
deftype
и defrecord
динамически создают именованный класс, который имеет набор заданных
полей и (необязательно) методов для одного или нескольких протоколов и/или интерфейсов.
Поскольку они не требуют явной компиляции, то это дает возможность их использования в
интерактивной разработке. С точки зрения разработчика deftype
и defrecord
похожи на
defstruct
, но во многом они отличаются:
deftype
является "базовым" инструментом для определения типов данных — созданный тип
имеет только конструктор, и ничего больше — все остальное должен реализовывать
разработчик. Но при этом, deftype
может иметь изменяемые поля, чего не имеет defrecord
.
В отличии от deftype
, defrecord
более прост в использовании, поскольку создаваемый тип
данных имеет большую функциональность (по большей части за счет реализации интерфейсов
IKeywordLookup
, IPersistentMap
, Serializable
и т.д.):
hashCode
и equals
;deftype
и defrecord
обычно имеют разные области применения: deftype
в основном
используется для "системных" вещей — коллекций, и т.п., тогда как defrecord
в основном
используется для хранения информации из "проблемной области" — данных о заказчиках,
записях в БД и т.п. — то, для чего использовались отображения в версиях 1.0 и 1.1.
Давайте рассмотрим как использовать конкретные средства для создания типов данных.
В общей форме использование макросов deftype
и defrecord
выглядит следующим образом:
(deftype имя [& поля] & спецификации) (defrecord имя [& поля] & спецификации)
Для обоих макросов обязательным параметром является лишь имя, которое становится именем
класса. Поля, которые станут членами класса, перечисляются в векторе, следующем за
именем, и могут содержать объявления типов. После этого вектора, можно указать список
реализуемых интерфейсов и протоколов, вместе с реализацией (это не обязательно, поскольку
для этого вы позже можете использовать extend-protocol
& extend-type
).
Спецификации протоколов/интерфейсов выглядят следующим образом:
протокол/интерфейс (название-метода [аргументы*] реализация)*
Вы можете указать любое количество протоколов/интерфейсов, которые будут реализованы
данным типом данных. Давайте посмотрим на простейший тип данных, который реализует
протокол Hello
:
(deftype A [] Hello (hello [this] (str "Hello A!")))
Мы можем вызвать функцию hello
для нашего объекта, и получим следующий вывод:
user> (hello (A.)) "Hello A!"
Мы можем также создать тип с помощью defrecord
:
(defrecord B [name] Hello (hello [this] (str "Hello " name "!")))
и вызвать метод hello
для этого типа:
user> (hello (B. "world")) "Hello world!"
Как уже отмечалось выше, создаваемые поля по умолчанию являются неизменяемыми, но если вы
создаете тип с помощью deftype
, то вы можете пометить некоторые поля как изменяемые,
используя метаданные (с помощью ключевого символа :volatile-mutable
или
:unsynchronized-mutable
). Для таких полей вы сможете использовать оператор (set! afield
aval)
для изменения данных. Давайте посмотрим как это делается на примере — если мы
создадим следующий протокол и тип данных:
(defprotocol Setter (set-name [this new-name])) (deftype AM [^{:volatile-mutable true} mfield] Hello (hello [this] (str "Hello " mfield "!")) Setter (set-name [this new-name] (set! mfield new-name)))то мы сможем изменять значение поля:
user> (def am (AM. "world")) #'user/am user> (hello am) "Hello world!" user> (set-name am "peace") "peace" user> (hello am) "Hello peace!"
reify
используется тогда, когда вам нужно реализовать протокол или интерфейс только в
одном месте — когда вы используете reify
вы одновременно объявляете тип, и сразу создаете
объект этого типа. Функция reify
по своему использованию очень похожа на proxy
, но с
некоторыми исключениями:
Эти отличия позволяют получить более высокую производительность по сравнению с proxy
, и
при создании и при выполнении.
Вот небольшой пример реализации протокола Hello
для конкретного объекта:
(def int-reify (reify Hello (hello [this] "Hello integer!")))
И при вызове hello
для этого объекта, мы получим соответствующий результат:
user> (hello int-reify)
"Hello integer!"
Для работы с протоколами и типами данных определено некоторое количество вспомогательных функций, которые могут вам понадобиться:
extends?
true
если данный тип данных (2-й аргумент) реализует интерфейс,
заданный первым аргументом;extenders
satisfies?
true
если данный протокол (1-й аргумент) применим к данному
объекту (2-й аргумент);Как всегда, основной источник информации — сайт языка: ознакомьтесь с разделами protocols и datatypes. Хорошее описание протоколов и типов данных можно найти в 13-й главе недавно вышедшей книги Practical Clojure. The Definitive Guide, а также в Clojure in Action и The Joy of Clojure. Thinking the Clojure Way, которые будут выпущены в ближайшее время.
Stuart Halloway создал очень интересный скринкаст в котором он рассказывает о том, зачем были созданы протоколы и data types, и демонстрирует их применение на небольших примерах.
Введение новых возможностей в язык не обходится без статей в блогах. Вот ссылки на некоторые интересные статьи на эту тему:
1. Стоит однако отметить, что протоколы не реализуют monkey patching и внедрение методов (injection) в существующие типы данных.
2. Возможность реализации абстракций на Clojure и высокая скорость работы протоколов позволит в будущем написать Clojure на самой Clojure, без использования исходного кода на Java.
3. Люди знакомые с Haskell могут рассматривать протоколы как некоторое подобие типов классов (typeclasses) в этом языке, правда при этом нельзя определять реализации по умолчанию для методов.
4. Но extend
может использоваться в тех случаях, когда вы хотите использовать одни и те
же реализации для разных типов данных — в этом случае, вы можете создать отображение
с нужными функциями, и использовать его для разных типов, например, как описано в
следующем блог-постинге.
Last change: 05.03.2013 16:54