image

Текст статьи взят из презентации, которую я показывал в LinkedIn в2016 году. В презентации была предпринята попытка объяснить функциональное программирование без использования таких понятий, как «монады», «неизменность» или «побочные эффекты». Вместо этого она фокусируется на том, как размышления о композиции могут сделать вас лучшим программистом, независимо от того, какой язык вы используете.

40 лет назад, 17 октября 1977 года, премия Тьюринга была вручена Джону Бэкусу за его вклад в разработку систем программирования высокого уровня, прежде всего языка программирования Fortran. Всем лауреатам премии Тьюринга предоставляется возможность выступить с лекцией по выбранной ими теме в течение года, в котором они получили премию. Как создатель языка программирования Фортран, можно было ожидать, что Бэкус выступит с лекцией о преимуществах Фортрана и будущих разработках в этом языке. Вместо этого он прочитал лекцию под названием «Можно ли освободить программирование от стиля фон Неймана»? в котором он критиковал некоторые из основных языков того времени, включая Фортран, за их недостатки. Он также предложил альтернативу: функциональный стиль программирования.

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

Композиция — это способность собрать сложное поведение путем объединения простых частей. На уроках информатики большое внимание уделяется абстракции: взятию большой проблемы и разделению ее на части. Меньший акцент делается на обратном: как только вы реализуете небольшие части, как соединить их вместе. Кажется, что некоторые функции и системы легко соединить вместе, в то время как другие намного сложнее. Но нам нужно сделать шаг назад и спросить: какие свойства этих функций и систем облегчают их компоновку? Какие свойства затрудняют их компоновку? После прочтения достаточного количества кода шаблон начинает появляться, и этот шаблон является ключом к пониманию функционального программирования.

Давайте начнем с рассмотрения функции, которая действительно хорошо скомпонована:

String addFooter(String message) {
  return message.concat(" - Sent from LinkedIn");
}

Мы можем легко скомпоновать ее с другой функцией без необходимости вносить какие-либо изменения в наш исходный код:

boolean validMessage(String message) {
  return characterCount(addFooter(message)) <= 140;
}

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

Теперь давайте взглянем на функцию, которая не так хорошо скомпонована:

String firstWord(String message) {
  String[] words = message.split(' ');
  if (words.length > 0) {
    return words[0];
  } else {
    return null;
  }
}

А затем попробуйте скомпоновать ее с другой функции:

// “Hello world” -> “HelloHello”
duplicate(firstWord(message));

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

String duplicateBad(String word) {
  if (word == null) {
    return null;
  } else {
    return word.concat(word);
  }
}

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

Нулевые объекты плохо компонуются.

Давайте рассмотрим альтернативную реализацию, в которой используется тип Java 8 Optional (также называемый Option или Maybe на других языках):

Optional<String> firstWord(String message) {
  String[] words = message.split(' ');
  if (words.length > 0) {
    return Optional.of(words[0]);
  } else {
    return Optional.empty();
  }
}

Теперь мы попытаемся скомпоновать ее с неизмененной функцией duplicate:

// "Hello World" -> Optional.of("HelloHello")
firstWord(input).map(this::duplicate)

Оно работает! Опциональный тип заботится о том, что firstWord иногда не возвращает значение. Если Optional.empty() возвращается из firstWord, то функция .map просто пропустит запуск функции duplicate. Мы смогли легко объединить функции без необходимости модифицировать duplicate. Сравните это с null случаем, когда нам нужно было создать функцию duplicateBad. Другими словами: нулевые объекты плохо компонуются, а опционалы хорошо.

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

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

getData(function(a) {  
    getMoreData(a, function(b) {
        getMoreData(b, function(c) { 
            getMoreData(c, function(d) { 
                getMoreData(d, function(e) { 
                    // ...
                });
            });
        });
    });
});

Например, в более крупном веб-приложении это приводит к очень вложенному спагетти-коду. Представьте себе попытку выделить одну из функций getMoreData в ее собственный метод. Или представьте, что вы пытаетесь добавить обработку ошибок к этой вложенной функции. Причина, по которой она не может быть скомпонована, состоит в том, что к каждому блоку кода предъявляется много контекстных требований: для самого внутреннего блока требуется доступ к результатам из a, b, c и т. д. и т. д.

