Функциональное мышление: Переосмысление подходов к управлению программой

Особенности реализации диспетчеризации в языках следующего поколения, основанных на JVM.

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

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

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



29.04.2013

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

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

В предыдущей статье я продемонстрировал, как с помощью параметризованных типов (generics) в Java можно воспроизвести функциональность для сопоставления значений с шаблонами, доступную в языке Scala и позволяющую писать более чёткие и понятные условные выражения. Функциональность Scala для сопоставления значений с шаблонами — это один из примеров альтернативного механизма организации управления программой (dispatch mechanism) или, говоря шире, способа, с помощью которого языки программирования динамически выбирают функциональность, которую необходимо выполнить. В этой статье мы продолжим обсуждение того, как механизмы для управления исполняемым кодом в различных функциональных языках на основе JVM обеспечивают большую выразительность и гибкость, чем аналогичная функциональность, доступная в Java.

Улучшения в процессе управления программой, доступные в Groovy

В языке Java выполнение действий в зависимости от разных условий сводится к применению конструкции if, и иногда, в редких случаях, применяется конструкция switch. Так как длинный набор if-выражений становится сложным для восприятия, то Java-программисты прибегают к помощи шаблона проектирования Фабрика / Factory (Абстрактная Фабрика / Abstract / Factory) из книги Гамма и др. (см. раздел "Ресурсы"). Применение языка, допускающего более гибкие выражения для принятия решений, позволяет ещё больше упростить код.

В языке Groovy имеется многофункциональная конструкция switch, имитирующая синтаксис, но не поведение конструкции switch из языка Java, как показано в листинге 1.

Листинг 1. Улучшенная версия конструкции switch в языке Groovy
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, каждый отдельный случай должен завершаться выражением return или break; также присутствует и семантика "сквозного" прохода конструкции switch. Но в Groovy, в отличие от Java, я могу использовать диапазоны (90..100), диапазоны с исключенными границами (80..<90), регулярные выражения (~"[ABCDFabcdf]"), default-секцию, активируемую, если ни одно из условий так и не было выполнено.

Динамическая типизация, присутствующая в Groovy, позволяет мне отправлять параметры различных типов, как показано в unit-тестах в листинге 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-выражений и шаблоном проектирования Фабрика. Конструкция 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, каждый отдельный случай включает контрольное условие, которое и определяет поведение.

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

Листинг 4. Тестирование символьных оценок в Scala
printf("Amy scores %d and receives %s\n", 91, letterGrade(91))
printf("Bob scores %d and receives %s\n", 72, letterGrade(72))
printf("Sam never showed for class, scored %d, and received %s\n", 44, letterGrade(44))
printf("Roy transfered and already had %s, which translated as %s\n", 
    "B", letterGrade("B"))

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


"Гибкий" язык программирования Clojure

Язык программирования Clojure – это ещё один функциональный язык следующего поколения для платформы Java (см. раздел "Ресурсы"). Этот язык представляет собой реализацию Lisp поверх виртуальной Java-машины и использует синтаксис, полностью отличный от других современных языков. Хотя разработчики могут легко адаптироваться к его синтаксису, программистам, постоянно работающим с Java, он покажется странным. Одной из самых удачных возможностей языков семейства Lisp является гомоиконичность (homoiconicity), означающая, что язык реализован на основе собственных структур данных. Это открывает возможность расширения языка до уровня, недоступного другим языкам.

В Java и подобных ему языках присутствуют ключевые слова (keywords) – синтаксический каркас языка. Разработчики не могут добавлять в язык новые ключевые слова (хотя некоторые из Java-подобных языков допускают расширение через метапрограммирование), а используемая ключевыми словами семантика недоступна для программистов. Например, конструкция if в Java понимает "сокращенное" (short-circuit) вычисление булевских выражений. Хотя в 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 методы занимают более важное место, чем классы, поэтому я могу выполнять вызовы методов "изнутри наружу": вызов score.toUpperCase() в Java эквивалентен вызову (.toUpperCase score) в Clojure.

В листинге 6 приведён unit-тест для проверки обработки символьных оценок в Clojure.

Листинг 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.

