Java.next: Расширение без наследования, Часть 2

Протоколы Clojure

Язык Java™ страдает от ограничений, умышленно внесенных в его механизмы расширения, поэтому он полагается преимущественно на наследование и на интерфейсы. Языки Groovy, Scala и Clojure предлагают множество альтернативных вариантов для расширения. В этой статье продолжается рассмотрение использования протоколов Clojure в качестве механизма расширения.

Об этом цикле статей

Java-технология оставит в наследство не язык, а платформу. На платформе JVM работают более 200 языков программирования; какой-то из них неминуемо вытеснит язык Java в качестве наилучшего способа программирования для JVM. Этот цикл статей посвящен исследованию трех языков нового поколения для платформы JVM — Groovy, Scala и Clojure — а также сравнению и противопоставлению новых возможностей и парадигм, чтобы дать Java-разработчикам возможность заглянуть в свое ближайшее будущее.

Статья Java.next: Расширение без наследования, Часть 1 посвящена рассмотрению механизмов Groovy, Scala и Clojure, которые добавляют новые методы к существующим классам — это один из множества способов, которыми языки Java.next обеспечивают расширение без наследования. В данной статье описывается, как протоколы Clojure предоставляют новые возможности расширения Java и предлагают изящные решения для т. н. "проблемы выражения" (Expression Problem).

Хотя эта статья посвящена преимущественно расширяемости, в ней также затрагиваются некоторые функции Clojure, которые обеспечивают беспрепятственное взаимодействие кода, написанного на Clojure и на Java. Эти два языка имеют явные отличия (Java является императивным и объектно-ориентированным языком, а Clojure — функциональным), однако в Clojure реализовано несколько возможностей, которые позволяют ему работать с Java-конструкциями с минимальными препятствиями.

Повторное рассмотрение протоколов Clojure

Протоколы являются важной частью экосистемы Clojure. В первой статье данного цикла описано использование протоколов как способа добавления методов к существующим классам. Протоколы также помогают языку Clojure имитировать многие хорошо знакомые функции объектно-ориентированных языков. Например, Clojure имитирует объектно-ориентированные классы — комбинации данных и методов — посредством связывания записей и функций через протоколы. Чтобы было понятно взаимодействие между протоколами и записями, я должен сначала рассмотреть отображения— базовую структуру данных, которая лежит в основе записей в Clojure.

Отображения и записи

В Clojure отображение представляет собой коллекцию пар " ключ –значение" (знакомая концепция, применяющаяся и в других языках). Например, цикл REPL (read–eval–print) в листинге 1 начинается с создания отображения, содержащего информацию о языке программирования Clojure.

Листинг 1. Взаимодействие с отображением Clojure
user=> (def language {:name "Clojure" :designer "Hickey" })
#'user/language
user=> (get language :name)
"Clojure"
user=> (:name language)
"Clojure"
user=> (:designer language)
"Hickey"

Язык Clojure активно использует отображения, поэтому в него включены специальные синтаксические средства для облегчения взаимодействия с ними. Для извлечения значения, ассоциированного с ключом, можно использовать знакомую функцию (get ). Однако Clojure пытается сделать такие типовые операции менее многословными.

В Java-среде исходный код языка не является нативной структурой данных; он должен быть подвергнут синтаксическому анализу и трансляции. В Clojure (и в других разновидностях языка Lisp) представление исходного кода является нативной структурой данных, например, списком, — что частично объясняет необычный синтаксис этого языка. Когда интерпретатор Lisp читает список в исходном коде, он пытается транслировать первый элемент этого списка в нечто, допускающее вызов, например, в функцию. Соответственно выражение (:name language) в листинге 1 возвращает такой же результат, как выражение (get language :name). Clojure предлагает эти синтаксические удобства, поскольку извлечение элементов из отображения является достаточно распространенной операцией.

Кроме того, в Clojure имеется несколько конструкций, способных заполнять промежуток функция-вызов и таким образом расширять такую характеристику, как callability— т. е. способность к вызову в качестве функции. Java-программы способны вызывать лишь методы и встроенные операторы языка. В листинге 1 показано, что ключи отображения, такие как (:name language), способны действовать как вызовы функции в Clojure. Сами отображения также являются вызываемыми; вы можете использовать альтернативный синтаксис (language :name), если он покажется вам более удобочитаемым. Обширные возможности вызываемости в языке Clojure упрощают его использование, избавляя от повторяющихся синтаксических элементов (таких как вездесущие get и set в мире Java-программ).

