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

Встраивание поведения в классы с помощью языков Groovy, Scala и Clojure

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

Нил Форд, Архитектор приложений, ThoughtWorks

Нил Форд (Neal Ford) работает архитектором приложений в ThoughtWorks, революционной компании, предоставляющей профессиональные IT-услуги и помогающей талантливым людям по всему миру эффективнее использовать программное обеспечение. Он также является проектировщиком и разработчиком приложений, учебных материалов, журнальных статей, учебных курсов, видео/DVD-презентаций и автором книг "Разработка с Delphi: Объектно-ориентированный подход", "JBuilder 3 Unleashed" и "Искусство разработки Web-приложений на Java". Он специализируется на консультациях по построению широкомасштабных корпоративных приложений. Он также является общепризнанным докладчиком и выступал на многочисленных конференциях разработчиков по всему миру. С ним можно связаться по адресу: nford@thoughtworks.com.



09.12.2013

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

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

В конструкцию языка Java намеренно были внесены определенные ограничения с целью избежания характерных проблем предшественников этого языка. Например, разработчики языка Java посчитали, что множественное наследование в языке C++ порождает слишком много сложностей, поэтому решили не включать эту возможность. Фактически они встроили в язык лишь несколько вариантов поддержки расширяемости, базирующихся на единичном наследовании и на интерфейсах.

Другие языки, в том числе языки Java.next, поддерживают мощный набор возможностей расширения. В этой и в двух последующих статьях я рассматриваю способы расширения классов Java, которые не подразумевают наследования. В этой статье вы увидите, как добавлять методы к существующим классам — непосредственно или с помощью т.н. "синтаксического сахара" (syntactic sugar — дополнения синтаксиса языка программирования, которые не добавляют новых возможностей, а лишь делают использование языка более удобным для человека).

Проблема выражения

Т. н. "проблема выражения" (Expression Problem) – это хорошо известное наблюдение из недавней истории компьютерных наук, которое берет свое начало в неопубликованной статье Филипа Уодлера (Philip Wadler) из компании Bell Labs (см. раздел Ресурсы). Стюарт Сиерра (Stuart Sierra) проделал огромную работу по детальному объяснению этой проблемы в своей статье для developerWorks под названием: Solving the Expression Problem with Clojure 1.2 (Решение «проблемы выражения» с помощью Clojure 1.2). В своей статье Ф. Уодлер говорит следующее.

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

Другими словами, речь идет о том, как расширить функциональность классов в рамках иерархии без использования приведения к типам или операторовif?

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

Один из способов решения этой проблемы состоит в максимальном упрощении переключения между этими двумя форматами посредством дополнения типа Integer методами преобразования. Новые языки предлагают несколько возможностей для реализации такого решения; в этой статье я рассматриваю следующие из них:

  • Открытые классы
  • Классы-обертки (Wrapper class)
  • Протоколы

Категории языка Groovy и класс ExpandoMetaClass

Groovy предлагает два различных способа расширения существующих классов с помощью т.н. открытых классов— что позволяет "повторно открыть" определение класса с целью внесения изменений (например, добавления, изменения или удаления методов).

Классы категории

Классы категории— концепция, заимствованная у языка Objective-C – это регулярные классы со статическими методами. Каждый метод принимает не менее одного параметра, который представляет тип, дополняющий этот метод. Например, если я хочу добавить методы к Integer, то мне нужны статические методы, которые принимают этот тип в качестве первого параметра (см. листинг 1).

Листинг 1. Классы категории в языке Groovy
class IntegerConv {
  static Double getAsMeters(Integer self) {
    self * 0.30480
  }

  static Double getAsFeet(Integer self) {
    self * 3.2808
  }
}

Класс IntegerConv в листинге 1 содержит два метода дополнения, каждый из которых принимает параметр типа Integer с именем self (общепринятое стандартное имя). Чтобы использовать эти методы, я должен обернуть обращающийся код в блок use (см. листинг 2).

