Java.next: Миксины и трейты

Подмешивание нового поведения в классы Scala и Groovy

Основная парадигма языка Java™— объектная ориентированность с одиночным наследованием — эффективно моделирует большинство задач программирования, однако далеко не все. Языки Java.next расширяют эту парадигму различными способами, в том числе с помощью т.н. "миксинов" (mixin) и "трейтов" (trait). В этой статье из цикла Java.next описываются механизмы, общие для миксинов и трейтов, а также рассматриваются тонкие различия между миксинами в языке Groovy и трейтами в языке Scala.

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

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



16.12.2013

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

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

Разработчики языка Java были хорошо знакомы с C++ и другими языками программирования, которые поддерживают множественное наследование, посредством которого класс может наследовать произвольному количеству родителей. Одна из проблем множественного наследования — невозможность определить, от какого именно родителя получена унаследованная функциональность. Это так называемая "проблема ромбического наследования" (diamond problem) (см. раздел Ресурсы). Эта проблема и другие сложности, присущие множественному наследованию, побудили проектировщиков языка Java ограничиться единичным наследованием и интерфейсами.

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

Миксины

Мороженое как источник вдохновения

Концепция миксинов ("примесей") впервые была реализована в языке Flavors (см. раздел Ресурсы). Источником вдохновения для этой концепция послужила лавка по продаже мороженого неподалеку от офиса, в котором происходила разработка этого языка. Эта лавка предлагала "чистые" сорта мороженого с любыми дополнительными добавками (тертый шоколад, карамельные крошки, орехи и так далее) по желанию клиента.

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

В Ruby, Groovy и подобные им языках миксины дополняют существующие иерархии классов на стыке между интерфейсом и родительским классом. Подобно интерфейсам, миксины действуют как типы при проверке с помощью оператора instanceof и соблюдают те же правила расширения. К классу можно применить неограниченное количество миксинов. В отличие от интерфейсов, миксины не только специфицируют сигнатуры методов, но и способны реализовать поведение сигнатур.

В первых языках, которые поддерживали миксины, эти миксины содержали только методы, но не состояния (в частности, они не содержали переменных экземпляра). В настоящее время многие языки (в том числе Groovy) поддерживают т.н. stateful mixin (миксины с хранением состояния). Трейты Scala также хранят состояния.


Миксины Groovy

В Groovy миксины реализуются посредством метода metaClass.mixin() или аннотации @Mixin (в свою очередь, аннотация @Mixin использует AST-преобразования Groovy (AST — Abstract Syntax Tree) для необходимого метапрограммирования). В листинге 1 метод metaClass.mixin() предоставляет классу File возможности создания сжатых ZIP-файлов.

Листинг 1. Подмешивание метода zip() в класс File
class Zipper {

  def zip(dest) {
      new ZipOutputStream(new FileOutputStream(dest))
          .withStream { ZipOutputStream zos ->
            eachFileRecurse { f ->
              if (!f.isDirectory()) {
                zos.putNextEntry(new ZipEntry(f.getPath()))
                new FileInputStream(f).withStream { s ->
                    zos << s
                    zos.closeEntry()
                }
              }
            }
          }
  }

  static {
    File.metaClass.mixin(Zipper)
  }

}

В листинге 1 я создаю класс Zipper, который содержит новый метод zip() и связывание для добавления этого метода к существующему классу File. Groovy-код (ничем не примечательный) метода zip() рекурсивно создает ZIP-файл. Последняя часть листинга связывает новый метод с существующим классом File с помощью статического инициализатора. Как в языке Java, статический инициализатор класса исполняется при загрузке класса. Статический инициализатор – это идеальное место для расширяющего кода, поскольку инициализатор гарантированно исполняется перед любым кодом, который зависит от этого расширения. В листинге 1 метод mixin() добавляет метод zip() к классу File.

В статье Java.next: Расширение без наследования, Часть 1 я рассмотрел два механизма Groovy — класс ExpandoMetaClass и классы категорий — которые можно использовать для добавления, изменения и удаления методов в существующих классах. Использование метода mixin() для добавления методов обеспечивает такой же конечный результат, как добавление методов с помощью ExpandoMetaClass или с помощью классов категорий, однако реализация имеет отличия. Рассмотрим пример миксина в листинге 2.

