Функциональное мышление: Часть 3. Шаблоны проектирования для функционального программирования

Шаблон Интерпретатор и расширение языка

Шаблон Интерпретатор (Interpreter) из каталога книги Design Patterns позволяет расширять язык за счет создания новых языков на основе уже имеющегося. Большинство функциональных языков программирования позволяют расширять себя различными способами, например, с помощью перегрузки операторов и сопоставления шаблонов. Хотя в Java™ поддержка данных приемов отсутствует, языки следующего поколения, основанные на JVM, будут обладать подобными возможностями, которые будут отличаться особенностями реализации. В этой статье Нил Форд исследует, как Groovy, Scala и Clojure обеспечивают реализацию идеи, лежащей в основе шаблона проектирования Интерпретатор, за счет функциональных расширений, которые в принципе отсутствуют в Java.

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

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



28.09.2012

Об этой серии статей

Эта серия статей призвана переориентировать читателя в "функциональном" направлении и помочь ему взглянуть на проблемы под новым углом, чтобы улучшить повседневное написание кода. В ней изучаются принципы функционального программирования и инфраструктуры, позволяющие использовать функциональное программирование в языке Java™. Так, в статьях рассматриваются функциональные языки программирования, работающие на виртуальной Java-машине, и некоторые направления будущего развития языков программирования. Эта серия статей предназначена для программистов, знающих язык Java и работу его абстракций, но не имеющих опыта в использовании функциональных языков.

В этой статье из серии "Функциональное мышление" я продолжаю свое исследование альтернативных функциональных решений для шаблонов проектирования из каталога книги Design Patterns (см. раздел "Ресурсы"). В этой статье я обращусь к наиболее сложному, но и наиболее функциональному шаблону: Interpreter (интерпретатор).

Определение шаблона Interpreter

Для любого языка можно определить представление для его грамматики и интерпретатор, который будет использовать это представление для интерпретации предложений, написанных на этом языке.

Другими словами, если используемый язык не подходит для решения проблемы, можно создать на нем язык, который сможет её решить. Хорошим примером подобного подхода служат такие Web-инфраструктуры, как Grails и Ruby on Rails (см. раздел "Ресурсы"), которые расширяют исходные языки (Groovy и Ruby соответственно), чтобы облегчить создание Web-приложений.

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

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

В Java нет "прямых" механизмов для расширения языка, если не прибегать к аспектно-ориентированному программированию. Однако языки следующего поколения (например, Groovy, Scala и Clojure), основанные на JVM, можно расширять различными способами. Тем самым они реализуют намерение, заложенное в шаблон проектирования Интерпретатор. Сначала я покажу, как реализовать перегрузку операторов во всех трёх языках, а затем продемонстрирую, как Groovy и Scala позволяют расширять существующие классы.

Перегрузка операторов

Стандартной возможностью функциональных языков является перегрузка операторов — возможность переопределить операторы (такие как +, - или *) для работы с новыми типами и демонстрации нового поведения. Отказ от перегрузки операторов в период формирования Java был сознательным решением, но сегодня такой возможностью обладают практически все современные языки, включая естественных преемников Java, работающих поверх JVM.

Groovy

Цель языка Groovy - обновить синтаксис Java до современного состояния, сохранив при этом его естественную семантику. Поэтому Groovy допускает перегрузку операторов за счет автоматической привязки операторов к названиям методов. Например, если вы хотите перегрузить оператор + для типа Integer, вам потребуется переопределить метод plus() в классе Integer. В таблице 1 представлен фрагмент списка операторов, которые можно перегружать; полный список можно найти по ссылке, приведенной в разделе "Ресурсы".

Таблица 1. Фрагмент списка Groovy-операторов, привязанных к определенным методам
ОператорМетод
x + yx.plus(y)
x * yx.multiply(y)
x / yx.div(y)
x ** yx.power(y)

В качестве примера перегрузки операторов я создам класс ComplexNumber с помощью Groovy и Scala. Комплексные числа в математике имеют вещественную и мнимую части и обычно записываются в формате 3 + 4i. Комплексные числа встречаются во многих областях науки, включая физику, электромеханику и теорию хаоса. Разработчики, создающие приложения для данных областей, получат большое преимущество, если у них будет возможность создавать операторы, отражающие проблемы предметной области. Дополнительную информацию о комплексных числах можно найти в разделе "Ресурсы".

В листинге 1 приведена Groovy-версия класса ComplexNumber.

Листинг 1. Groovy-версия класса ComplexNumber
package complexnums

