Урок 7 из 11 базовый около 40 мин

Stream API: от цикла к конвейеру данных

Фильтруем, преобразуем и группируем коллекции конвейерами, разбираем Collectors, ленивость и типичные ошибки новичков.

От цикла к конвейеру

Большинство циклов в прикладном коде делают одно и то же: пройти по коллекции, что-то отфильтровать, что-то преобразовать и собрать результат. Stream API (в языке с Java 8, но в Java 21 заметно удобнее) описывает такие операции как конвейер, где каждый шаг говорит, что делается, а не как.

record Student(String name, int age, String city) {}

var students = java.util.List.of(
        new Student("Анна", 17, "Москва"),
        new Student("Борис", 19, "Казань"),
        new Student("Вера", 16, "Москва"),
        new Student("Глеб", 21, "Тула"));

// Цикл
var namesLoop = new java.util.ArrayList<String>();
for (var s : students) {
    if (s.age() < 18) {
        namesLoop.add(s.name().toUpperCase());
    }
}

// Конвейер
var names = students.stream()
        .filter(s -> s.age() < 18)
        .map(s -> s.name().toUpperCase())
        .toList();
System.out.println(names);   // [АННА, ВЕРА]

Метод toList() появился в Java 16 и возвращает неизменяемый список. Он заменил громоздкое collect(Collectors.toList()) в большинстве случаев.

Три вида операций

  • Источник: collection.stream(), Stream.of(...), IntStream.range(0, 10), Files.lines(path), text.lines().
  • Промежуточные: filter, map, sorted, distinct, limit, skip, flatMap, peek. Они ленивые: ничего не происходит, пока не вызвана терминальная операция.
  • Терминальные: toList, collect, forEach, count, sum, max, findFirst, anyMatch, reduce. После них поток использовать нельзя.

Ленивость означает, что filter и map применяются к элементам по одному, а не проходят по всей коллекции по очереди. Поэтому limit(3) после дорогого map действительно вычислит только три элемента.

Группировка и подсчёты

Самая частая практическая задача — разложить элементы по корзинам. Для этого есть Collectors.groupingBy, и его результат — обычная Map:

import java.util.stream.Collectors;

var byCity = students.stream()
        .collect(Collectors.groupingBy(Student::city));
// {Казань=[Student[...]], Тула=[...], Москва=[Student[Анна], Student[Вера]]}

var countByCity = students.stream()
        .collect(Collectors.groupingBy(Student::city, Collectors.counting()));
// {Казань=1, Тула=1, Москва=2}

var averageAgeByCity = students.stream()
        .collect(Collectors.groupingBy(Student::city, Collectors.averagingInt(Student::age)));
// {Казань=19.0, Тула=21.0, Москва=16.5}

Порядок ключей в комментариях — тот, что выдаёт JDK 21 на этих данных, но полагаться на него нельзя: groupingBy по умолчанию собирает результат в HashMap, а она не обещает никакого порядка. Второй аргумент groupingBy — коллектор, который применяется к каждой группе. Кроме counting и averagingInt пригодятся mapping, summingInt, maxBy и joining. Если нужен предсказуемый порядок ключей, передайте фабрику карты: groupingBy(Student::city, TreeMap::new, counting()).

Сортировка и поиск

import java.util.Comparator;

var oldestFirst = students.stream()
        .sorted(Comparator.comparingInt(Student::age).reversed()
                .thenComparing(Student::name))
        .map(Student::name)
        .toList();   // [Глеб, Борис, Анна, Вера]

var firstFromMoscow = students.stream()
        .filter(s -> s.city().equals("Москва"))
        .findFirst();   // Optional[Student[name=Анна, ...]]

boolean anyAdult = students.stream().anyMatch(s -> s.age() >= 18);   // true

Результат findFirst и maxOptional, потому что поток может оказаться пустым. Как с ним работать, разобрано в следующем уроке.

Числовые потоки

Для чисел есть IntStream, LongStream и DoubleStream без упаковки в объекты. Они умеют sum, average, summaryStatistics:

var stats = students.stream().mapToInt(Student::age).summaryStatistics();
System.out.println(stats.getMin() + " … " + stats.getMax() + ", среднее " + stats.getAverage());
// 16 … 21, среднее 18.25

int sumOfSquares = java.util.stream.IntStream.rangeClosed(1, 10)
        .map(n -> n * n)
        .sum();   // 385

flatMap: списки внутри списков

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

record Course(String title, java.util.List<String> tags) {}

var courses = java.util.List.of(
        new Course("Java", java.util.List.of("код", "backend")),
        new Course("SQL", java.util.List.of("данные", "backend")));

var uniqueTags = courses.stream()
        .flatMap(c -> c.tags().stream())
        .distinct()
        .sorted()
        .toList();   // [backend, данные, код]

Три ошибки новичков

  • Изменять внешнюю переменную внутри forEach или map. Конвейер должен быть чистым; для накопления есть коллекторы и reduce.
  • Использовать поток дважды. Второй вызов терминальной операции бросит IllegalStateException. Создайте новый поток от источника.
  • Ставить parallel() «для скорости». На маленьких коллекциях это медленнее, а с изменяемым состоянием ещё и неверно. Параллельные потоки — для больших массивов чистых вычислений.

Когда цикл лучше

Если в теле цикла есть ранний выход с сложным условием, обработка исключений или несколько результатов сразу, обычный for часто читается проще. Конвейер хорош, когда шаги независимы и каждый умещается в одну строку.

Попробуйте сами

  1. По списку students постройте Map<String, List<String>> «город → имена», используя groupingBy и mapping.
  2. Из текста, разбитого на слова, получите пять самых частых слов. Подсказка: groupingBy(w -> w, counting()), затем поток по entrySet(), сортировка по значению в обратном порядке и limit(5).
  3. Перепишите один из своих старых циклов конвейером и сравните, стало ли понятнее. Если нет — оставьте цикл и запишите, почему.