Функциональное мышление: Переосмысление диспетчеризации

в 0:00, , рубрики: clojure, functional programming, groovy, java, scala, функциональное программирование

Нил Форд, Архитектор ПО, ThoughWorks Inc.
21 августа 2012
перевод статьи Functional thinking: Rethinking dispatch

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

(прим. перевод.: dispatch method mechanism, перевод диспетчеризация методов очень часто используется, однако попадается литература по Java, которая переводит термин как назначение методов, однако я использую первый)

Улучшаем диспетчеризацию методов с помощью Groovy

В Java, условное исполнение ограничивается if выражениями или в некоторых случаях switch. Так как длинную серию if выражений тяжело читать, Java разработчики опираются на Банду Четырех (Gang of Four, GoF) — паттерн Factory или Abstract Factory. Если вы используете язык, который включает более гибкие методы работы с выражениями выбора, вы можете существенно упростить свой код.

В Groovy есть мощное switch выражение, которое имитирует синтаксис, но не поведение switch выражения в Java, как показано в Листинге 1:

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

Листинг 1. Серьезно улучшенное Groovy switch выражение

class LetterGrade {
  def gradeFromScore(score) {
    switch (score) {
      case 90..100 : return "A"
      case 80..<90 : return "B"
      case 70..<80 : return "C"
      case 60..<70 : return "D"
      case 0..<60  : return "F"
      case ~"[ABCDFabcdf]" : return score.toUpperCase()
      default: throw new IllegalArgumentException("Invalid score: ${score}")
    }
  }
}

Switch выражение Groovy может принимать множество динамических типов. В Листинге 1, параметр score должен быть либо числом между 0 и 100 либо буквой оценки. Как в Java, вы должны закрыть каждый case c помощью return или break, используя ту же «проваливающуюся семантику» (fall-through semantics). Но в Groovy, в отличии от Java, я могу указать диапазоны (90..100, не включающие диапазоны 80..<90), регулярные выражения (~"[ABCDFabcdf]"), и условие по-умолчанию.

Динамическая типизация Groovy позволяет мне отправлять различные типы параметров и правильно реагировать, как это показано в юнит тесте в Листинге 2:

Листинг 2. Тестирование буквенных оценок на Groovy

@Test
public void test_letter_grades() {
  def lg = new LetterGrade()
  assertEquals("A", lg.gradeFromScore(92))
  assertEquals("B", lg.gradeFromScore(85))
  assertEquals("D", lg.gradeFromScore(65))
  assertEquals("F", lg.gradeFromScore("f"))
} 

Более мощный switch дает вам полезную золотую середину между серией if выражений и шаблоном проектирования Factory. Switch в Groovy дает вам возможность указывать диапазоны или другие сложные типы, что очень похоже на поведение сопоставления-по-образцу в Scala


Сопоставление-по-образцу в Scala

Сопоставление-по-образцу в Scala дает вам возможность определить совпадающие случаи в соответствии с поведением. Рассмотрим пример, показанный в прошлой части буквенное оценивание, показанный в Листинге 3:

Листинг 3. Буквенные оценки в Scala

val VALID_GRADES = Set("A", "B", "C", "D", "F")

def letterGrade(value: Any) : String = value match {
  case x:Int if (90 to 100).contains(x) => "A"
  case x:Int if (80 to 90).contains(x) => "B"
  case x:Int if (70 to 80).contains(x) => "C"
  case x:Int if (60 to 70).contains(x) => "D"
  case x:Int if (0 to 60).contains(x) => "F"
  case x:String if VALID_GRADES(x.toUpperCase) => x.toUpperCase
}

В Scala, я разрешаю динамический ввод объявляя тип параметра как Any. Оператор в действии match, который пытается сопоставить первое условие и вернуть результат. Как показано в Листинге 3, каждый случай может иметь охранное условие (guard condition), которое определяет условия в целом.
Листинг 4 показывает результаты исполнения некоторых условий выбора буквенной оценки:

Листинг 4. Тестирование буквенных оценок в Scala

