Нил Форд, Архитектор ПО, ThoughWorks Inc.
15 Мая 2012
перевод статьи Functional thinking: Functional design patterns, Part 3
Серия «Функциональное мышление» (часть 1, часть 2) продолжает исследование альтернативных, функциональных решений шаблонам проектирования Банды Четрых (англ. Gang of Four, GoF). В этой части я буду исследовать наименее понимаемый, но наиболее мощный из шаблонов проектирования: «Интерпретатор» (англ. Interpreter).
Определение интерпретатора следующее:
C учетом языка, определяет представление его грамматики вместе с переводчиком, который использует представление для перевода предложений в язык.
Given a language, define a representation for its grammar along with an interpreter that uses the representation to interpret sentences in the language.
Другими словами, если язык, который вы используете не подходит для решения проблемы, используйте его для того чтобы создать язык, который будет решать эту проблему. Хорошие примеры данного подхода встречаются в веб фреймворках типа Grails или Ruby on Rails, которые расширяют свой базовый язык (Groovy и Ruby соответственно) для упрощения написания веб приложений.
Этот шаблон проектирования наименее понятен, потому что не часто приходится создавать новый язык, поэтому необходимые навыки и идиомы специфичны.Это наиболее мощный шаблон проектирования, потому что он подстегивает вас к расширению вашого языка программирования в направлении проблемы, которую вы решаете. Это характерный подход в мире Lisp (в том числе Clojure), но менее частый в мейнстрим языках.
Когда используются языки (например Java), которые не дают возможностей расширения языка, разработчики стремятся формировать свои мысли в рамках синтаксиса языка — это единственный выход. Тем не менее, когда вы привыкаете к языкам, позволяющие безболезненное расширение, вы начинаете двигаться в направлении решения проблемы, а не поиска обходного пути.
Java не хватает четкого механизма расширения языка, конечно если вы не прибегаете к аспектно-ориентированному программированию. Тем не менее, следующее поколение JVM языков (Groovy, Scala и Clojure) предоставляют расширение языка различными способами. Осуществляя это, они сталкиваются с задачами шаблона проектирования «Интерпретатор». Первым делом, я покажу как реализовать перегрузку операторов во всех трех языках, затем покажу как Groovy и Scala позволяют расширять существующие классы.
Перегрузка операторов
Общая возможность функциональный языков программирования это перегрузка операторов — возможность переопределять операторы (такие как +, -, или *) для работы с новыми типами и проявлять новое поведение. Отсутствие перегрузки операторов было сознательное решение в период формирования Java, но виртуально каждый современный язык использует эту способность, включая естественных потомком Java на JVM.
Groovy
Groovy пытается обновить синтаксис Java в соответствие с текущим столетием, в тоже время сохраняя естественную семантику. Таким образом, Groovy предоставляет перегрузку операторов с помощью автоматического «мэппинга» операторов в соответствие именам метода. Например, если вы хотите выполнить перегрузку Integer оператора +, вам необходимо переопределить метод plus() класса Integer. Весь список мэппингов доступен онлайн; Таблица 1 показывает часть списка.
Таблица 1. Частичный список Groovy мэппингов оператор/метод
Оператор | Метод |
---|---|
x + y | x.plus(y) |
x * y | x.multiply(y) |
x / y | x.div(y) |
x ** y | x.power(y) |
Как пример оператора перегрузки, я создам класс ComplexNumber в Groovy и Scala. Комплексные числа это математический концепт с реальной и мнимой составляющей, обычно записывается как 4+4i. Комплексные числа распространены в большом количестве научных областей, включая инженерные дисциплины, физику, электромагнетизм и даже теорию хаоса. Разработчики, пишущие приложения для этих областей серьезно выигрывают от возможности создания операторов, которые отображают прикладную проблему.
Класс ComplexNumber в Groovy представлен в Листинге 1:
Листинг 1. ComplexNumber в Groovy
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, я создал класс, который содержит и реальную и мнимую часть, также я создал перегруженные операторы plus() и multiply(). Сложение двух комплексных чисел просто: оператор plus() складывает соответственно мнимую и реальную части каждого числа и возвращает результат. Для произведения комплексных чисел необходима следующая формула:
(x + yi)(u + vi) = (xu - yv) + (xv + yu)i
Оператор multiply() в Листинге 1 повторяет эту формулу.
Листинг 2. Тестирование операторов комплексных чисел
package complexnums
import org.junit.Test
import static org.junit.Assert.assertTrue
import org.junit.Before
class ComplexNumberTest {
def x, y
<hh user=Before> void setup() {
x = new ComplexNumber(3, 2)
y = new ComplexNumber(1, 4)
}
<hh user=Test> void plus_test() {
def z = x + y;
assertTrue 3 + 1 == z.real
assertTrue 2 + 4 == z.imaginary
}
<hh user=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, вы переопределяете * метод. Комплексные числа в Scala представлены в Листинге 3:
Листинг 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) = %sn", 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. Тем не менне, GoF никогда не упоминала DSL, потому что в то время о них мало кто знакл, DSL описывают изначальную задачу шаблона проектирования Interpreter.
Добавляя единицы и модификаторы к основным классам как Integer, вы имеете возможность моделировать реальную проблему ближе к прикладной области. И Groovy, и Scala предоставляют такие возможности, но за счет разных механизмов.
Groovy ExpandoMetaClass и категории классов
Groovy имеет 2 механизма добавления методов в существующие классы: ExpandoMetaClass и категории. (Детали использования ExpandoMetaClass были рассмотрены в предыдущей части, в контексте шаблона проектирования Adapter)
Предположим, что вашей компании, по странным логическим причинам, необходимо представлять скорость в виде фурлонгов в фортнайт (прим. перевод.: furlongs per fortnight, еще доимперские единицы измерения, используемые иногда в Великобритании и Австралии, Фурлонг — 182.88 метра и Фортнайт — 14 суток) вместо миль в час, разработчики довольно часто выполняют это преобразование. Используя ExpandoMetaClass, вы можете добавить свойство FF в класс Integer, который будет осуществлять преобразование, как показано в Листинг 5:
Листинг 5. Использование ExpandoMetaClass для добавления единицы фурлонг/фортнайт в Integer
static {
Integer.metaClass.getFF { ->
delegate * 2688
}
}
<hh user=Test> void test_conversion_with_expando() {
assertTrue 1.FF == 2688
}
Альтернативный способ это создать класс-категорию обертку, концепт перенятый из Objective-C. В Листинге 6, я добавил свойство ff (в нижнем регистре) классу Integer
Листинг 6. Добавление единиц через класс-категорию
class FFCategory {
static Integer getFf(Integer self) {
self * 2688
}
}
<hh user=Test> void test_conversion_with_category() {
use(FFCategory) {
assertTrue 1.ff == 2688
}
}
Класс-категория это обычный класс с коллекцией статических методов. Каждый метод принимает минимум один параметр; первый параметр это тип аргументов метода. Например, в Листинге 6, класс FFCategory имеет метод getFf(), который принимает параметр типа Integer. Когда этот класс-категория используется с ключевым словом use, все подходящие типы внутри блока кода расширены.В юнит тесте, я могу ссылаться на свойство ff (напоминаю, Groovy автоматически конвертирует методы get в правильные ссылки) внутри блока кода, как это показано в Листинге 6.
Наличие двух механизмов позволяет вам более качественно контролировать область раширения. Например, если вся система использует мили/ч как единицу скорости по умолчанию, но в то же время требует частых преобразование в фурлонг/фортнайт, глобальное изменение с использование ExpandoMetaClass будет наиболее подходящим.
Вы можете скептично относится к полезности переоткрытия корневых классов JVM, беспокоясь о широко идущих последствиях. Использование классов-категорий может избавить вас от потенциально обысных улучшений. Вот пример реального проекта с открытым исходным кодом, который использует превосходство этого механизма.
Проект easyb (http://easyb.org/ — oss проект, инструмент разработки через поведение, написанный на Groovy для использования в Groovy и Java) дает возможность написать тесты, которые будут верифицировать аспекты классов внутри теста. Рассмотрим кусочек кода из easyb в Листинге 7:
Листинг 7. easyb тестирование queue класса.
it "should dequeue items in same order enqueued", {
[1..5].each {val ->
queue.enqueue(val)
}
[1..5].each {val ->
queue.dequeue().shouldBe(val)
}
}
Класс queue не содержит метод shouldBe(), который вызывается во время фазы верификации теста. Фреймворк easyb
добавил этот метод для меня; определение метода it() в исходном коде easyb, представлено в Листинге 8:
Листинг 8. Определение метода it()
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() принимает «спек»(строку описывающую тест) и блок замыкание, представляющий тело теста. В середине метода, замыкание выполняется в рамках блока BehaviorCategory, который описан внизу Листинга. Класс BehaviorCategory расширяет Object, предоставляя возможность любому экземпляру во вселенной Java, верифицировать его значение.
Разрешая селективное расширение Object, который стоит на вершине иерархии, механизм открытых классов Groovy (open-class mechanism) дает возможность проверять результат легко для любого экземпляра, но ограничивает это изменения в рамках блока use.
Scala — неявные преобразования (implicit casts)
В 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] осведомленным об Peron (В Scala дженерики используют [] вместо <>, в качестве разделителей), я создаю класс 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, %sn", p.lastName, p.firstName)
Scala не добавляет метод в оригинальный класс, но предоставляет возможность автоматического конвертирования в необходимый тип. Одинаковое усердие необходимо как для метапрограммирования в языках типа Groovy так и в Scala для того чтобы избежать создания запутанных сетей взаимосвязанных классов, используя слишком много неявных преобразований. Однако, верное использует помогает вам лучше выразить код с помощью неявных преобразований.
Заключение
Оригинальный шаблон проектирования Interpreter из GoF рекомендуется использовать для создания новых языков, но их базовые языки не содержат изящного механизма расширения, который есть в наличии сегодня. Все JVM языки следующего поколения поддерживают расширяемость на уровне языка, используя различные техники. В этой части, я показал перегрузку операторов в Groovy, Scala, Clojure и исследовал расширение классов в Groovy и Scala.
В следующей части, я покажу как комбинация сопоставления с образцом в стиле Scala(pattern-matching in Scala-style) вместе с дженериками позволяет избавится от нескольких традиционных паттернов.
Существенное значение для этой дискуссии является концепция, которая также играет важную роль в обработке ошибок в функциональном стиле, которая также является темой следующей части.
От переводчика: от статьи к статье появляются небольшие вопросы, потому что Нил некоторые места не до конца раскрывает или есть небольшие нестыковки. Кое что потом можно встретить в книгах по Groovy. Однако я хочу отправить ему письмо, если у вас есть вопросы, которые вы бы хотели уточнить пишите. Отправлю целенаправленно, возможно в ответ получится целая статья.
Автор: Sigrlami