class ComplexNumber {
   def real, imaginary

  public ComplexNumber(real, imaginary) {
    this.real = real
    this.imaginary = imaginary
  }

  def plus(rhs) {
    new ComplexNumber(this.real + rhs.real, this.imaginary + rhs.imaginary)
  }
  
  def multiply(rhs) {
    new ComplexNumber(
        real * rhs.real - imaginary * rhs.imaginary,
        real * rhs.imaginary + imaginary * rhs.real)
  }

  String toString() {
    real.toString() + ((imaginary < 0 ? "" : "+") + imaginary + "i").toString()
  }
}

В листинге 1 я создал класс для хранения вещественной и мнимой частей, а также перегрузил операторы + и * c помощью методов plus() и multiply(). Сложить два комплексных числа довольно просто: оператор plus() сначала складывает между собой вещественные части обоих чисел, затем мнимые и возвращает полученный результат. Для перемножения двух комплексных чисел используется формула:

(x + yi)(u + vi) = (xu - yv) + (xv + yu)i

Эта же формула воспроизводится и в операторе multiply() в листинге 1. Она перемножает вещественные части обоих чисел, затем вычитает из них произведение мнимых частей, что дает вещественную часть произведения. Мнимая часть произведения получается путем сложения произведения вещественной части первого числа и мнимой части второго числа с произведением мнимой части первого числа и вещественной части второго.

В листинге 2 приведены тесты для проверки операторов сложения / умножения комплексных чисел.

Листинг 2. Тестирование операторов для работы с комплексными числами
package complexnums

import org.junit.Test
import static org.junit.Assert.assertTrue
import org.junit.Before

class ComplexNumberTest {
  def x, y

  @Before void setup() {
    x = new ComplexNumber(3, 2)
    y = new ComplexNumber(1, 4)
  }

  @Test void plus_test() {
    def z = x + y;
    assertTrue 3 + 1 == z.real
    assertTrue 2 + 4 == z.imaginary
  }
  
  @Test void multiply_test() {
    def z = x * y
    assertTrue(-5  == z.real)
    assertTrue 14 == z.imaginary
  }
}

В листинге 2 в методах plus_test() и multiply_test() используются перегруженные операторы, в обоих случаях представленные теми же символами, которыми пользуются и эксперты в данной области, поэтому такой подход ничем не отличается от аналогичного использования встроенных типов.

Scala (и Clojure)

Язык Scala позволяет перегружать операторы, отказываясь от различий между методами и операторами, так операторы - это просто методы со специальными названиями. Таким образом, для переопределения оператора умножения в Scala необходимо переопределить метод *. В листинге 3 приведен пример реализации комплексных чисел на Scala.

Листинг 3. Комплексные числа в Scala
class ComplexNumber(val real:Int, val imaginary:Int) {
    def +(operand:ComplexNumber):ComplexNumber = {
        new ComplexNumber(real + operand.real, imaginary + operand.imaginary)
    }
 