printf("Amy scores %d and receives %sn", 91, letterGrade(91))
printf("Bob scores %d and receives %sn", 72, letterGrade(72))
printf("Sam never showed for class, scored %d, and received %sn", 44, letterGrade(44))
printf("Roy transfered and already had %s, which translated as %sn", 
    "B", letterGrade("B"))

Сопоставление-по-образцу в Scala довольно часто используется в сочетании с case классами(case classes) Scala, которые предназначены для представления алгебраических и других типов данных.

«Гибкий» язык Clojure

Другой язык следующего поколения на базе платформы Java это Clojure. Clojure — это реализация языка программирования Lisp на базе JVM — имеет существенно отличный синтаксис в сравнении с современными языками. Несмотря на это разработчики довольно легко приспосабливаются к синтаксису, он воспринимается мейнстрим Java разработчиками как странный. Его главное свойство это homoiconicity, означающее что язык реализован используя свои собственные структуры данных, разрешающие степень расширения недоступную другим языка программирования.

Java и языки похожие на него имеют в наличии ключевые слова (keywords) — строительный материал языка. Разработчики не могут добавлять свои ключевые слова в язык (хотя некоторые Java-подобные языки позволяют реализовать такой функционал через метапрограммирование), ключевые слова имеют семантику недоступную для разработчика. Например, if условие в Java «понимает» вещи типа короткого замыкания булеановских выражений. В Java вы можете создать методы и классы, но не можете создавать фундаментальные строительные блоки, поэтому вы должны перевести проблему в синтаксис языка программирования(между прочим, большое количество разработчиков считает, что их работа выполнять такие переводы). В Lisp-подобных языках типа Clojure, разработчик может изменять язык в направлении проблемы, размывая разницу между тем, что создатель языка и разработчики использующие язык могут создавать. Я исследую все последствия гомоиконности в следующей части; важные параметры, которые необходимо понять — это философия стоящая за Clojure (и Lisp-подобными языками).

В Clojure, разработчики используют язык для того, чтоб создавать читаемый (Lisp) код. Например, Листинг 5 показывает пример с буквенными оценками из прошлых частей в виде Clojure кода:

Листинг 5. Буквенные оценки на Clojure