Значения легче компоновать вместе, чем функции

Давайте посмотрим на инструментарий функционального программиста чтобы найти альтернативу: Promise (иногда называемое Future на других языках). Вот как выглядит код сейчас:

getData()
  .then(getMoreData)
  .then(getMoreData)
  .then(getMoreData)
  .catch(errorHandler)

Функции getData теперь возвращают значение Promise вместо принятия функции обратного вызова. Значения проще компоновать вместе, чем функции, потому что они не имеют тех же предварительных условий, что и обратный вызов. Теперь легко добавить обработку ошибок ко всему блоку благодаря функциональности, которую предоставляет нам объект Promise.

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

// ["hello", "world"] -> ["hello!", "world!"]
List<String> addExcitement(List<String> words) {
  List<String> output = new LinkedList<>();
  for (int i = 0; i < words.size(); i++) {
    output.add(words.get(i) + “!”);
  }
  return output;
}

// ["hello", "world"] -> ["hello!!", "world!!"]
List<String> addMoreExcitement(List<String> words) {
  return addExcitement(addExcitement(words));
}

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

Это немного надуманный пример, но если вы представите себе код, разбросанный по большей кодовой базе, то он иллюстрирует важный момент: в больших системах, когда вы пытаетесь разбить вещи на модули, операции над одним фрагментом данных не будут жить все вместе. Вы должны сделать выбор между модульностью или производительностью.

При императивном программировании вы можете получить только модульность или только производительность. С функциональным программированием вы можете иметь и то, и другое.

Ответ функционального программиста (по крайней мере, в Java 8) — это Stream. Stream по умолчанию ленив, что означает, что он проходит через данные только тогда, когда это необходимо. Другими словами, «ленивая» функция: она начинает выполнять работу только тогда, когда ее просят о результате (функциональный язык программирования Haskell построен на концепции лени). Давайте перепишем приведенный выше пример, используя вместо этого Stream:

String addExcitement(String word) {
  return word + "!";
}

list.toStream()
  .map(this::addExcitement)
  .map(this::addExcitement)
  .collect(Collectors.toList())

Таким образом цикл по списку пройдет только один раз и вызовет функцию addExcitement дважды для каждого элемента. Опять же, нам нужно представить, что наш код работает с одним и тем же фрагментом данных в нескольких частях приложения. Без такой ленивой структуры, как Stream, попытка повысить производительность за счет объединения всех обходов списков в одном месте означала бы разрушение существующих функций. С ленивым объектом вы можете достичь как модульности, так и производительности, потому что обходы откладываются до конца.

Теперь, когда мы рассмотрели некоторые примеры, давайте вернемся к задаче чтобы выяснить, какие свойства облегчают создание некоторых функций по сравнению с другими. Мы видели, что такие вещи, как нулевые объекты, обратные вызовы и циклы, плохо компонуются. С другой стороны, опциональные типы, промисы и потоки действительно хорошо компонуются. Почему это так?

Ответ в том, что составные примеры имеют четкое разделение между тем, что вы хотите сделать, и тем, как вы на самом деле это делаете.

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

Например, в случае Promise: что в этом случае делает один HTTP-вызов, за которым следует другой? Вопрос не имеет значения и абстрагируется: возможно, он использует пулы потоков, блокировки мьютексов и т. д., Но это не имеет значения.

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

Именно таково мое практическое определение функционального программирования. Мы хотим иметь четкое разделение проблем в наших программах. Часть “что вы хотите" хороша и композиционна и позволяет легко создавать большие вещи из меньших. В какой-то момент требуется часть “как вы это делаете”, но, отделяя ее, мы убираем материал, который не так композиционен, от материала, который более композиционен.

Мы можем видеть это в реальных примерах:

  • API Apache Spark для выполнения вычислений на больших наборах данных абстрагирует детали того, на каких машинах он будет работать и где хранятся данные
  • React.js описывает представление и делает преобразование DOM эффективным алгоритмом

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

image

Узнайте подробности, как получить востребованную профессию с нуля или Level Up по навыкам и зарплате, пройдя платные онлайн-курсы SkillFactory:



Читать еще