Миксины, манипулирующие иерархией наследования
import groovy.transform.ToString

class DebugInfo {
  def getWhoAmI() {
    println "${this.class} <- ${super.class.name} 
    <<-- ${this.getClass().getSuperclass().name}"
  }
}

@ToString class Person {
  def name, age
}

@ToString class Employee extends Person {
  def id, role
}

@ToString class Manager extends Employee {
  def suiteNo
}


Person.mixin(DebugInfo)

def p = new Person(name: "Pete", age: 33)
def e = new Employee(name: "Fred", age: 25, id:"FRE", role:"Manager")
def m = new Manager(name: "Burns", id: "001", suiteNo: "1A")

p.whoAmI
e.whoAmI
m.whoAmI

В листинге 2 я создаю класс с именем DebugInfo, который содержит единственное определение свойства getWhoAmI. Внутри этого свойства я вывожу на печать некоторые детали этого класса, такие как текущий класс и перспективы происхождения свойств super и getClass().getSuperClass(). Затем я создаю простую иерархию классов, которая состоит из Person, Employee и Manager.

После этого я подмешиваю класс DebugInfo в класс Person, который находится наверху иерархии. Поскольку свойство whoAmI существует для класса Person, оно существует и для его дочерних классов.

В выводе программы вы можете увидеть (и это может вас удивить), что класс DebugInfo внедряет себя в иерархию наследования:

class Person <- DebugInfo <<-- java.lang.Object
class Employee <- DebugInfo <<-- Person
class Manager <- DebugInfo <<-- Employee

Методы миксина должны соответствовать и без того сложным отношениям Groovy для разрешения методов. Разные возвращаемые значения для родительских классов в листинге 2 отражают эти отношения. Подробное рассмотрение разрешения методов выходит за рамки данной статьи. Однако избегайте применения значений this и super (в различных формах) внутри методов миксинов.

Использование классов категорий или класса ExpandoMetaClass не влияет на наследование, поскольку при этом вы производите изменения в классе, а не подмешиваете новое отличающееся поведение. Отрицательная сторона этого подхода заключается в том, что такое изменение невозможно выявить в качестве безусловно отличающегося артефакта. Если с помощью классов категорий или класса ExpandoMetaClass я добавлю три одинаковых метода к нескольким классам, то никакой конкретный артефакт кода (такой как интерфейс или сигнатура класса) не выявит общность, которая возникнет в результате этого. Преимущество миксинов состоит в том, что со всем, что использует миксины, Groovy обращается как с категориями.

Один из раздражающих моментов при реализации с использованием классов категорий — строгая структура класса. Чтобы представить тип, подвергаемый расширению, вы должны использовать полностью статические методы, каждый из которых принимает не менее одного параметра. Метапрограммирование является наиболее полезным, когда оно устраняет подобный стереотипный код. Появление аннотации @Mixin существенно облегчило создание категорий и подмешивание их в классы. В листинге 3 (фрагмент из документации Groovy) демонстрируется синергизм между категориями и миксинами.

Сочетание категорий и миксинов
interface Vehicle {
    String getName()
}

@Category(Vehicle) class Flying {
    def fly() { "I'm the ${name} and I fly!" }
}

@Category(Vehicle) class Diving {
    def dive() { "I'm the ${name} and I dive!" }
}

@Mixin([Diving, Flying])
class JamesBondVehicle implements Vehicle {
    String getName() { "James Bond's vehicle" }
}

assert new JamesBondVehicle().fly() ==
       "I'm the James Bond's vehicle and I fly!"
assert new JamesBondVehicle().dive() ==
       "I'm the James Bond's vehicle and I dive!"

В листинге 3 я создаю простой интерфейс Vehicle и два класса категорий (Flying и Diving). Аннотация @Category учитывает стереотипные требования кода. Определив категории, я подмешиваю их в JamesBondVehicle и таким образом присоединяю оба поведения.

