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

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

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

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

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



02.09.2013

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

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

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

На протяжении всей короткой истории развития компьютерных технологий от магистральной линии развития время от времени ответвлялись различные практические и теоретические направления. Языки 4-го поколения, появившиеся в 90-ых годах, представляют собой практическое ответвление, а функциональное программирование – это пример теоретической ветви. Со временем многие ответвления вновь присоединялись к основному ядру, и именно это сейчас и происходит с функциональным программированием. Функциональные языки появляются не только для виртуальной Java-машины, где наиболее интересны два новых языка – Scala и Clojure, но и для платформы .NET, где появился первоклассный язык F#. Почему все платформы начали приобретать возможности функционального программирования? Ответ заключается в том, что со временем среда исполнения стала способной выполнять все больше количество рутинной работы, а разработчики получили возможность передавать ей все больше контроля над выполнением подобных задач.

Передача управления

В начале 80-х годов, когда я учился в университете, мы использовали среду разработки Pecan Pascal. Ее уникальной возможностью было то, что один и тот же код на Pascal можно было запускать на Apple II и на IBM PC. Создатели Pecan реализовали эту возможности благодаря использованию таинственной концепции "байт-кода". Разработчики компилировали свой код, написанный на Pascal, в "байт-код", который запускался на "виртуальной машине", реализованной отдельно для каждой платформы. Это было ужасно! Получившийся код даже простое присваивание класса выполнял крайне медленно. Просто аппаратное обеспечение к тому моменту еще не было готово к подобным задачам.

Десять лет спустя после Pecan Pascal компания Sun выпустила платформу Java, использующую такую же архитектуру, которая, хотя и с трудом, но успешно поддерживалась аппаратным обеспечением середины 90-ых годов. Она также предлагала другие удобные возможности для программистов, например, автоматическую "уборку мусора". Поработав с такими языками как C++, я уже никогда не хотел снова программировать на языках, не поддерживающих автоматическое управление памятью. Я предпочел бы проводить время на более высоких уровнях абстракции, обдумывая способы решения сложных бизнес-задач, а не сложные низкоуровневые проблемы типа управления памятью.

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


Результаты, а не промежуточные действия

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

Листинг 1. Java-программа для классификации чисел с кешированием суммы
import static java.lang.Math.sqrt;

public class ImpNumberClassifier {
    private Set<Integer> _factors;
    private int _number;
    private int _sum;

    public ImpNumberClassifier(int number) {
        _number = number;
        _factors = new HashSet<Integer>();
        _factors.add(1);
        _factors.add(_number);
        _sum = 0;
    }

    private boolean isFactor(int factor) {
        return _number % factor == 0;
    }

    private void calculateFactors() {
        for (int i = 1; i <= sqrt(_number) + 1; i++)
            if (isFactor(i))
                addFactor(i);
    }

    private void addFactor(int factor) {
        _factors.add(factor);
        _factors.add(_number / factor);
    }

    private void sumFactors() {
        calculateFactors();
        for (int i : _factors)
            _sum += i;
    }

    private int getSum() {
        if (_sum == 0)
            sumFactors();
        return _sum;
    }

    public boolean isPerfect() {
        return getSum() - _number == _number;
    }

    public boolean isAbundant() {
        return getSum() - _number > _number;
    }

    public boolean isDeficient() {
        return getSum() - _number < _number;
    }
}

В листинге 1 представлен стандартный Java-код, использующий итерации для поиска и суммирования делителей. В функциональных языках программисты могут обращать меньше внимания на подробности реализации действий, таких как итерирование, используемое в методе calculateFactors(), и операций, таких как суммирование элементов списка в методе sumFactors(). Вместо этого они могут передать управление этими деталями реализации функциям высшего порядка и крупномодульным абстракциям (coarse-grained abstractions).


Крупномодульные абстракции

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

Листинг 2. Groovy-программа для классификации чисел
import static java.lang.Math.sqrt