В тесте для символьных и числовых оценок я хочу проверить каждое значение в соответствующих диапазонах. Если вы не знакомы с семантикой Lisp, то проще всего понять код, читая его "изнутри наружу". Сначала в коде #(is (= "A" (letter-grade %))) создаётся новая анонимная функция, принимающая единственный параметр (если у вас есть анонимная функция, принимающая на вход всего один параметр, то в теле функции к нему можно обращаться с помощью символа %) и возвращающая true, если возвращается правильное значение оценки. Функция map привязывает эту анонимную функцию к коллекции элементов, находящейся во втором параметре (в этой коллекции хранится список чисел, входящих в соответствующий диапазон). Другими словами, функция map вызывает анонимную функцию для каждого элемента из коллекции, возвращая коллекцию измененных значений (которую я в данном случае игнорирую). Функция dorun обеспечивает работу вспомогательной функциональности, которая используется тестирующей инфраструктурой. Вызов функции map для каждого из диапазонов, приведённых в листинге 6, возвращает список, содержащий только значения True. Метод is из пространства имён clojure.test дополнительно проверяет значение. Вызов функции map внутри функции dorun обеспечивает правильную работу тестирующей инфраструктуры и возвращает результаты тестирования.


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

Хотя длинные последовательности из if-выражений довольно сложно читать и отлаживать, в языке Java не существует достаточно удачных альтернатив этому подходу. Подобная проблема обычно решается с помощью шаблонов проектирования Фабрика или Абстрактная Фабрика из книги Гамма и др. Шаблон Фабрика работает в языке Java благодаря межклассовому полиморфизму, что позволяет определить общую сигнатуру метода в родительском классе или интерфейсе, а затем выбрать реализацию, которая будет выполняться динамически.

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

Синтаксис языка Groovy обладает меньшей избыточностью и более понятен, чем синтаксис Java, так что в следующих нескольких примерах я воспользуюсь Groovy, а не Java, хотя полиморфизм работает одинаково в обоих языках. Рассмотрим комбинацию из интерфейса и классов, приведённых в листинге 7 и определяющих фабрику Product.

Листинг 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 из-за того, что это не объектно-ориентированный язык, считая, что именно объектно-ориентированные языки обладают максимальными возможностями. Это ошибка, так как Clojure обладает всеми возможностями объектно-ориентированного языка, которые реализованы независимо от других его возможностей. Например, в Clojure поддерживается полиморфизм, но не нужно определять тип класса, чтобы принять решение, куда дальше передать управление. Язык Clojure поддерживает полиморфные мультиметоды, когда выбор направления работы программы, определяется по определённой характеристике (или их комбинации), выбранной программистом.

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

Листинг 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 определяется структура, содержащая три значения, которые соответствуют определённым цветам. Я также создаю три метода, которые возвращают экземпляр структуры, соответствующей одному определённому цвету.

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

В листинге 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 я определяю dispatch-функцию basic-colors-in, которая возвращает вектор при любых ненулевых значениях цветов. В различных вариантах этого метода я указываю, что должно произойти, если dispatch-функция вернёт один цвет; в этом случае значение будет возвращено в виде строки, соответствующей данному цвету. В последнем варианте, содержащем необязательное ключевое слово :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 при вызове метода с указанием одного из "элементарных" цветов выполняется версия мультиметода, соответствующая данному цвету. При вызове же этого метода с составным цветовым профилем выполняется реализация мультиметода по умолчанию, возвращающая все цвета.

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


Заключение

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

Ресурсы

  • Functional thinking: Rethinking dispatch: оригинал статьи (EN).
  • The Productive Programmer (Neal Ford, O'Reilly Media, 2008): новая книга Нила Форда, в которой более подробно рассматриваются некоторые вопросы из данной серии статей.
  • Scala: современный функциональный язык на основе JVM.
  • Clojure: современная, функциональная реализация Lisp, работающая поверх JVM.
  • Инфраструктура Functional Java добавляет в язык Java множество возможностей из арсенала функциональных языков программирования.
  • Design Patterns: Elements of Reusable Object-Oriented Software (Erich Gamma et al., Addison-Wesley, 1994): классическая книга, содержащая каталог шаблонов проектирования.
  • Execution in the Kingdom of Nouns (Steve Yegge, март 2006 г.) (EN): занимательная статья, посвященная отдельным особенностям архитектуры языка Java.

Комментарии

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=900924
ArticleTitle=Функциональное мышление: Переосмысление подходов к управлению программой
publish-date=04292013