Взаимопересечение категорий, класса ExpandoMetaClass и миксинов в Groovy является неизбежным следствием активного развития языка. Эти три технологии в значительной степени перекрываются, однако для каждой из них существуют области, с которыми наилучшим образом способна справиться именно она. Если бы язык Groovy был перепроектирован с нуля, его авторы скорее всего консолидировали бы значительную часть функциональности этих трех методов в рамках одного механизма.


Трейты языка Scala

Scala реализует повторное использование кода посредством т.н. трейтов (трейт — это базовый языковой конструкт, подобный миксину). Трейты в языке Scala сохраняют состояние (т.е. обладают свойством stateful) — они могут включать и методы, и поля — и играют такую же роль для instanceof, какую играют интерфейсы в языке Java. Трейты и миксины решают множество аналогичных проблем, однако поддержка трейтов реализуется более строгими нормами языка.

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

Сравниваемые комплексные числа
final class Complex(val real: Int, val imaginary: Int) extends Ordered[Complex] {
  require (real != 0 || imaginary != 0)

  def +(operand: Complex) =
      new Complex(real + operand.real, imaginary + operand.imaginary)

  def +(operand: Int) =
    new Complex(real + operand, imaginary)

  def -(operand: Complex) =
    new Complex(real - operand.real, imaginary - operand.imaginary)

  def -(operand: Int) =
    new Complex(real - operand, imaginary)


  def *(operand: Complex) =
      new Complex(real * operand.real - imaginary * operand.imaginary,
          real * operand.imaginary + imaginary * operand.real)

  override def toString() =
      real + (if (imaginary < 0) "" else "+") + imaginary + "i"

  override def equals(that: Any) = that match {
    case other : Complex => (real == other.real) && (imaginary == other.imaginary)
    case _ => false
  }

  override def hashCode(): Int =
    41 * ((41 + real) + imaginary)

  def compare(that: Complex) : Int = {
    def myMagnitude = Math.sqrt(this.real ^ 2 + this.imaginary ^ 2)
    def thatMagnitude = Math.sqrt(that.real ^ 2 + that.imaginary ^ 2)
    (myMagnitude - thatMagnitude).round.toInt  }
}

В листинге 4 я не реализую операторы >, <, <= и >=, однако я могу вызывать их для экземпляров комплексных чисел (см. листинг 5).

Тестирование операторов сравнения
class ComplexTest extends FunSuite {

  test("comparison") {
    assert(new Complex(1, 2) >= new Complex(3, 4))
    assert(new Complex(1, 1) < new Complex(2,2))
    assert(new Complex(-10, -10) > new Complex(1, 1))
    assert(new Complex(1, 2) >= new Complex(1, 2))
    assert(new Complex(1, 2) <= new Complex(1, 2))
  }

}

Для сравнения комплексных чисел не существует единой математически определенной методики, поэтому в листинге 4 я использую общепринятый алгоритм для сравнения чисел по модулю. Я расширил определение класса с помощью трейта Ordered[Complex], который подмешивается к булевым операторам для параметризованного класса. Для того чтобы трейты функционировали, введенные операторы должны сравнить два комплексных числа, что и является целью метода compare(). Если вы попытаетесь применить extend к трейту Ordered, но не предоставите требуемых методов, сообщение компилятора уведомит вас о том, что ваш класс должен быть декларирован как abstract, поскольку необходимые методы отсутствуют.

Трейты в Scala имеют две четко определенные роли: обогащение интерфейсов и выполнение многоуровневых модификаций (stackable modification).

Обогащение интерфейсов

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

Трейты успешно решают эту дилемму "насыщенный-тонкий". Вы можете реализовать базовую функциональность в тонком интерфейсе, а затем с помощью трейтов дополнить этот интерфейс с целью наращивания функциональности. Например, в Scala трейт Set реализует функциональность совместно используемого набора, а выбираемый разработчиком субтрейт ( mutable или immutable) определяет возможность/невозможность внесения изменений в этот набор.

Многоуровневые модификации

Другое распространенное использование трейтов в Scala —многоуровневые модификации. Трейты позволяют изменять существующие методы и добавлять новые методы, а super предоставляет доступ назад по цепочке к реализации предшествующих трейтов.