class Classifier {
  def static isFactor(number, potential) {
    number % potential == 0;
  }

  def static factorsOf(number) {
    (1..number).findAll { isFactor(number, it) }
  }

  def static sumOfFactors(number) {
    factorsOf(number).inject(0, {i, j -> i + j})
  }

  def static isPerfect(number) {
    sumOfFactors(number) == 2 * number
  }

  def static isAbundant(number) {
    sumOfFactors(number) > 2 * number
  }

  def static isDeficient(number) {
    sumOfFactors(number) < 2 * number
  }
}

Код в листинге 2 делает все то же, что и код из листинга 1, за исключением кеширования суммы, которое вернется в следующем примере, но использует для этого значительно меньше кода. Например, итерирование для определения делителей в методе factorsOf() заменено вызовом метода findAll(), который принимает на вход в качестве параметра блок кода (функцию высшего порядка), содержащий критерий для фильтрации. Язык Groovy допускает еще более краткую запись подобных блоков кода за счет использования неявного наименования для единственного параметра. Аналогично, метод sumFactors() использует метод inject(), который использует 0 в качестве стартового значения, применяя блок кода к каждому элементу и "сворачивая" каждую пару элементов в единственное значение. Блок кода {i, j -> i + j} возвращает сумму двух параметров: применяя этот блок, я "сворачиваю" элементы списка попарно, получая в результате их сумму.

Java-разработчики привыкли к повторному использованию на уровне инфраструктуры, так как приемы повторного использования в объектно-ориентированных языках требуют столько усилий, что обычно к ним прибегают только для решения масштабных проблем. Функциональные языки предлагают повторное использование на более низком уровне поверх фундаментальных структур данных, таких как списки (lists) и таблицы соответствия (maps), допуская их модификацию с помощью функций высшего порядка.


Малое количество структур данных и множество действий

В объектно-ориентированных императивных языках единицами для повторного использования считаются классы и сообщения, которыми они обмениваются между собой, зафиксированные в диаграмме классов. Ключевая книга по данному вопросу Design Patterns: Elements of Reusable Object-Oriented Software (см. раздел "Ресурсы") содержит как минимум одну диаграмму классов для каждого шаблона. В мире ООП программистов побуждают создавать уникальные структуры данных со специализированными операциями, оформленными в виде методов. Функциональное программирование не пытается добиться повторного использования кода такими способами. В нем предпочитают использовать несколько ключевых структур данных (таких как list, map, set) с сильной оптимизацией операций, выполняемых для этих структур. Вы передаете свои структуры данных и функции высшего порядка, которые встраиваются в функциональность структур данных, преобразуя их под нужды конкретной задачи. Например, в листинге 2 метод findAll() принимает на вход блок кода в качестве "встраиваемой" функции высшего порядка, которая определяет критерий фильтрации, а среда исполнения применяет этот критерий эффективным способом и возвращает отфильтрованный список.

Инкапсуляция на уровне функций позволяет организовать повторное использование на более низком фундаментальном уровне, нежели построение специализированных структур классов. Одно из преимуществ этого подхода уже присутствует в Clojure. Из последних новшеств, появившихся в библиотеке этого языка, стоит упомянуть изменение функции map(), в результате которого ее выполнение автоматически распараллеливается, что повышает производительность всех операций с ней без участия разработчика.

Для примера рассмотрим сценарий разбора XML. Для выполнения этой задачи в Java существуют множество инфраструктур, каждая с собственными структурами данных и своей семантикой методов (сравните, например, подходы, используемые в SAX и DOM). Clojure, в свою очередь, "разбирает" XML-документ в стандартную структуру типа Map, не заставляя вас использовать специальные структуры данных. Так как Clojure содержит множество инструментов для работы с объектами типа Map, то выполнение запросов в XPath-стиле можно легко организовать с помощью встроенной функции-цикла for для обработки списков, как показано в листинге 3.

