Часто над коллекцией нужно сделать одно и то же: отобрать подходящие элементы, что-то из них достать, посчитать сумму. Раньше это всегда был цикл 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. терминальная операция
Промежуточные 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.