В листинге 6 показаны многоуровневые модификации для очереди чисел.

Построение многоуровневых модификаций
abstract class IntQueue {
  def get(): Int
  def put(x: Int)
}

import scala.collection.mutable.ArrayBuffer

class BasicIntQueue extends IntQueue {
  private val buf = new ArrayBuffer[Int]
  def get() = buf.remove(0)
  def put(x: Int) { buf += x }
}

trait Squaring extends IntQueue {
  abstract override def put(x: Int) { super.put(x * x) }
}

В листинге 6 я создаю простой класс IntQueue. Затем я создаю допускающую изменения версию, которая содержит ArrayBuffer. Трейт Squaring расширяет очередь IntQueue и автоматически возводит в квадрат значения, вставляемые в эту очередь. Вызов super в трейте Squaring предоставляет доступ к предыдущим трейтам в стеке. По мере того как каждый переопределенный метод, кроме первого, осуществляет вызовов super, модификации размещаются одна поверх другой (см. листинг 7).

Создание многоуровневой конфигурации экземпляров
object Test {
  def main(args: Array[String]) {
    val queue = (new BasicIntQueue with Squaring)
    queue.put(10)
    queue.put(20)
    println(queue.get()) // 100
    println(queue.get()) // 400
  }
}

Использование super в листинге 6 иллюстрирует важное различие между трейтами и миксинами. Миксинам — поскольку они подмешиваются после создания исходного класса — необходимо устранять потенциальную неопределенность своего текущего местоположения внутри иерархии классов. Трейты выстраиваются в линейную иерархию при создании класса; компилятор отвечает на вопрос о том, что представляет собой super, без какой-либо неоднозначности. За линеаризацию иерархии в Scala отвечают сложные, строго определенные правила (рассмотрение которых выходит за рамки этой статьи). Кроме того, трейты решают проблему ромбического наследования для языка Scala. Когда Scala отслеживает происхождение метода и осуществляет разрешение метода, неоднозначность исключена, поскольку в этом языке определены явные правила разрешения.


Заключение

В этой статье я исследовал сходства и различия между миксинами (в языке Groovy) и трейтами (в языке Scala). Миксины and трейты предоставляют множество схожих возможностей, однако детали их реализации имеют важные различия, обуславливаемые концептуальными различиями этих языков. В Groovy миксины существуют как аннотации и используют мощные возможности метапрограммирования на основе AST-преобразований. Миксины, классы категорий и класс ExpandoMetaClass перекрываются по своей функциональности, с небольшими (но важными) различиями. Трейты Scala, такие как Ordered, являются базовым средством этого языка, на котором основана значительная часть встроенной функциональности Scala.

В следующей статье я рассмотрю такие вопросы, как карринг (currying) и частичное применение (partial application) в языках Java.next.

Ресурсы

Научиться

  • Оригинал статьи: Java.next: Mixins and traits.
  • Groovy: динамическая разновидность Java с обновленным синтаксисом и расширенными возможностями.
  • Scala: современный функциональный язык, работающий на платформе JVM.
  • Clojure: современный функциональный вариант Lisp, работающий на платформе JVM.
  • Multiple inheritance (Множественное наследование): из этой статьи в Википедии вы узнаете, как возникает проблема ромбического наследования в языках с множественным наследованием.
  • Mixin: статья в Википедии, содержащая дополнительную информацию о миксинах (в том числе о происхождении этого термина) и о языке Flavors.
  • Функциональное мышление: колонка Нила Форда на developerWorks, посвященная функциональному программированию.
  • Записная книжка дизайнера языка: в этом цикле статей developerWorks архитектор языка Java Брайан Гетц исследует некоторые проблемы дизайна языка, вызвавшие трудности при эволюции языка Java версий Java SE 7, Java SE 8 и далее.
  • Раздел developerWorks, посвященный Java-технологии: сотни статей по всем аспектам программирования на Java.

Обсудить

  • Присоединяйтесь к сообществу 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
ArticleID=957532
ArticleTitle=Java.next: Миксины и трейты
publish-date=12162013