Листинг 3. Анализ XML в Clojure
(use 'clojure.xml)

(def WEATHER-URI "http://weather.yahooapis.com/forecastrss?w=%d&u=f")

(defn get-location [city-code]
  (for [x (xml-seq (parse (format WEATHER-URI city-code))) 
        :when (= :yweather:location (:tag x))]
    (str (:city (:attrs x)) "," (:region (:attrs x)))))

(defn get-temp [city-code]
  (for [x (xml-seq (parse (format WEATHER-URI city-code))) 
        :when (= :yweather:condition (:tag x))]
    (:temp (:attrs x))))

(println "weather for " (get-location 12770744) "is " (get-temp 12770744))

В листинге 3 я обращаюсь к службе погоды Yahoo, чтобы получить прогноз погоды для указанного города. Так как Clojure является одной из вариаций Lisp, то этот пример проще всего изучать "изнутри наружу". Непосредственное обращение к сервису происходит при вызове (parse (format WEATHER-URI city-code)), где используется функция format() класса String для встраивания кода города в строку. Функция for для обработки списка (list-comprehension function) в цикле с помощью функции xml-seq помещает разобранный XML документ в переменную x типа Map, к которой впоследствии можно будет выполнять запросы. Предикат :when используется для указания критерия, задающего условие для попадания элемента в коллекцию x, в данном случае я выполняю поиск по тегу :yweather:condition (указанному как ключевое слово Clojure).

Чтобы понять синтаксис, используемый для извлечения значений из структуры данных, может оказаться полезным заглянуть внутрь нее. Соответствующий вызов службы погоды после разбора полученного XML-документа возвращает следующую структуру данных:

({:tag :yweather:condition, :attrs {:text Fair, :code 34, :temp 62, :date Tue, 
   04 Dec 2012 9:51 am EST}, :content nil})

Так как Clojure оптимизирован для работы с коллекциями типа Map, то ключевые слова превращаются в функции для объектов типа Map, содержащих их. Вызов (:tag x) в листинге 3 является сокращенной формой записи для "извлечь значение, соответствующее ключу :tag, из коллекции x типа Map". Таким образом, конструкция :yweather:condition возвращает значения из коллекции, связанные с этим ключом, в которые входит и коллекция attrs. К этой коллекции я в свою очередь обращаюсь с аналогичным запросом :temp, чтобы извлечь значение температуры.

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

Преимущества подхода, основанного на фундаментальных структурах данных, проявляются в XML-библиотеках Clojure. В 1997 году для прохода по древовидным структурам (таким как XML-документы) была создана полезная структура данных, которую назвали zipper (застежка-молния - см. ссылку в разделе "Ресурсы"). Она позволяет перемещаться по структуре дерева путем указания координат нужного направления. Например, начав с корневого элемента, вы можете отдать команду (-> z/down z/down z/left), чтобы перейти на левый элемент второго уровня. В Clojure уже доступны функции для преобразования разобранных XML-документов в zipper-структуру, обеспечивающую единообразную навигацию по всем древовидным структурам.


Новые инструменты

Функциональное программирование предлагает новые типы инструментов для элегантного решения сложных задач. Например, Java-разработчики не привыкли работать с "отложенными" (lazy) структурами данных, которые откладывают генерацию собственных значений до того момента, пока они не будут явно запрошены. Несмотря на то, что появляющиеся функциональные языки уже предлагают поддержку подобных структур, некоторые инфраструктуры преобразуют подобную функциональность, делая возможным ее использование в Java. Например, в листинге 4 приведена версия классификатора чисел, использующая возможности инфраструктуры Totally Lazy (см. ссылку в разделе "Ресурсы").

Листинг 4. Классификатор чисел на Java, использующий отложенное выполнение и функциональные структуры данных, предоставленные Totally Lazy
import com.googlecode.totallylazy.Predicate;
import com.googlecode.totallylazy.Sequence;

import static com.googlecode.totallylazy.Predicates.is;
import static com.googlecode.totallylazy.numbers.Numbers.*;
import static com.googlecode.totallylazy.predicates.WherePredicate.where;


public class Classifier {
  public static Predicate<Number> isFactor(Number n) {
      return where(remainder(n), is(zero));
  }

  public static Sequence<Number> getFactors(final Number n){
      return range(1, n).filter(isFactor(n));
  }

  public static Sequence<Number> factors(final Number n) {
      return getFactors(n).memorise();
  }

  public static Number sumFactors(Number n){
      return factors(n).reduce(sum);
  }

  public static boolean isPerfect(Number n){
      return equalTo(n, subtract(sumFactors(n), n));
  }

  public static boolean isAbundant(Number n) {
    return greaterThan(subtract(sumFactors(n), n), n);
  }

  public static boolean isDeficient(Number n) {
    return lessThan(subtract(sumFactors(n), n), n);
  }

}

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


Приближение языка к решаемой проблеме

Большинство разработчиков работают, пребывая в заблуждении, что их деятельность состоит в преобразовании сложных бизнес-проблем в язык программирования, например, Java. Они поступают так, потому что язык Java не обладает особой гибкостью, заставляя вас оформлять свои идеи в уже имеющиеся жесткие структуры. Но когда разработчики начинают использовать более пластичные языки, они замечают возможность приблизить язык к решаемой проблеме, а не проблему к языку, как они делали раньше. Языки, подобные Ruby с его более удобной по сравнению с другими языками поддержкой доменно-ориентированных языков (domain-specific language - DSL) демонстрируют данную возможность. Современные функциональные языки идут еще дальше. Язык Scala был спроектирован с возможностью встраивания внутренних доменно-ориентированных языков, а все языки семейства Lisp (включая Clojure) обладают уникальной гибкостью, которая позволяет разработчику подстраивать язык под конкретную задачу. В листинге 5 я использовал XML-примитивы из арсенала Scala для реализации примера обращения к службе погоды, который уже встречался в листинге 3.

Листинг 5. Синтаксические сокращения для XML, имеющиеся в Scala
import scala.xml._
import java.net._
import scala.io.Source

val theUrl = "http://weather.yahooapis.com/forecastrss?w=12770744&u=f"

val xmlString = Source.fromURL(new URL(theUrl)).mkString
val xml = XML.loadString(xmlString)

val city = xml \\ "location" \\ "@city"
val state = xml \\ "location" \\ "@region"
val temperature = xml \\ "condition" \\ "@temp"

println(city + ", " + state + " " + temperature)

Язык Scala был специально спроектирован для максимальной адаптируемости и допускает такие расширения, как перегрузка операторов и неявные типы. В листинге 5 этот язык расширяется реализацией XPATH-подобных запросов с использованием оператора \\.


Придерживайтесь основной парадигмы языка

Одной из целей функционального программирования является обеспечение минимальной изменчивости состояния. В листинге 1 присутствуют два типа общего состояния. Обе переменные _factors и _number существуют для облегчения тестирования кода (оригинальная версия данного примера была написана, чтобы продемонстрировать, как можно обеспечить максимальную тестируемость кода) и могут быть устранены путем преобразования в методы. Однако наличие переменной _sum объясняется другой причиной. Я ожидаю, что пользователи этого кода скорее всего не ограничатся одной проверкой числа. Например, если проверка на совершенство окажется неудачной, то можно ожидать последующей проверки на избыточность. Так как операция по суммированию делителей потенциально является ресурсоемкой, то я создал метод доступа к ней с отложенной инициализацией. При первом вызове он вычисляет сумму и сохраняет ее в поле объекта _sum для последующих вызовов.

Так же как и сборка мусора в памяти, реализация кеширования к данному моменту уже может быть переложена на сам язык. Классификатор чисел в листинге 2, написанный на Groovy, не использует отложенную инициализацию суммы делителей, которая присутствует в листинге 1. Если бы я захотел реализовать эту функциональность, я бы изменил классификатор, как показано в листинге 6.

Листинг 6. Ручная реализация кеширования
class ClassifierCachedSum {
  private sumCache

  ClassifierCachedSum() {
    sumCache = [:]
  }

  def sumOfFactors(number) {
    if (sumCache.containsKey(number))
      return sumCache[number]
    else {
      def sum = factorsOf(number).inject(0, {i, j -> i + j})
      sumCache.putAt(number, sum)
      return sum
    }
  }
  // ... остальной код пропущен

В последних версиях Groovy код, представленный в листинге 6, не нужен. Рассмотрим улучшенную версию классификатора, приведенную в листинге 7.

Листинг 7. Классификатор чисел, использующий мемоизацию
class ClassifierMemoized {
  def static dividesBy = { number, potential ->
    number % potential == 0
  }
  def static isFactor = dividesBy.memoize()

  def static factorsOf(number) {
    (1..number).findAll { i -> isFactor.call(number, i) }
  }

  def static sumFactors = { number ->
    factorsOf(number).inject(0, {i, j -> i + j})
  }
  def static sumOfFactors = sumFactors.memoize()

  def static isPerfect(number) {
    sumOfFactors(number) == 2 * number
  }

  def static isAbundant(number) {
    sumOfFactors(number) > 2 * number
  }

  def static isDeficient(number) {
    sumOfFactors(number) < 2 * number
  }
}

Все простые функции (которые не имеют побочных эффектов) могут быть мемоизированы, как метод sumOfFactors() в листинге 7. Мемоизация функции позволяет среде исполнения кешировать повторяющиеся значения, устраняя необходимость в ручной реализации кеширования. На самом деле обратите внимание на отношения между методом getFactors(), который выполняет основную работу, и методом factors() – мемоизованной версией getFactors(). Инфраструктура Totally Lazy также привносит в Java возможность мемоизации, добавляя еще одну продвинутую функциональную возможность в базовый язык программирования.

По мере того как среды исполнения становятся мощнее и функциональнее, разработчики получают возможность передавать рутинные задачи языку, освобождая себя для размышлений о более важных проблемах. Мемоизация, присутствующая в Groovy, - это лишь один из множества примеров; все современные языки добавляют функциональные конструкции, если нижележащая среда допускает такую возможность, включая и инфраструктуры, подобные Totally Lazy.


Заключение

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

С публикацией этой, 20-й по счету, статьи цикл "Функциональное мышление" завершается, но я планирую представить новый цикл статей "Java.next", который будет посвящен трем языкам нового поколения на основе JVM. Этот цикл поможет вам взглянуть в ближайшее будущее и сделать осмысленный выбор к тому времени, когда вам придется приступить к освоению нового языка.

Ресурсы

  • Functional thinking: Why functional programming is on the rise: оригинал статьи (EN).
  • The Productive Programmer (Neal Ford, O'Reilly Media, 2008): в этой книге Нила Форда обсуждаются инструменты и приемы, способные повысить эффективность кодирования.
  • Scala: современный функциональный язык на основе JVM.
  • Clojure: современная функциональная реализация Lisp, работающая поверх JVM.
  • Инфраструктура Totally Lazy предлагает множество функциональных расширений для Java, используя для этого интуитивно понятный DSL-подобный интерфейс.
  • Functional Java: инфраструктура, добавляющая в язык Java многие конструкции из арсенала функциональных языков.
  • Design Patterns: Elements of Reusable Object-Oriented Software (Erich Gamma et al., Addison-Wesley, 1994): классическая книга по шаблонам проектирования.
  • Статья в Wikipedia о структуре данных Zipper.

Комментарии

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=943251
ArticleTitle=Функциональное мышление: Чем объясняется растущая популярность функционального программирования
publish-date=09022013