(defn letter-grade [score]
  (cond
    (in score 90 100) "A"
    (in score 80 90)  "B"
    (in score 70 80)  "C"
    (in score 60 70)  "D"
    (in score 0 60)   "F"
    (re-find #"[ABCDFabcdf]" score) (.toUpperCase score)))

(defn in [score low high]
  (and (number? score) (<= low score high)))

В Листинге 5, я написал метод letter-grade ясно читаемый, потом реализовал метод in для того чтоб заставить его работать. В этом коде, функция cond дает мне возможность оценить последовательностью тестов, переданных через in параметров. Как и в предыдущих примерах, я могу обработать как цифровые так и буквенные значения. В конечном счете, возвращаемое значение должно быть буква в верхнем регистре, так что если входные данные в нижнем регистре я применяю метод toUpperCase для возвращаемого значения. В Clojure, методы являются жителями первого уровня (first-class citizens), а не классы, поэтому методы исполняются «изнутри-наружу»: вызов score.toUpperCase() в Java эквивалентно (. toUpperCase score).

Тесты решения приведены в Листинге 6:

Листинг 6. Тестирование буквенных оценок на Clojure

(ns nealford-test
  (:use clojure.test)
  (:use lettergrades))


(deftest numeric-letter-grades
  (dorun (map #(is (= "A" (letter-grade %))) (range 90 100)))
  (dorun (map #(is (= "B" (letter-grade %))) (range 80 89)))
  (dorun (map #(is (= "C" (letter-grade %))) (range 70 79)))
  (dorun (map #(is (= "D" (letter-grade %))) (range 60 69)))
  (dorun (map #(is (= "F" (letter-grade %))) (range 0 59))))

(deftest string-letter-grades
  (dorun (map #(is (= (.toUpperCase %)
           (letter-grade %))) ["A" "B" "C" "D" "F" "a" "b" "c" "d" "f"])))

(run-all-tests)

В этом случае, код тестов намного сложение самой реализации! Тем не менее, это показывает насколько выразительным может быть код, написанный в Clojure.

В тесте numeric-letter-grades, я хочу проверить каждое значение в допустимых рамках. Если вы незнакомы с Lisp, самый простой способ читать код, это читать его изнутри наружу. Сначала, код #(is (= «A» (letter-grade %))) создает новую анонимную функцию, которая принимает один параметр(если вы используете анонимную функцию, которая имеет всего один параметр, вы можете использовать ее в теле метода с помощью %) и возвращает true если возвращена верная оценка. Функция map накладывают данную функцию на каждый элемент коллекции, который является списком чисел в допустимом диапазоне. Другими словами, map вызывает данную функцию для каждого элемента коллекции во втором параметре и возвращает коллекцию измененных значений (которые я игнорирую). Функция dorun разрешает появление побочных эффектов (side effects), собственно фреймворк тестирования базируется на них. Вызов map для каждого range возвращает список true значений. Это метод из области видимости (namespace) clojure.test проверяет значение как побочный эффект. Вызов функции маппинга внутри dorun позволяет побочным эффектам появляться корректно и возвращать результаты теста.

Мультиметоды в Clojure

Длинную последовательность if выражений довольно тяжело читать и отлаживать, пока Java не имеет каких-либо конкретных хороших альтернатив на уровне языка. Эта проблема обычно решается за счет использования шаблонов проектирования Factory или Abstract Factory из GoF(Gang of Four, Банда четырех). Шаблон Factory работает в Java благодаря полиморфизму-на-основании-классов (class-based polymorphism), позволяющий мне определить главные методы сигнатуры в родительском классе или интерфейсе, затем динамически выбрать реализацию которая будет выполняться.

Фабрики и полиморфизм

В сравнении с Java у Groovy менее многословный (verbose) и более простой в понимании синтаксис, поэтому я буду использовать его вместо Java в нескольких следующих примерах, однако полиморфизм работает одинаково в обоих языках. Рассмотрим комбинацию интерфейсов и классов для того чтобы описать фабрику Product (прим. перевод.: product в данном случае имеется ввиду как общее понятие произведение, и показана реализация произведения чисел, хотя сама идея может быть расширена до различных видов, как скалярное произведение, векторное, прямое произведение множеств и другие, тут примеры максимально простоые), в Листинге 7:

Листинг 7. Создание фабрики произведения в Groovy

interface Product {
  public int evaluate(int op1, int op2)
}

class Multiply implements Product {
  @Override
  int evaluate(int op1, int op2) {
    op1 * op2
  }
}

class Incrementation implements Product {
  @Override
  int evaluate(int op1, int op2) {
    def sum = 0
    op2.times {
      sum += op1
    }
    sum
  }
}

class ProductFactory {
  static Product getProduct(int maxNumber) {
    if (maxNumber > 10000)
      return new Multiply()
    else
      return new Incrementation()
  }
}

В Листинге 7, я создал интерфейс, который определяет семантику того как получить произведение двух чисел и реализацию двух различных версий алгоритма. В ProductFactory, я определяю правила по которым реализации возвращаются из фабрики.

Я использую фабрику как абстрактный заполнитель для конкретных реализаций формирующихся на базе некоторого критерия выбора. Например, рассмотрим код в Листинге 8:

Листинг 8. Динамический выбор реализации

@Test
public void decisionTest() {
  def p = ProductFactory.getProduct(10010)
  assertTrue p.getClass() == Multiply.class
  assertEquals(2*10010, p.evaluate(2, 10010))
  p = ProductFactory.getProduct(9000)
  assertTrue p.getClass() == Incrementation.class
  assertEquals(3*3000, p.evaluate(3, 3000))
}

В Листинге 8, я создал две версии моей реализации Product, проверяя корректность возвращаемых значений из фабрики.

В Java, наследование и полиморфизм тесно связанные концепции: полиморфизм является определителем класса объекта. В других языках эта связь менее тесная.

Полиморфизм по требованию в Clojure

(прим. перевод.: Конкретно в мире Clojure этот вид полиморфизма называется a la carte polymorphism и впервые упоминался в контексте Clojure создателем языка Ричем Хики на конференции StrangeLoop.)

Многие разработчики не работают с Clojure думая, что он не является объектно-ориентированным языком, веря в том что ООП языки являются вершиной технической мысли. Это ошибка: Clojure имеет в наличии все возможности ООП языков, реализованные отдельно от остальных возможностей. Например, Clojure поддерживает полиморфизм, но не ограничивается оценкой класса для выполнения диспетчеризации. Clojure поддерживает полиморфические мультиметоды(multimethods), где диспетчеризация выполняется по какой-то характеристике (или группе характеристик) определяемой разработчиком по желанию.

Вот пример. В Clojure данные обычно хранятся в struct, которые имитируют часть данных класса. Рассмотрим следующий пример в Листинге 9:

Листинг 9. Определение цветовой структуры в Clojure

(defstruct color :red :green :blue)

(defn red [v]
  (struct color v 0 0))

(defn green [v]
  (struct color 0 v 0))

(defn blue [v]
  (struct color 0 0 v))

В Листинге 9, я определяю структуру, которая содержит 3 значения соответствующих цветов. Я также создаю 3 метода, которые возвращают структуру наполненную одним цветом.

Мультиметод в Clojure это определение метода(method defintion), которое принимает функцию диспетчеризации, которая возвращает критерий отбора. Последующие определения позволяют конкретизировать различные версии метода.

Листинг 10 показывает пример такого определения метода:

Листинг 10. Описание мультиметода

(defn basic-colors-in [color]
  (for [[k v] color :when (not= v 0)] k))

(defmulti color-string basic-colors-in)

(defmethod color-string [:red] [color]
  (str "Red: " (:red color)))

(defmethod color-string [:green] [color]
  (str "Green: " (:green color)))

(defmethod color-string [:blue] [color]
  (str "Blue: " (:blue color)))

(defmethod color-string :default [color]
  (str "Red:" (:red color) ", Green: " (:green color) ", Blue: " (:blue color)))

В Листинге 10, я описываю функцию диспетчеризации, которая называется basic-color-in, которая возвращает вектор всех ненулевых цветовых значений. Для вариации метода, я уточняю, что должно произойти если функция диспетчеризации вернет один цвет. В данном случае, она вернет строку этого цвета. Последний случай включает необязательное ключевое слово :default, который обрабатывает все остальные варианты. Для этого случая, я не могу предположить, что я получу только один цвет, поэтому я возвращаю список значений всех цветов.

Тесты проверки этих мультиметодов показаны в Листинге 11:

Листинг 11. Тестирование цветов в Clojure.

(ns colors-test
  (:use clojure.test)
  (:use colors))

(deftest pure-colors
  (is (= "Red: 5" (color-string (struct color 5 0 0))))
  (is (= "Green: 12" (color-string (struct color 0 12 0))))
  (is (= "Blue: 40" (color-string (struct color 0 0 40)))))

(deftest varied-colors
  (is (= "Red:5, Green: 40, Blue: 6" (color-string (struct color 5 40 6)))))

В Листинге 11, когда я вызываю метод с одним цветом, он выполняется в версии мультиметода для одного цвета. Если я вызову сложный цветовой профиль, метод по умолчанию вернет все цвета.

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

Вывод

В этой части, я сделал беглый обзор по различным механизмам диспетчеризации, которые есть в языках следующего поколения (next-generation languages) на базе JVM. Работа с языком ограничивающим использование диспетчеризации ведет к неудобным обходным путям в виде шаблонов проектирования. Выбор альтернативных языков программирования, которые ранее не существовали, может быть тяжелым из-за необходимости изменять свои взгляды на разработку, однако это часть развития функционального мышления.

Автор: Sigrlami

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js