W3docs

Функциональное программирование в Java

Обзор концепций функционального программирования в Java — функции первого класса, неизменяемость, чистые функции и композиция.

Функциональное программирование в Java

Предыдущая часть — Collections Framework — была о контейнерах: структурах данных, хранящих элементы, и операциях (add, remove, iterate, sort, binarySearch), работающих с ними. Эта часть — о другом слое той же задачи. Вместо того, где живут данные, мы сосредоточимся на том, как выражать преобразования над ними — ясно, композиционно, без избыточных циклов или переменных-накопителей.

У этого сдвига есть имя. Функциональное программирование — это стиль, в котором вычисление выражается как применение функций к значениям, в котором сами функции являются значениями первого класса и в котором данные обычно рассматриваются как неизменяемые. Java не задумывалась как функциональный язык — классы, мутация и явные циклы лежат в её основе, — но с Java 8 каждая современная Java-программа активно заимствует из функционального инструментария. Кое-что из этого вы уже писали в части о коллекциях: list.sort(Comparator.comparing(Person::name)), map.getOrDefault(k, 0), List.copyOf(source). Что делает Часть 12 — она прямо называет стиль и даёт вам остальные инструменты — лямбды, функциональные интерфейсы, типы java.util.function, ссылки на методы, Optional и API Stream, — так что шаблоны, которым вы подражали, становятся ходами первого класса.

Четыре идеи, определяющие стиль