Однако отображения не эмулируют JVM-классы полностью. Clojure предоставляет другие способы, помогающие моделировать проблемы, которые охватывают данные и поведение — и которые проще интегрируются с исполняющей JVM-машиной. Можно создать несколько конструкций, включая типы и записи, соответствующих различным степеням полноты относительно подобных им нижележащих JVM-классов. Создание типа — традиционно используемого для моделирования механических структур— осуществляется с помощью (deftype ). Например, если вам потребуется тип данных для хранения XML, вы скорее всего используете (deftype MyXMLStructure) в качестве механизма для извлечения данных, содержащихся внутри XML. В Clojure записи используются для данных в буквальном смысле — как записи информации, которая является основным назначением приложения. Для поддержки такого подхода в базовом определении записи языка Clojure предусмотрено множество интерфейсов, которые поддерживают такие возможности, как callability. REPL в листинге 2 демонстрирует нижележащие классы и суперклассы записи.

Листинг 2. Нижележащие классы и суперклассы записи
user=> (defrecord Person [name age postal])
user.Person

user=> (def bob (Person. "Bob" 42 60601))
#'user/bob
user=> (:name bob)
"Bob"
user=> (class bob)
user.Person
user=> (supers (class bob))
#{java.io.Serializable clojure.lang.Counted java.lang.Object 
clojure.lang.IKeywordLookup clojure.lang.IPersistentMap 
clojure.lang.Associative clojure.lang.IMeta 
clojure.lang.IPersistentCollection java.util.Map 
clojure.lang.IRecord clojure.lang.IObj java.lang.Iterable 
clojure.lang.Seqable clojure.lang.ILookup}

В листинге 2 я создаю новую запись с именем Person, которая имеет поля name, age и postal. Я могу сконструировать этот тип новой записи с помощью синтаксических средств Clojure для вызовов конструкторов (используя имя класса и точку в качестве вызова функции). Возвращаемое значение является экземпляром, именованным согласно пространству имен (по умолчанию все REPL-взаимодействия происходят в пространстве имен user). namespace by default.) Правила callability по-прежнему действуют, поэтому я могу получить доступ к элементам записи с помощью синтаксических средств, показанных в листинге 1.

Когда я вызываю функцию (class ), она возвращает пространство имен плюс имя класса, созданное Clojure (совместимое с Java-кодом). Кроме того, я могу получить доступ к суперклассам записи Person с помощью (supers ). В последних четырех строках листинга 2 язык Clojure реализует несколько интерфейсов, включая callability-интерфейсы (такие как IPersistentMap), что позволяет нативному синтаксису Clojure для отображений работать с классами и с объектами. Этот набор автоматически включенных интерфейсов является одним из основных различий между записями и типами (которые не получают реализаций интерфейса автоматически).


Реализация протоколов с записями

Протокол Clojure представляет собой именованный набор именованных функций и их сигнатур. Определение в листинге 3 создает объект протокола и набор полиморфных функций протокола.

Листинг 3. Протокол Clojure
(defprotocol AProtocol
  "A doc string for AProtocol abstraction"
  (bar [this a] "optional doc string for aar function")
  (baz [this a] [this a b] 
     "optional doc string for multiple-arity baz function"))

Функции в листинге 3 координируются по типу первого аргумента, что делает их полиморфными по этому типу (традиционно он имеет имя this с целью имитации держателя контекста Java). Соответственно каждая функция протокола должна иметь не менее одного параметра. Традиционно именование протоколов производится с использованием т. н. "верблюжьей нотации" (CamelCase); поскольку эти протоколы материализуют интерфейсы Java на уровне JVM, соответствие их имен соглашениям Java об именовании упрощает совместимость.

Запись может реализовать протокол подобно реализации интерфейса на языке Java. Запись должна (это проверяется в процессе исполнения) реализовывать функции, которые соответствуют сигнатурам протокола. В листинге 4 я создаю запись, которая реализует протокол AProtocol:

Листинг 4. Реализация протокола
(defrecord Foo [x y]
   AProtocol
   (bar [this a] (min a x y))
   (baz [this a] (max a x y))
   (baz [this a b] (max a b x y)))

