Содержание


Java.next

Миксины и трейты

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

Comments

Серия контента:

Этот контент является частью # из серии # статей: Java.next

Следите за выходом новых статей этой серии.

Этот контент является частью серии:Java.next

Следите за выходом новых статей этой серии.

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

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

Миксины

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

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

Комментарии

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=957532
ArticleTitle=Java.next: Миксины и трейты
publish-date=12162013