← назад к разделу

Часто над коллекцией нужно сделать одно и то же: отобрать подходящие элементы, что-то из них достать, посчитать сумму. Раньше это всегда был цикл for. Лямбды и Stream API позволяют описать что мы хотим получить, а не как перебирать. Разберёмся с обоими механизмами с нуля.

Зачем это нужно: «что», а не «как»

Представь задачу: из списка пользователей оставить совершеннолетних и собрать их имена. Обычный цикл:

List<String> names = new ArrayList<>();
for (User u : users) {
    if (u.age() >= 18) {        // отбираем
        names.add(u.name());    // достаём имя
    }
}

Тут перемешаны две вещи: намерение (отобрать и достать имя) и механика (создать список, перебрать, добавить). Stream API даёт записать только намерение:

List<String> names = users.stream()
        .filter(u -> u.age() >= 18)   // отбираем
        .map(User::name)              // достаём имя
        .toList();

Чтобы дойти до второго варианта, сначала нужно понять две вещи: что такое лямбда и что такое функциональный интерфейс.

Функциональные интерфейсы

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

В пакете java.util.function есть четыре базовых интерфейса, которые встречаются постоянно:

  • Function<T, R> — принимает T, возвращает R. Метод apply. «Преобразование».
  • Predicate<T> — принимает T, возвращает boolean. Метод test. «Проверка/условие».
  • Consumer<T> — принимает T, ничего не возвращает. Метод accept. «Действие с побочным эффектом».
  • Supplier<T> — ничего не принимает, возвращает T. Метод get. «Поставщик значения».
Function<String, Integer> length = s -> s.length();   // строка -> её длина
Predicate<Integer> isPositive = n -> n > 0;           // число -> true/false
Consumer<String> printer = s -> System.out.println(s);// строка -> печать
Supplier<Long> now = () -> System.currentTimeMillis();// без входа -> число

System.out.println(length.apply("hello")); // 5
System.out.println(isPositive.test(-3));    // false
printer.accept("привет");                   // печатает: привет
System.out.println(now.get());              // текущее время в мс

Можно писать и свои функциональные интерфейсы. Аннотация @FunctionalInterface не обязательна, но защищает: компилятор проверит, что метод действительно один.

@FunctionalInterface
interface Discount {
    int applyTo(int price); // ровно один абстрактный метод
}

Лямбды

Лямбда — это короткая запись реализации функционального интерфейса прямо на месте, без отдельного класса. Короткая формула: (аргументы) -> тело.

Discount half = price -> price / 2;     // одно выражение — результат возвращается сам
System.out.println(half.applyTo(100));  // 50

Несколько форм записи:

() -> 42                       // без аргументов
x -> x + 1                     // один аргумент, скобки можно опустить
(x, y) -> x + y                // два аргумента — скобки обязательны
(int x, int y) -> x + y        // можно указать типы явно
x -> {                         // тело из нескольких строк — нужны фигурные скобки и return
    int doubled = x * 2;
    return doubled + 1;
}

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

Важная деталь: лямбда может использовать переменные из окружающего кода, но только если они фактически не меняются (effectively final).

int bonus = 10;                    // больше не присваиваем — значит effectively final
Function<Integer, Integer> add = x -> x + bonus; // лямбда «захватывает» bonus
System.out.println(add.apply(5));  // 15

Method reference

Если лямбда просто вызывает уже существующий метод, её можно записать ещё короче — через method reference (ссылку на метод) с оператором ::. Это то же самое поведение, только компактнее и читабельнее.

Function<String, Integer> length = String::length;     // вместо s -> s.length()
Consumer<String> printer = System.out::println;        // вместо s -> System.out.println(s)
Supplier<ArrayList<String>> factory = ArrayList::new;   // вместо () -> new ArrayList<>()

Четыре вида ссылок:

  • String::length — на метод экземпляра по типу (объект подставит сам поток).
  • System.out::println — на метод конкретного объекта.
  • Integer::parseInt — на статический метод.
  • ArrayList::new — на конструктор.

Правило выбора простое: если лямбда только перенаправляет вызов в один метод без лишней логики — пиши method reference; если внутри есть что-то ещё — оставляй лямбду.

Stream API: конвейер обработки

Stream — это конвейер для обработки последовательности элементов. Он не хранит данные (источник — коллекция, массив и т.д.) и не меняет источник: на каждом шаге получается новый поток.

Конвейер всегда состоит из трёх частей:

long count = users.stream()           // 1. источник
        .filter(u -> u.age() >= 18)   // 2. промежуточные операции
        .map(User::name)
        .count();                     // 3. терминальная операция
diagram

Промежуточные vs терминальные операции

Это ключевое различие, без которого стримы кажутся магией.

  • Промежуточные операции (filter, map, sorted, distinct, limit) возвращают новый Stream и ничего не делают сразу — только записывают, что нужно сделать.
  • Терминальная операция (collect, toList, count, forEach, reduce, findFirst) запускает весь конвейер и возвращает результат (значение или коллекцию). После неё поток использовать нельзя.

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

Stream<String> s = users.stream()
        .filter(u -> { System.out.println("проверяю " + u); return u.age() >= 18; })
        .map(User::name);
// до этой строки в консоль НЕ выведется ничего — нет терминальной операции
List<String> result = s.toList(); // вот теперь конвейер реально побежал

Ленивость ещё и экономит работу: элементы проходят конвейер по одному, и limit может остановить обработку, не перебирая весь источник.

filter, map

filter оставляет элементы, подходящие под Predicate. map преобразует каждый элемент через Function.

List<String> result = List.of("apple", "kiwi", "banana", "fig").stream()
        .filter(s -> s.length() > 3)   // оставить длиннее 3 символов: apple, banana
        .map(String::toUpperCase)      // в верхний регистр: APPLE, BANANA
        .toList();                     // [APPLE, BANANA]

reduce

reduce сворачивает поток в одно значение: берёт начальное значение и функцию, которая объединяет «накопленное» с очередным элементом.

int sum = List.of(1, 2, 3, 4).stream()
        .reduce(0, (acc, n) -> acc + n); // 0+1+2+3+4
System.out.println(sum); // 10

Для чисел чаще берут специализированные потоки — короче и без упаковки:

int sum = List.of(1, 2, 3, 4).stream()
        .mapToInt(Integer::intValue)
        .sum(); // 10

collect и Collectors

collect собирает поток в коллекцию или другую структуру. Чаще всего используют готовые сборщики из класса Collectors. Для простого списка в Java 21 есть короткий toList().

import static java.util.stream.Collectors.*;

List<String> names = users.stream().map(User::name).toList();

// сгруппировать пользователей по возрасту: Map<Integer, List<User>>
Map<Integer, List<User>> byAge = users.stream()
        .collect(groupingBy(User::age));

// склеить имена через запятую: "Анна, Борис, Вера"
String joined = users.stream()
        .map(User::name)
        .collect(joining(", "));

Когда стрим, а когда обычный цикл

Stream API — не замена циклу «всегда и везде». Ориентир простой.

Стрим уместен, когда есть цепочка преобразований над коллекцией (отобрать → преобразовать → собрать/посчитать) — он читается как описание намерения.

Обычный цикл лучше, когда:

  • нужен побочный эффект на каждом шаге (запись в файл, в БД) — для этого цикл честнее, чем forEach;
  • логика сложная, с ранним выходом, несколькими переменными состояния или вложенными условиями;
  • важна отладка по шагам или нужен индекс элемента;
  • это горячий участок, где упаковка/распаковка чисел в стриме создаёт лишнюю нагрузку.

Короткая формула: стрим — для трансформации данных, цикл — для управления ходом выполнения. И не меняй источник внутри стрима — это нарушает его модель и приводит к трудноуловимым ошибкам.

Коротко

  • Функциональный интерфейс — интерфейс с одним абстрактным методом; базовые: Function, Predicate, Consumer, Supplier.
  • Лямбда (args) -> тело — реализация такого интерфейса прямо на месте; захватываемые переменные должны быть effectively final.
  • Method reference (String::length, System.out::println, ArrayList::new) — короткая форма лямбды, когда она лишь вызывает существующий метод.
  • Stream — конвейер: источник → промежуточные операции (filter, map) → терминальная (collect, count, reduce).
  • Промежуточные операции ленивы: ничего не выполняется, пока не вызвана терминальная.
  • collect + Collectors (groupingBy, joining) собирают результат; для простого списка — toList().
  • Стрим — для трансформации данных; обычный цикл — для побочных эффектов, сложного управления и горячих участков.

Что почитать дальше

  • Generics: обобщённые типы — почему Function<T, R> и List<String> пишутся в угловых скобках.
  • Records и современный Java — компактные типы данных, которые удобно гонять через стримы.
  • Коллекции — List, Set, Map: то, над чем чаще всего и строится Stream API.