Листинг 2. Использование классов категории
@Test void test_conversion_with_category() {
  use(IntegerConv) {
    assertEquals(1 * 3.2808, 1.asFeet, 0.1)
    assertEquals(1 * 0.30480, 1.asMeters, 0.1)
  }
}

Особый интерес в листинге 2 представляют следующие два момента. Во-первых, несмотря на то, что метод расширения в листинге 1 имеет имя getAsMeters(), я вызываю его как 1.asMeters. «Синтаксический сахар» языка Groovy применительно к свойствам в Java позволяет мне выполнить метод getAsMeters() таким образом, как будто он представляет собой поле класса с именем asMeters. Если я опускаю as в методах расширения, то вызовы методов расширения требуют пустых круглых скобок, например, 1.asMeters(). Обычно я предпочитаю более аккуратный синтаксис на основе свойств, который является типовым при написании т. н. "предметно-ориентированных" языков (domain-specific language, DSL).

Второй примечательный момент в листинге 2— это вызовы asFeet и asMeters. В пределах блока use я вызываю новые методы точно так же, как и встроенные методы. Это расширение прозрачно в рамках лексических границ блока use, что весьма неплохо, поскольку ограничивает область действия дополнений для классов.

ExpandoMetaClass

атегории были первым механизмом расширения, который был добавлен языком Groovy, однако его лексическое определение области действия оказалось слишком ограничивающим при построении Grails — веб-среды на основе Groovy. Грэм Рочер (Graeme Rocher), один из создателей Grails, был весьма огорчен этими ограничениями в категориях и добавил к языку Groovy другой механизм расширения под названием ExpandoMetaClass.

ExpandoMetaClass— это инициализируемый в режиме отложенной инициализации держатель расширения, способный "вырасти" из любого класса. В листинге 3 показано, как реализовать мое расширение класса Integer с помощью класса ExpandoMetaClass:

Листинг 3. Использование ExpandoMetaClass для расширения класса Integer
class IntegerConvTest{

  static {
    Integer.metaClass.getAsM { ->
      delegate * 0.30480
    }

    Integer.metaClass.getAsFt { ->
      delegate * 3.2808
    }
  }

  @Test void conversion_with_expando() {
    assertTrue 1.asM == 0.30480
    assertTrue 1.asFt == 3.2808
  }
}

В листинге 3 я использую держатель metaClass для добавления свойств asM и asFt с таким же соглашением об именовании, как в листинге 2. Вызов метакласса появляется в статическом инициализаторе для класса test, поскольку мне нужно гарантировать, что дополнение будет происходить до первого появления метода расширения.

И классы категории, и ExpandoMetaClass осуществляют вызов методов extension-class до встроенных методов. Это позволяет добавлять, изменять или удалять существующие методы. Соответствующий пример показан в листинге 4.

Листинг 4. Классы расширения, заменяющие существующие методы
@Test void expando_order() {
  try {
    1.decode()
  } catch(NullPointerException ex) {
    println("can't decode with no parameters")
  }
  Integer.metaClass.decode { ->
    delegate * Math.PI;
  }
  assertEquals(1.decode(), Math.PI, 0.1)
}

Первый вызываемый метод decode() в листинге 4— это встроенный статический Groovy-метод, предназначенный для изменения кодировок целых чисел. Обычно этот метод принимает единственный параметр; в случае вызова без параметра он бросает исключение NullPointerException. Когда я дополняю класс Integer моим собственным методом decode(), этот метод заменяет исходный метод.


Неявные приведения в языке Scala

В Scala этот аспект проблемы выражения решается с помощью классов-оберток. Чтобы добавить метод к классу, мы добавляем его к хелпер-классу (helper class), а затем реализуем неявное приведение от исходного класса к своему хелперу. После того как приведение происходит, мы незримым образом вызываем этот метод из хелпер-класса, а не из исходного класса. Этот механизм используется в примере, показанном в листинге 5.