Чисто функциональные языки (Haskell, Erlang, F#) доводят все четыре до предела. Java применяет их в меру. Четыре идеи:

  1. Функции — это значения первого класса. Вы можете передать функцию как аргумент, вернуть её из метода, сохранить в поле или построить во время выполнения.
  2. Чистые функции. Чистая функция зависит только от своих входов и не меняет ничего наблюдаемого в мире. При одном и том же входе она возвращает один и тот же выход. Никакого ввода-вывода, никакой мутации полей, никакого зависящего от времени ветвления.
  3. Неизменяемость по умолчанию. Структуры данных не изменяются на месте; преобразования возвращают новые значения. Старые ссылки остаются действительными.
  4. Композиция. Большие функции строятся путём комбинирования меньших (f.andThen(g), pred.and(other), cmp.thenComparing(...)), а не их редактированием.

Ни одна из них не специфична для Java. Это способ мышления, который язык теперь поддерживает через лямбды, ссылки на методы, API Stream и неизменяемые коллекции, с которыми вы только что познакомились.

1. Функции как значения

До Java 8 у вас не могло быть переменной, значением которой была бы функция. Вы могли передать объект, у класса которого случайно был один метод — таковыми были Runnable, Comparator и ActionListener, — но синтаксис был громоздким:

list.sort(new Comparator<String>() {
  @Override
  public int compare(String a, String b) {
    return a.length() - b.length();
  }
});

Единственный метод был обёрнут в объявление анонимного класса. Java 8 ввела лямбда-выражения как лаконичный синтаксис для той же идеи:

list.sort((a, b) -> a.length() - b.length());

Лямбда и есть значение. Она компилируется в экземпляр того функционального интерфейса, который нужен в точке вызова (здесь Comparator<String>). Следующая глава целиком о синтаксисе; пока суть в том, что функции в Java теперь — это значения, которые можно именовать, хранить и передавать.

2. Чистые функции

Чистая функция — это та, чьё возвращаемое значение зависит только от её аргументов и чьё выполнение не имеет наблюдаемых побочных эффектов. Math.sqrt(2) чиста. System.currentTimeMillis() нет — она возвращает разные значения при разных вызовах. list.add(x) нет — она изменяет list.

Чистые функции ценны, потому что:

  • Их легко тестировать — без настройки, без моков, просто assertEquals(expected, f(input)).
  • Их легко распараллеливать — два чистых вызова могут выполняться в разных потоках без синхронизации.
  • Их легко кэшировать — мемоизировать один раз, навсегда возвращать тот же ответ.
  • Они компонуются без сюрпризовf(g(x)) делает то, что подсказывает прочтение.

Большинство полезных реальных программ не на 100 % чисты (кто-то должен писать в базу данных). Функциональная дисциплина состоит в том, чтобы сделать основное вычисление чистым и оттеснить нечистые части — ввод-вывод, время, случайность, мутацию — к краям. Стримы поощряют это: конвейер чистых операций корректен по построению; нечистый (stream().peek(x -> counter++)...) — магнит для ошибок.

3. Неизменяемость

С этим вы познакомились в прошлой главе. List.of(...), Set.of(...), Map.of(...) и List.copyOf(...) создают коллекции, которые нельзя изменить. Записи (records, рассматриваются далее) дают вам неизменяемые классы данных:

record Point(double x, double y) {
  Point translated(double dx, double dy) {
    return new Point(x + dx, y + dy);     // returns a NEW Point — does not mutate this
  }
}

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

4. Композиция

Композиция — это «построй большую функцию из маленьких». В Java Function, Predicate и Comparator — все предоставляют композиционные операторы:

Function<String, String> trim  = String::trim;
Function<String, String> upper = String::toUpperCase;
Function<String, String> clean = trim.andThen(upper);   // trim, then upper

Predicate<Integer> positive = n -> n > 0;
Predicate<Integer> even     = n -> n % 2 == 0;
Predicate<Integer> posEven  = positive.and(even);

Comparator<String> byLength    = Comparator.comparingInt(String::length);
Comparator<String> lengthThenA = byLength.thenComparing(Comparator.naturalOrder());

Композиционный API — это часть значения. Вы не пишете помощник, принимающий два предиката и соединяющий их через &&, — вы пишете a.and(b). Стиль масштабируется: шестишаговое преобразование может читаться сверху вниз как одно выражение вместо шести вложенных циклов с промежуточными накопителями.

Что Java сохраняет из императивной стороны

Java многопарадигменна. Функциональные возможности, добавленные в Java 8+, живут рядом с императивными, которые есть с версии 1.0. Некоторые вещи остаются императивными намеренно:

  • Инструкции и поток управления. if, for, while, try по-прежнему базовые строительные блоки; лямбды не заменяют их, они заменяют шаблонный код анонимных классов.
  • Изменяемые локальные переменные. Внутри тела метода int sum = 0; for (int x : xs) sum += x; по-прежнему идиоматично.
  • Изменяемые поля там, где они уместны. Строители, кэши и UI-компоненты с состоянием по-прежнему изменяются.

Принцип: используйте функциональный стиль там, где он делает код яснее, а не как догму. Чистое stream().mapToInt(Integer::intValue).sum() яснее, чем написанный вручную цикл. Шестишаговый конвейер лямбда-композиций, который никто в вашей команде не может прочитать, — нет.

Разобранный пример: императивный против функционального, бок о бок

Программа ниже вычисляет среднюю длину непустых строк в списке, дважды. Первая версия императивна — изменяемый накопитель, явный цикл, защита от деления на ноль. Вторая версия функциональна — конвейер чистых операций над стримом, читающийся сверху вниз. Третий фрагмент строит составные значения Predicate и Function из меньших, показывая композицию в действии.

java— editable, runs on the server

Что вынести из запуска:

  • Обе версии вычисляют одно и то же среднее. Императивная объявляет два изменяемых счётчика и тело цикла; функциональная сцепляет пять именованных операций, каждая из которых описывает что, а не как.
  • Predicate.and построил составную проверку (notNull.and(notBlank)) из двух меньших предикатов — без новой вспомогательной функции. Это композиция в действии.
  • Function.andThen сделал то же для конвейера, производящего значение: trim затем length, выраженные как одна составная Function<String, Integer>.
  • Каждая операция в стриме чиста: String::trim, лямбда s -> !s.isEmpty(), String::length — ни одна не изменяет состояние. Двойной вызов trimmedLen.apply(" hi ") дал тот же ответ; это гарантия детерминизма, которая делает чистые функции безопасными для мемоизации и распараллеливания.

Что дальше

Ментальная модель на месте: функции — это значения, чистые преобразования компонуются, неизменяемость избавляет вас от класса ошибок. Следующая глава, Лямбда-выражения Java, вводит конкретный синтаксис(params) -> body, — который делает этот стиль эргономичным в Java, плюс правила вокруг захвата переменных, целевой типизации и того, где может появляться лямбда.

Practice

Практика

A 'pure' function in the functional-programming sense is one that...