W3docs

Функциональное программирование в 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 применяет их умеренно. Вот эти четыре идеи:

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

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 Lambda Expressions, вводит конкретный синтаксис(params) -> body — который делает этот стиль эргономичным в Java, а также правила захвата переменных, выведения целевого типа и мест, где может появляться лямбда.

Практика

Практика
'Чистая' функция в смысле функционального программирования — это функция, которая...
'Чистая' функция в смысле функционального программирования — это функция, которая...
Was this page helpful?