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

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

живой пример

import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;

public class Basics {
    public static void main(String[] args) {
        Function<String, Integer> length = s -> s.length();    // строка -> её длина
        Predicate<Integer> isPositive = n -> n > 0;            // число -> true/false
        Consumer<String> printer = s -> System.out.println(s); // строка -> печать
        Supplier<String> greeting = () -> "привет";            // без входа -> значение

        System.out.println(length.apply("hello")); // 5
        System.out.println(isPositive.test(-3));   // false
        printer.accept(greeting.get());            // привет
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Function<T, R> принимает T и возвращает R (метод apply), Predicate<T> отвечает boolean (test), Consumer<T> только принимает (accept), Supplier<T> только возвращает (get). Можно писать и свои: аннотация @FunctionalInterface не обязательна, но защищает — компилятор проверит, что метод действительно один.

Лямбды

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

живой пример

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

    public static void main(String[] args) {
        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).

живой пример

import java.util.function.Function;

public class Capture {
    public static void main(String[] args) {
        int bonus = 10;                                  // больше не присваиваем — effectively final
        Function<Integer, Integer> add = x -> x + bonus; // лямбда «захватывает» bonus
        System.out.println(add.apply(5));                // 15
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Checked-исключения в лямбде

Лямбда обязана точно соответствовать сигнатуре метода функционального интерфейса — а у Function.apply, Consumer.accept и остальных стандартных интерфейсов нет throws. Поэтому метод с checked-исключением внутри лямбды не компилируется:

List<String> texts = paths.stream()
    .map(p -> Files.readString(p))   // ошибка: unreported exception IOException
    .toList();

Обходов два: обернуть в unchecked прямо в лямбде (catch (IOException e) { throw new UncheckedIOException(e); }) или вынести чтение в метод с той же обёрткой и передать .map(this::readQuietly). Если ошибки нужно обрабатывать по-разному для разных элементов или прервать обход на первой — обычный цикл с try/catch читается проще.

Method reference

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

живой пример

import java.util.ArrayList;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;

public class Refs {
    public static void main(String[] args) {
        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<>()

        System.out.println(length.apply("stream")); // 6
        printer.accept("напечатала ссылка на метод");
        System.out.println(factory.get());          // []
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

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

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

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

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

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

элементы идут по конвейеру по одному, а не списком после каждого шага stream() filter n > 10 map n * 2 toList() 12, 5, 20, 3 [] 24 40 1224 2040 5 3 не прошли filter результат

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

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

Это ключевое различие.

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

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

живой пример

import java.util.List;
import java.util.stream.Stream;

public class Lazy {
    public static void main(String[] args) {
        List<String> words = List.of("kiwi", "banana", "fig");

        Stream<String> pipeline = words.stream()
                .filter(w -> { System.out.println("проверяю " + w); return w.length() > 3; })
                .map(String::toUpperCase);
        System.out.println("конвейер описан, но не выполнялся");

        List<String> result = pipeline.toList(); // терминальная операция запускает всё
        System.out.println(result);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

Вторая половина выигрыша от ленивости — операции с коротким замыканием: они умеют остановить конвейер, не дождавшись конца источника. anyMatch и noneMatch отвечают, как только нашли первый подходящий или неподходящий элемент; findFirst и findAny забирают первый и всё; limit(n) пропускает ровно n и дальше не тянет. Для списка на миллион строк, где нужная третья, это разница между тремя проверками и миллионом. Замечать это стоит и в другую сторону: sorted и distinct замыкание ломают, потому что им нужен весь источник целиком, и sorted().findFirst() всё равно прочитает и отсортирует всё.

1-й filter: мимо 2-й filter: мимо 3-й findFirst забрал 4-й и дальше источник не читали

Через конвейер прошли только три элемента: на найденном findFirst остановил источник, и до остальных дело не дошло.

Терминальные операции, у которых результата может не быть, возвращают Optional: findFirst, min, max и reduce без начального значения. Это тот самый Optional из статьи про современную Java, и с ним обращаются так же: orElse, orElseThrow, map, а не get. Пустой стрим даёт пустой Optional, и это единственная причина, по которой users.stream().filter(...).findFirst() не может вернуть null.

Но у сохранённого в переменную конвейера есть цена. Стрим — одноразовый: после терминальной операции второй вызов pipeline.toList() не вернёт тот же результат, а упадёт с IllegalStateException: stream has already been operated upon or closed. Если данные нужны дважды, второй раз берут новый стрим от источника, а не старую переменную.

filter, map

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

живой пример

import java.util.List;

public class FilterMap {
    public static void main(String[] args) {
        List<String> result = List.of("apple", "kiwi", "banana", "fig").stream()
                .filter(s -> s.length() > 3)  // длиннее трёх символов: apple, kiwi, banana
                .map(String::toUpperCase)     // в верхний регистр
                .toList();
        System.out.println(result);           // [APPLE, KIWI, BANANA]
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

flatMap

map меняет каждый элемент на один другой. А flatMap — на несколько сразу: возвращает не значение, а маленький поток, и все такие потоки склеиваются в один общий. Этим разбирают вложенность: список списков в один список, Optional в поток из нуля или одного элемента.

живой пример

import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;

public class Flat {
    public static void main(String[] args) {
        List<List<String>> orders = List.of(List.of("хлеб", "молоко"), List.of(), List.of("чай"));
        System.out.println(orders.stream().flatMap(List::stream).toList());  // [хлеб, молоко, чай]

        Stream<Optional<String>> found = Stream.of(Optional.of("Анна"), Optional.empty(), Optional.of("Вера"));
        System.out.println(found.flatMap(Optional::stream).toList());        // [Анна, Вера]
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Вторая строчка — самый частый приём: Optional::stream превращает «значение или пусто» в поток, и пустые просто исчезают. Так Stream<Optional<User>> становится Stream<User> без единой проверки на null.

sorted

sorted без аргументов сортирует по натуральному порядку (числа по возрастанию, строки по алфавиту). Чаще нужен порядок по полю — его задают через Comparator.comparing, а reversed() разворачивает.

живой пример

import java.util.Comparator;
import java.util.List;

public class Sorted {
    record Product(String name, Integer rating) {}

    public static void main(String[] args) {
        List<Product> products = List.of(
                new Product("чай", 3), new Product("хлеб", 5), new Product("соль", null));

        // так падает с NullPointerException: у соли оценки нет
        // products.stream().sorted(Comparator.comparing(Product::rating).reversed()).toList();

        products.stream()
                .sorted(Comparator.comparing(Product::rating,
                        Comparator.nullsLast(Comparator.reverseOrder())))
                .forEach(p -> System.out.println(p.name() + " " + p.rating()));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Закомментированная строка — та самая ловушка. Comparator.comparing(Product::rating) достаёт значение и сравнивает его, а null сравнивать не с чем. Лечится не проверкой в лямбде, а вторым аргументом comparing: nullsLast (или nullsFirst) говорит, куда девать пустые, а внутренний компаратор задаёт порядок для остальных.

reduce

reduce сворачивает поток в одно значение: берёт начальное значение и функцию, которая объединяет «накопленное» с очередным элементом. Для чисел есть специализированные потоки — IntStream, LongStream, DoubleStream: у них сразу готовы sum(), average(), max(), и внутри они возят примитивы, а не обёртки.

живой пример

import java.util.List;

public class Sum {
    public static void main(String[] args) {
        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 fast = List.of(1, 2, 3, 4).stream()
                .mapToInt(Integer::intValue)     // дальше по конвейеру — примитивы
                .sum();
        System.out.println(fast);                // 10
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Выигрыш тут, честно говоря, в читаемости: элементы List.of(1, 2, 3, 4) уже упакованы в Integer при создании списка, и mapToInt их только распаковывает обратно. Экономия появляется там, где числа рождаются сразу примитивами — например IntStream.rangeClosed(1, 1_000_000).sum(): ни одного объекта на миллион значений.

collect и Collectors

collect собирает поток в коллекцию или другую структуру. Чаще всего используют готовые сборщики из класса Collectors: groupingBy раскладывает элементы по ключу, joining склеивает строки. Для простого списка с Java 16 есть короткий toList().

живой пример

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class Collect {
    record User(String name, int age) {}

    public static void main(String[] args) {
        List<User> users = List.of(new User("Анна", 30), new User("Борис", 17), new User("Вера", 30));

        Map<Integer, List<User>> byAge = users.stream()
                .collect(Collectors.groupingBy(User::age));
        System.out.println(byAge.get(30).size() + " пользователя с возрастом 30");

        String joined = users.stream()
                .map(User::name)
                .collect(Collectors.joining(", "));
        System.out.println(joined);  // Анна, Борис, Вера
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

toMap и groupingBy со счётом

Самое частое реальное применение collect — не список, а словарь, и у двух сборщиков для него есть ловушки, о которые спотыкаются в первую же неделю.

живой пример

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

public class ToMapDemo {
    record User(String login, String city) {}

    public static void main(String[] args) {
        List<User> users = List.of(new User("anna", "Казань"), new User("boris", "Москва"), new User("vera", "Казань"));

        Map<String, User> byLogin = users.stream().collect(Collectors.toMap(User::login, u -> u));
        System.out.println(byLogin.keySet());

        Map<String, Long> perCity = users.stream().collect(Collectors.groupingBy(User::city, Collectors.counting()));
        System.out.println(perCity);

        try {
            users.stream().collect(Collectors.toMap(User::city, User::login));
        } catch (IllegalStateException e) {
            System.out.println("toMap: " + e.getMessage());
        }
        Map<String, String> loginsByCity = users.stream()
                .collect(Collectors.toMap(User::city, User::login, (a, b) -> a + "," + b, TreeMap::new));
        System.out.println(loginsByCity);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

toMap(ключ, значение) падает с IllegalStateException: Duplicate key, как только два элемента дали один ключ: в примере это два пользователя из Казани. Это правильное поведение, оно не даёт молча потерять данные, но к нему надо быть готовым: либо ключ уникален по построению (логин), либо третьим аргументом передают функцию слияния, которая говорит, что делать с дублями, а четвёртым можно задать тип карты. groupingBy дубли ждёт по определению и раскладывает элементы по спискам, а второй аргумент говорит, что сделать с каждой группой: counting() даёт число, summingInt(User::age) сумму, mapping(User::login, toList()) список полей вместо объектов. Пара groupingBy плюс counting это ровно GROUP BY с COUNT из SQL, и читать её стоит так же.

Параллельные стримы

Замените stream() на parallelStream(), и конвейер разложится по ядрам процессора. Это первое, что пробует новичок, увидев стримы, и первое, что ломает программу. Три вещи, которые надо знать до того, как нажать.

Порядок пропадает: forEach на параллельном стриме печатает элементы вперемешку, и если порядок важен, нужен forEachOrdered или toList, который порядок сохраняет за счёт лишней работы по склейке. Общий пул один на всю JVM: параллельные стримы выполняются в общем ForkJoinPool, и один долгий конвейер с чтением из базы внутри займёт его потоки, а соседний код, который тоже надеялся на параллельность, будет ждать. И выигрыш есть только на большой чисто вычислительной работе: на списке из ста элементов накладные расходы на разбиение и слияние съедают больше, чем экономят, а на ввод-вывод параллельный стрим вообще не рассчитан. Правило: по умолчанию stream(), а parallelStream() только после замера и только на данных, которые в памяти и считаются долго.

users.stream() groupingBy(city) 3 элемента anna, vera ключ «Казань» boris ключ «Москва»

Один поток разложен по корзинам: ключ решает, в какую корзину попадёт элемент, а в корзине лежит список всех попавших.

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

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

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

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

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

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

Коротко

  • Функциональный интерфейс — интерфейс с одним абстрактным методом; базовые: Function, Predicate, Consumer, Supplier.
  • Лямбда (args) -> тело — реализация такого интерфейса прямо на месте; захватываемые локальные переменные должны быть effectively final.
  • Method reference (String::length, System.out::println, ArrayList::new) — короткая форма лямбды, когда она лишь вызывает существующий метод.
  • Stream — конвейер: источник → промежуточные операции (filter, map, flatMap, sorted) → терминальная (collect, count, reduce); Collectors (groupingBy, joining) задают, что соберёт collect, а для простого списка есть toList().
  • Промежуточные операции ленивы: ничего не выполняется, пока не вызвана терминальная, и элементы идут по конвейеру по одному. Стрим одноразовый: второй раз по нему не пройти.
  • flatMap разворачивает вложенность (список списков, Optional::stream); sorted с Comparator.comparing сортирует по полю, а пустые значения раскладывает nullsLast.
  • Стрим — для трансформации данных; обычный цикл — для побочных эффектов, сложного управления и горячих участков.
  • Короткое замыкание (anyMatch, findFirst, limit) останавливает конвейер, не дочитывая источник; sorted и distinct его ломают. Операции без гарантии результата (findFirst, min, max, reduce без начального) возвращают Optional.
  • toMap падает на дубле ключа (Duplicate key), если не передать функцию слияния; groupingBy с counting/summingInt/mapping это GROUP BY из SQL.
  • parallelStream() теряет порядок в forEach, делит общий ForkJoinPool со всей программой и окупается только на большой вычислительной работе после замера.

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