    def *(operand:ComplexNumber):ComplexNumber = {
        new ComplexNumber(real * operand.real - imaginary * operand.imaginary,
            real * operand.imaginary + imaginary * operand.real)
    }

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

Класс в листинге 3 содержит знакомые поля real и imaginary, а также методы/операторы + и *. Как можно увидеть в листинге 4, я могу без проблем пользоваться классом ComplexNumber.

Листинг 4. Использование комплексных чисел в Scala
val c1 = new ComplexNumber(3, 2)
val c2 = new ComplexNumber(1, 4)
val c3 = c1 + c2
assert(c3.real == 4)
assert(c3.imaginary == 6)

val res = c1 + c2 * c3
 
printf("(%s) + (%s) * (%s) = %s\n", c1, c2, c3, res)
assert(res.real == -17)
assert(res.imaginary == 24)

Объединяя операторы и методы, Scala значительно упрощает перегрузку операторов. Аналогичный механизм перегрузки операторов используется в Clojure. Ниже приведен пример Clojure-кода для определения перегруженного оператора **.

(defn ** [x y] (Math/pow x y))

Расширение классов

Аналогично перегрузке операторов, следующее поколение языков на основе JVM позволит расширять классы (включая базовые классы Java) способами, которые отсутствуют в самом языке Java. Эти возможности часто используются для создания доменно-ориентированных языков (DSL). Хотя доменно-ориентированные языки и не рассматривались в каталоге книги Design Patterns, так как в тот момент они еще не были настолько популярны, именно DSL являются воплощением оригинальной идеи, лежащей в основе шаблона Interpreter.

Добавляя компоненты или другие изменения к базовым классам Java, например, Integer, вы можете, так же как и при добавлении операторов, более точно моделировать проблемы реального мира. И Groovy и Scala поддерживают такую возможность, но с помощью различных механизмов.

ExpandoMetaClass и классы категорий в Groovy

В Groovy присутствуют два механизма для добавления методов к существующим классам: ExpandoMetaClass и категории. Я уже рассматривал особенности использования ExpandoMetaClass в предыдущей статье в контексте шаблона Adapter.

Давайте предположим, что вашей компании из-за некоей странной традиции требуется выражать скорость не в обычных единицах – в милях в час, а в фурлонгах (200 м) за две недели (336 часов), и разработчикам часто приходится производить это преобразование. С помощью Groovy-класса ExpandoMetaClass вы можете добавить в класс Integer свойство FF, которое будет выполнять необходимое преобразование, как показано в листинге 5.

Листинг 5. Использование ExpandoMetaClass для добавления к классу Integer блока кода, выполняющего необходимое преобразование.
static {
  Integer.metaClass.getFF { ->
    delegate * 2688
  }
}

@Test void test_conversion_with_expando() {
  assertTrue 1.FF == 2688
}

Альтернативой классу ExpandoMetaClass может стать создание категории (класса-оболочки) – понятия, позаимствованного из языка Objective-C. В листинге 6 я добавляю свойство ff к классу Integer.

Листинг 6. Добавление блока кода с помощью класса-категории
class FFCategory {
  static Integer getFf(Integer self) {
    self * 2688
  }
}

@Test void test_conversion_with_category() {
  use(FFCategory) {
    assertTrue 1.ff == 2688
  }
}

Класс-категория – это обычный класс с набором статических методов. Каждый метод принимает как минимум один параметр; первый параметр – это тип, который и будет дополняться данным методом. Например, в листинге 6 в классе FFCategory есть метод getF(), который принимает на вход параметр типа Integer. Когда этот класс-категория используется с ключевым словом use, все соответствующие типы внутри блока кода получают дополнительную функциональность. В данном unit-тесте я могу сослаться в блоке кода на свойство ff (напомню, что Groovy автоматически преобразует get()-методы в ссылки на свойства), как показано в конце листинга 6.

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

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

Проект easyb (см. раздел "Ресурсы") позволяет писать тесты, проверяющие различные аспекты тестируемых классов. Рассмотрим фрагмент easyb-теста, приведенный в листинге 7.

Листинг 7. Тестирование класса-очереди с помощью easyb
//элементы должны выводиться из очереди в порядке добавления
it "should dequeue items in same order enqueued", {
    [1..5].each {val ->
        queue.enqueue(val)
    }
    [1..5].each {val ->
        queue.dequeue().shouldBe(val)
    }
}

Класс-очередь не содержит метода shouldBe(), который я вызываю на этапе выполнения тестирования. Инфраструктура easyb добавила этот метод для меня. В листинге 8 приведен метод it(), определенный в исходном коде easyb, в котором показано, как выполняется данное изменение.

Листинг 8. Определение метода it() инфраструктуры easyb
def it(spec, closure) {
  stepStack.startStep(BehaviorStepType.IT, spec)
  closure.delegate = new EnsuringDelegate()
  try {
    if (beforeIt != null) {
      beforeIt()
    }
    listener.gotResult(new Result(Result.SUCCEEDED))
    use(categories) {
      closure()
    }
    if (afterIt != null) {
      afterIt()
    }
  } catch (Throwable ex) {
    listener.gotResult(new Result(ex))
  } finally {
    stepStack.stopStep()
  }
}

class BehaviorCategory {
  // ...

  static void shouldBe(Object self, value) {
    shouldBe(self, value, null)
  }