;exercising the record
(def f (Foo. 1 200))
(println (bar f 4))
(println (baz f 12))
(println (baz f 10 2000))

В листинге 4 я создаю запись с именем Foo, которая имеет два поля: x и y. Чтобы реализовать протокол, я должен включить функции, соответствующе его сигнатуре. После того как я реализую этот протокол, я смогу применять эти функции как обычные функции к экземплярам объекта. В рамках определений функции я имею доступ к внутренним полям записи (x и y) и к параметрам функции.


Опции расширения протокола

Протоколы создавались как способ чистого расширения существующих классов и иерархий, поэтому при их проектировании учитывалась проблема выражения (полное определение проблемы выражения приведено в первой части данного цикла). Расширения представляют собой функции (как и все остальное в Clojure), что избавляет от многих проблем идентификации и наследования, свойственных объектно-ориентированным языкам. Тем не менее этот механизм допускает различные полезные расширения.

Clojure является «гостевым» языком: он спроектирован для исполнения (с использованием протоколов) на нескольких платформах, включая .NET и JavaScript (посредством компилятора ClojureScript). Для JavaScript нужна среда, способная настраивать, разбирать на части, загружать и оценивать код. Соответственно ClojureScript определяет запись BrowserEnv, которая обрабатывает такие функции жизненного цикла, как setup и teardown для любой подходящей JavaScript-среды (браузер, REPL, имитатор). Определение записи для BrowserEnv представлено в листинге 5:

Листинг 5. Запись BrowserEnv компилятора ClojureScript
(defrecord BrowserEnv []
  repl/IJavaScriptEnv
  (-setup [this]
    (do (require 'cljs.repl.reflect)
        (repl/analyze-source (:src this))
        (comp/with-core-cljs (server/start this))))
  (-evaluate [_ _ _ js] (browser-eval js))
  (-load [this ns url] (load-javascript this ns url))
  (-tear-down [_]
    (do (server/stop)
        (reset! server/state {})
        (reset! browser-state {}))))

Методы жизненного цикла, определенные в протоколе IJavaScriptEnv, предоставляют реализациям (таким как браузер) доступ к общему интерфейсу. Дефис в начале каждого имени функций (например, -tear-down ) используется в соответствии с соглашениями ClojureScript (а не языка Clojure).

Другая цель решений проблемы выражения состоит в поддержании возможности добавления новых опций к существующим иерархиям без повторной компиляции или какого-либо другого на них влияния. В версии Clojure 1.5 представлена усовершенствованная библиотека коллекций под названием reducers. Эта библиотека позволяет добавить автоматическую параллельную обработку для многих типов коллекций. Чтобы использовать библиотеку reducers, существующие типы должны реализовать один из методов этой библиотеки, а именно coll-fold. Посредством протоколов и удобного макроса extend-protocol— который позволяет одновременно распространить протокол на множество типов — функция (coll-fold ) доступна нескольким базовым типам (см. листинг 6).

Листинг 6. Присоединение функции (coll-fold ) к нескольким типам
(extend-protocol CollFold
 nil
 (coll-fold
  [coll n combinef reducef]
  (combinef))

 Object
 (coll-fold
  [coll n combinef reducef]
  ;;can't fold, single reduce
  (reduce reducef (combinef) coll))

 clojure.lang.IPersistentVector
 (coll-fold
  [v n combinef reducef]
  (foldvec v n combinef reducef))

 clojure.lang.PersistentHashMap
 (coll-fold
  [m n combinef reducef]
  (.fold m n combinef reducef fjinvoke fjtask fjfork fjjoin)))

Вызов (extend-protocol ) в листинге 6 присоединяет протокол CollFold (который содержит единственный метод (coll-fold )), к следующим типам: nil, Object, IPersistentVector и PersistentHashMap. Благодаря этой библиотеке корректно работает даже nil (Clojure-разновидность значения null в языке Java) при обращении с типовыми граничными случаями пустых коллекций. Библиотека reducers также присоединяется к двум базовым классам коллекций (IPersistentVector и IPersistentHasMap), добавляя функциональность редукции "поверх" иерархий этих коллекций.

В Clojure реализован элегантный набор стандартных блоков, обеспечивающий простые, но при этом весьма мощные возможности расширения. Поскольку этот язык базируется на функциях, а не на классах, некоторые разработчики испытывают трудности при организации кода без использования классов в качестве основной организационной структуры. Язык Clojure организует код почти таким же способом, как язык Java, с минимальными различиями. В Java имеются пакеты, классы и методы, а в Clojure - пространства имен (которые примерно соответствуют пакетам) и функции (которые примерно соответствуют методам). Кроме того, при необходимости протоколы Clojure генерируют нативные интерфейсы Java, которые разработчики могут использовать для совместимости. Согласно соглашениям Clojure, протоколы определяются на границах компонентов, а подобные функции и протоколы помещаются в рамках одного пространства имен. Clojure не имеет классов как механизма сокрытия информации, однако позволяет определять приватные функции для пространства имен (с помощью определения функции (defn- )).

Организация кода в Clojure в виде пространств имен дает возможность чистого и централизованного расширения. Посмотрите на протокол CollFold в листинге 6, который появляется в файле reducers.clj исходного кода Clojure. Этот файл, добавленный в версии Clojure 1.5, включает в себя протокол, новые типы и расширения. Расширение протокола позволяет вернуться к базовым типам (таким как Object) и добавить функциональность редукции, которая частично реализуется посредством приватных функций пространства имен в рамках пространства имен reducers. Clojure позволяет добавить новое значимое поведение к существующей иерархии с хирургической точностью, без изощренного хакерства, и сохраняет все надлежащие детали в одном месте.

Макрос (extend-type ) подобен макросу (extend-protocol ); он позволяет одновременно добавить к типу несколько протоколов. В листинге 7 показано, как ClojureScript добавляет функциональность коллекций к array:

Листинг 7. Добавление функциональности коллекций к массивам JavaScript
(extend-type array
  ICounted
  (-count [a] (alength a))

  IReduce
  (-reduce [col f] (array-reduce col f))
  (-reduce [col f start] (array-reduce col f start)))

В листинге 7 код ClojureScript использует JavaScript-массивы для реагирования на Clojure-функции, такие как (count ) и (reduce ). Макрос (extend-type ) позволяет реализовать несколько протоколов в одном месте. Clojure исходит из предположения, что коллекции реагируют на count, а не на length, поэтому присоединение протокола ICounted и функции добавляет соответствующий псевдоним метода.

Для материализации протоколов записи не требуются. Как это делается для анонимных объектов в Java, реализацию и использование протоколов можно осуществлять встроенным образом (см. листинг 8).

Листинг 8. Встраивание протоколов
(let [z 42
      p (reify AProtocol
       (bar [_ a] (min a z))
       (baz [_ a] (max a z)))]
  (println (baz p 12)))

В листинге 8 я использую блок let с целью создания двух локальных связываний: x и p— и определения встраиваемого протокола. Когда я создаю анонимный протокол, я по-прежнему имею доступ к локальной области: присутствие z в качестве параметра является допустимым, поскольку z находится в области действия для этого блока let. Реализованный таким образом протокол включает в себя свою среду подобно блоку замыкания. Обратите внимание, что я не реализовал протокол полностью; отсутствуют некоторые версии арности для функции baz. В отличие от Java-интерфейсов, реализации протоколов являются необязательными. Clojure не настаивает на реализации протокола на этапе компиляции, однако выдает ошибку в процессе исполнения, если ожидаемый метод протокола на самом деле не существует.


Заключение

В этой статье цикла Java.next показано, как общепринятые соглашения Java, такие как классы и интерфейсы, отображаются на структуры языка Clojure. Кроме того, в статье рассматривается использование протоколов в Clojure, а также простое и элегантное решение проблемы выражений в языке Clojure посредством нескольких практичных изменений. В следующей статье, которая завершает мини-цикл Расширение без наследования, я рассмотрю т.н. "миксины" (mixin) в языке Groovy.

Ресурсы

Научиться

Получить продукты и технологии

Обсудить

  • Присоединяйтесь к сообществу developerWorks. Связывайтесь с другими пользователями developerWorks и знакомьтесь с ориентированными на разработчиков форумами, блогами, группами и вики-ресурсами.

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java, Open source
ArticleID=956532
ArticleTitle=Java.next: Расширение без наследования, Часть 2
publish-date=12092013