Функциональное программирование в 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 применяет их в меру. Четыре идеи:
- Функции — это значения первого класса. Вы можете передать функцию как аргумент, вернуть её из метода, сохранить в поле или построить во время выполнения.
- Чистые функции. Чистая функция зависит только от своих входов и не меняет ничего наблюдаемого в мире. При одном и том же входе она возвращает один и тот же выход. Никакого ввода-вывода, никакой мутации полей, никакого зависящего от времени ветвления.
- Неизменяемость по умолчанию. Структуры данных не изменяются на месте; преобразования возвращают новые значения. Старые ссылки остаются действительными.
- Композиция. Большие функции строятся путём комбинирования меньших (
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 из меньших, показывая композицию в действии.
Что вынести из запуска:
- Обе версии вычисляют одно и то же среднее. Императивная объявляет два изменяемых счётчика и тело цикла; функциональная сцепляет пять именованных операций, каждая из которых описывает что, а не как.
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...