Функциональное программирование в 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 и Stream API — чтобы паттерны, которые вы имитировали, стали полноправными приёмами.
Четыре идеи, определяющие стиль
Чисто функциональные языки (Haskell, Erlang, F#) доводят все четыре идеи до предела. Java применяет их умеренно. Вот эти четыре идеи:
- Функции — это значения первого класса. Функцию можно передать в качестве аргумента, вернуть из метода, сохранить в поле или создать во время выполнения.
- Чистые функции. Чистая функция зависит только от своих входных данных и не изменяет ничего наблюдаемого в окружающем мире. При одних и тех же входных данных она возвращает один и тот же результат. Никакого I/O, никаких мутаций полей, никаких ветвлений, зависящих от времени.
- Иммутабельность по умолчанию. Структуры данных не изменяются на месте; преобразования возвращают новые значения. Старые ссылки остаются действительными.
- Композиция. Более крупные функции строятся путём комбинирования меньших (
f.andThen(g),pred.and(other),cmp.thenComparing(...)), а не путём их редактирования.
Ни одна из этих идей не является специфичной для Java. Это способ мышления, который язык теперь поддерживает через лямбда-выражения, ссылки на методы, Stream API и иммутабельные коллекции, с которыми вы только что познакомились.
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% чистыми (кто-то ведь должен записывать в базу данных). Функциональная дисциплина заключается в том, чтобы сделать базовое вычисление чистым, а нечистые части — I/O, время, случайность, мутацию — вынести на периферию. Потоки (Streams) поощряют этот подход: конвейер из чистых операций корректен по построению; нечистый (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;по-прежнему идиоматично. - Изменяемые поля там, где это уместно. Строители (builders), кэши и компоненты UI с состоянием по-прежнему мутируют.
Принцип: используйте функциональный стиль там, где он делает код понятнее, а не как догму. Чистый stream().mapToInt(Integer::intValue).sum() понятнее, чем написанный вручную цикл. Конвейер из шести шагов с лямбда-композицией, который никто в вашей команде не может прочитать, — нет.
Рабочий пример: императивный vs функциональный, бок о бок
Программа ниже вычисляет среднюю длину непустых строк в списке двумя способами. Первая версия — императивная: изменяемый аккумулятор, явный цикл, защита от деления на ноль. Вторая версия — функциональная: конвейер потока из чистых операций, читаемый сверху вниз. Третий фрагмент строит составные значения 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 Lambda Expressions, вводит конкретный синтаксис — (params) -> body — который делает этот стиль эргономичным в Java, а также правила захвата переменных, выведения целевого типа и мест, где может появляться лямбда.