Листинг 5. Неявные приведения в языке Scala
class UnitWrapper(i: Int) {
  def asFt = {
    i * 3.2808
  }

  def asM = {
    i * 0.30480
  }
}

implicit def unitWrapper(i:Int) = new UnitWrapper(i)

println("1 foot = " + 1.asM + " meters");
println("1 meter = " + 1.asFt + "foot")

В листинге 5 я определяю хелпер-класс с именем UnitWrapper, который принимает один параметр конструктора и два метода:asFt и asM. Имея хелпер-класс, который преобразует значения, я создаю implicit def посредством приписывания нового UnitWrapper. Чтобы вызвать нужный метод, я просто вызываю его, как будто он является методом исходного класса, например, 1.asM. Когда Scala не в состоянии найти метод asM в классе Integer, он осуществляет проверку на наличие неявных преобразований, что позволяют ему преобразовывать вызывающий класс в класс с целевыми методами. Подобно Groovy, язык Scala также имеет свой синтаксический сахар, который позволяет мне опустить круглые скобки при вызове метода (обращаю внимание, что это свойство языка, а не соглашение об именовании).

Хелперы преобразования в Scala обычно являются объектами (object), а не классами, однако я использую именно класс, поскольку хочу передать соответствующее значение как параметр конструктора (что не допускается для object).

Неявные приведение в Scala — это изящный и типобезопасный способ дополнения существующих классов, однако с помощью этого механизма нельзя изменить или удалить существующие методы (в отличие от открытых классов).


Протоколы Clojure

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

Чтобы расширить набор чисел и с целью добавления преобразований, я определяю протокол, содержащий две мои функции (asF и asM).Я могу использовать этот протокол для применения функции extend к существующему классу (такому как Number). Функция extend принимает целевой класс в качестве первого параметра, протокол в качестве второго параметра и отображение с именами функций в виде ключей и с реализациями (в виде анонимных функций) в качестве значений. В листинге 6 показано преобразование единиц измерения в Clojure:

Листинг 6. Протоколы Clojure для расширения
(defprotocol UnitConversions
  (asF [this])
  (asM [this]))