  //...
}

В листинге 8 метод it() принимает на вход параметр spec (строку, описывающую тест) и блок с замыканием, представляющим тело теста. В середине метода замыкание выполняется внутри блока BehaviorCategory, который приведен в конце листинга. Класс BehaviorCategory дополняет класс Object, позволяя любому объекту из вселенной Java проверить своё значение.

Допуская частичное изменение класса Object, расположенного на самом верху иерархии классов, механизм открытия классов, имеющийся в Groovy, позволяет любому объекту легко проверить результат тестирования, но ограничивает область применения подобного изменения телом блока, в котором оно используется.

Неявные преобразования в Scala

Язык Scala использует неявные преобразования (implicit casts), чтобы "симулировать" изменение существующих классов. Неявные преобразования не добавляют в классы новых методов, но позволяют языку автоматически приводить объекты к соответствующему типу, в котором имеется необходимый метод. Например, я не могу добавить метод isBlank() к классу String, но я могу создать неявное преобразование, которое преобразует объекты типа String к классу, в котором имеется подобный метод.

В качестве примера я добавлю метод append() к классу Array, что позволит мне легко добавлять новые объекты типа Person в массив соответствующего типа, как показано в листинге 9.

Листинг 9. Добавление метода к классу Array
case class Person (firstName: String, lastName: String) {}

class PersonWrapper(a: Array[Person]) {
  def append(other: Person) = {
    a ++ Array(other)
  }
  def +(other: Person) = {
    a ++ Array(other)
  }
}
    
implicit def listWrapper(a: Array[Person]) = new PersonWrapper(a)

В листинге 9 я создал простой класс Person с парой атрибутов. Чтобы получить типизированный массив Array[Person] (в языке Scala для создания параметризованных типов (generics) используются "[]", а не "< >" как в Java), я создал класс PersonWrapper, в котором имеется требуемый метод append(). В конце листинга я создал неявное преобразование, которое будет автоматически преобразовывать объект типа Array[Person] к типу PersonWrapper каждый раз, когда я буду вызывать у массива метод append(). В листинге 10 выполняется проверка преобразования.

Листинг 10. Тестирование "естественного" расширения существующих классов
val p1 = new Person("John", "Doe")
var people = Array[Person]()
people = people.append(p1)

В листинге 9 я также добавил к классу PersonWrapper метод +. В листинге 11 продемонстрировано использование этого интуитивно понятного оператора.

Листинг 11. Изменение языка для повышения удобства восприятия
people = people + new Person("Fred", "Smith")
for (p <- people)
  printf("%s, %s\n", p.lastName, p.firstName)

Scala на самом деле не добавляет метод в исходный класс, но создает именно такое впечатление, автоматически преобразуя класс к нужному типу. Аккуратность, необходимая при использовании метапрограммирования в языках, подобных в Groovy, требуется и при работе в Scala, чтобы при использовании неявных преобразований избежать создания запутанной сети взаимозависимых классов. Но при грамотном использовании неявные преобразования помогают создавать очень функциональный код.


Заключение

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

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

Ресурсы

  • Functional thinking: Functional design patterns, Part 2: оригинал статьи (EN).
  • The Productive Programmer (Neal Ford, O'Reilly Media, 2008): новая книга Нила Форда, в которой более подробно рассматриваются некоторые вопросы из данной серии статей.
  • Design Patterns: Elements of Reusable Object-Oriented Software (Erich Gamma et al., Addison-Wesley, 1994): классическая книга четырех авторов ("банды четырех") по шаблонам проектирования.
  • Design Patterns in Dynamic Languages: презентация Питера Норвига (Peter Norvig) объясняет, почему языки программирования с богатыми возможностями (например, функциональные) не так сильно нуждаются в шаблонах проектирования.
  • Groovy: этот язык, реализующий несколько парадигм программирования и работающий поверх JVM, обладает синтаксисом, близким к Java, и многими дополнительными возможностями из арсенала функционального программирования.
  • Комплексные числа: математическая абстракция, играющая важную роль во многих научных областях.
  • Scala: современный функциональный язык на основе JVM.
  • Clojure: современная функциональная версия Lisp, работающая поверх JVM.
  • Operator overloading in Groovy: в этой статье приведен полный список операторов, поддерживаемых в Groovy, и методов к которым они привязаны.
  • Practically Groovy: Metaprogramming with closures, ExpandoMetaClass, and categories (Scott Davis, developerWorks, июнь 2009 г.): статья с дополнительной информацией о том, как в Groovy можно динамически добавлять новые методы к классам прямо во время исполнения.
  • easyb: open-source инструмент для разработки, основанной на поведении (behavior-driven development), разработанный на Groovy и предназначенный для использования в Groovy и Java-проектах.
  • Drive development with easyb (Andrew Glover, developerWorks, ноябрь 2008 г.): статья о том, как инфраструктура easyb помогает наладить контакт между разработчиками и конечными пользователями.
  • Grails: open-source Web-инфраструктура, написанная на Java и Groovy.
  • Ruby on Rails: open-source Web-инфраструктура, написанная на Ruby и работающая на JRuby.

Комментарии

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=838126
ArticleTitle=Функциональное мышление: Часть 3. Шаблоны проектирования для функционального программирования
publish-date=09282012