(extend Number
  UnitConversions
  {:asF (fn [this] (* this 3.2808))
   :asM #(* % 0.30480)})

Для проверки этого преобразования я могу использовать новое расширение в Clojure REPL (интерактивный цикл read-eval-print):

user=> (println "1 foot is " (asM 1) " meters")
1 foot is  0.3048  meters

Реализация этих двух функций преобразования в листинге 6 иллюстрирует две разновидности синтаксиса для объявлений анонимной функции. Каждая функция принимает один параметр (в функции asF это параметр this). Функции с одним аргументом широко распространены, поэтому Clojure имеет соответствующие синтаксические средства для их создания, например, в функции AsM можно использовать % в качестве подстановочного символа для параметра.

Протоколы предоставляют простое решение для добавления методов (в качестве функций) к существующим классам. Кроме того, в Clojure есть несколько полезных макросов, которые позволяют консолидировать расширения в виде группы. Например, веб-инфраструктура Compojure (см. раздел Ресурсы) использует протоколы для расширения различных типов с целью оптимизации их представления. В листинге 7 показан фрагмент из определения Renderable в Compojure.

Листинг 7. Расширение множества типов посредством протоколов
(defprotocol Renderable
  (render [this request]
    "Render the object into a form suitable for the given request map."))

(extend-protocol Renderable
  nil
  (render [_ _] nil)
  String
  (render [body _]
    (-> (response body)
        (content-type "text/html; charset=utf-8")))
  APersistentMap
  (render [resp-map _]
    (merge (with-meta (response "") (meta resp-map))
           resp-map))
  IFn
  (render [func request]
    (render (func request) 
  ; . . .

В листинге 7 протокол Renderable определяется с помощью одной функции render, которая принимает в качестве параметров значение и отображение запроса. Макрос языка Clojure с именем extend-protocol позволяющий группировать определения протоколов, принимает пары "тип/реализация". Clojure позволяет заменять незначимые для разработчика параметры символами подчеркивания. В листинге 7 видимая часть этого определения предоставляет инструкции рендеринга для nil, String, APersistentMap и IFn(базовый интерфейс для функций в языке Clojure). Данная среда имеет большое количество других типов, однако для краткости они не показаны в листинге 7. Обратите внимание, насколько прекрасно это работает на практике: для любого типа, который вы захотите отобразить, вы можете определить семантику и расширения в одном месте.


Заключение

В этой статье я представил проблему выражения и подробно рассмотрел, как языки Java.next подходят к одному из ее аспектов — а именно к аккуратному расширению существующих классов. В каждом из этих языков применяются свои приемы (Groovy использует открытые классы, Scala — классы-обертки, а Clojure — протоколы), однако все они дают схожие результаты.

Однако проблема выражения не ограничивается дополнением типов. В следующей статье этого цикла я рассмотрю другие возможности для расширения.

Ресурсы

  • Оригинал статьи: Java.next: Extension without inheritance, Part 1.
  • Scala: современный функциональный язык на платформе JVM.
  • Clojure: современный функциональный вариант Lisp, работающий на платформе JVM.
  • Groovy динамическая разновидность Java с обновленным синтаксисом и расширенными возможностями.
  • The Expression Problem (Проблема выражения): неопубликованная статья Филипа Уодлера (Philip Wadler) 1998 года, в которой подробно рассматривается проблема выражения.
  • Solving the Expression Problem with Clojure 1.2 (Решение «проблемы выражения» с помощью Clojure 1.2), Stuart Sierra, developerWorks, декабрь 2010 г. Дополнительные сведения о решении проблемы выражения с помощью языка Clojure.
  • Compojure: среда маршрутизации для Ring, написанная на Clojure.
  • Изучение альтернативных языков для платформы Java:: путь обучения developerWorks, посвященный альтернативным JVM-языкам.
  • Записная книжка дизайнера языка: в этом цикле статей developerWorks архитектор языка Java Брайан Гетц исследует некоторые проблемы дизайна языка, вызвавшие трудности при эволюции языка Java версий Java SE 7, Java SE 8 и далее.
  • Функциональное мышление: колонка Нила Форда на developerWorks, посвященная функциональному программированию.
  • Java.next: Языки Java.next. Общие черты Groovy и Scala и Clojure, часть 1 (Нил Форд, developerWorks, март 2013 г.): о преодолении неспособности языка Java к перегрузке операторов в языках Java.next — Groovy, Scala и Clojure.
  • Java.next: Языки Java.next. Общие черты Groovy и Scala и Clojure, часть 2 (Нил Форд, developerWorks, апрель 2013 г.): преодоление стереотипности и сложности с помощью синтаксических механизмов языков Java.next.
  • Java.next: Языки Java.next. Общие черты Groovy и Scala и Clojure, часть 3 (Нил Форд, developerWorks, май 2013 г.): сравнение улучшений в Clojure, Scala и Groovy, позволяющих справляться с такими проблемными областями, как исключения, выражения и крайние проявления значения null.
  • Java.next: Языки Java.next (Нил Форд, developerWorks, январь 2013 г.): о сходствах и различиях трех JVM-языков нового поколения (Groovy, Scala и Clojure) и об их преимуществах.
  • Другие статьи автора (Нил Форд, developerWorks, с июня 2005 г. по настоящее время): о Groovy, Scala, Clojure, функциональном программировании, архитектуре, дизайне, Ruby, Eclipse и других Java-технологиях.

Комментарии

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=956530
ArticleTitle=Java.next: Расширение без наследования, Часть 1
publish-date=12092013