W3docs

Терминальные операции Java Stream

Запуск конвейера потока в Java с помощью терминальных операций: collect, forEach, reduce, count, min, max, anyMatch.

Терминальная операция — это то, что заставляет конвейер потока действительно выполниться. Промежуточные операции (filter, map, sorted, …) только записывают работу и остаются ленивыми; терминальная операция тянет элементы через конвейер, вычисляет весь конвейер и производит результат (или побочный эффект). Каждый конвейер заканчивается ровно одной терминальной операцией — вызовите её, и поток будет потреблён; вызовите другую терминальную операцию на том же потоке — и вы получите IllegalStateException.

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

Терминальные операции бывают трёх форм. Агрегаторы возвращают единственное значение (count, sum, min, max, reduce). Поисковики ищут один элемент и останавливаются (findFirst, findAny, anyMatch, allMatch, noneMatch). Строители материализуют поток в контейнер (toList, toArray, collect, forEach для побочных эффектов). Эта глава охватывает каждую терминальную операцию, которую вам придётся писать, за исключением collect, которая достаточно велика, чтобы потребовать отдельной главы.

forEach / forEachOrdered — побочные эффекты

Простейшая терминальная операция. Запускает Consumer<T> для каждого элемента, ничего не возвращает:

names.stream().forEach(System.out::println);

Порядок не гарантирован — на последовательном потоке он обычно соблюдается; на параллельном — нет. Если вам нужен исходный порядок даже в параллельном режиме, используйте forEachOrdered:

names.parallelStream().forEachOrdered(System.out::println);

forEach предназначен для побочных эффектов, которые вам действительно нужны — логирование, изменение приёмника, вызов стороннего API. Это не правильный способ построить коллекцию (для этого есть toList / collect) или накопить значение (для этого есть reduce). forEach, мутирующий внешний список, — это запах кода, даже когда он работает, потому что вы теряете всё то, что сделало конвейер декларативным.

count — количество элементов

Возвращает long:

long adults = people.stream().filter(p -> p.age() >= 18).count();

count выполняет короткое замыкание на ограниченных источниках, где JVM может вычислить ответ из размера источника (поэтому IntStream.range(0, 1_000_000).count() возвращает 1000000 без итерации). На потоке с активным filter или flatMap он должен пройти по каждому элементу.

Распространённая ловушка: stream.count() в цепочке с .peek(...) может не запустить peek, если JVM может доказать количество из источника, поскольку нет наблюдаемой разницы в поведении. Не используйте peek, чтобы «посмотреть, сколько было отфильтровано» — используйте mapToInt(x -> 1).sum() или измените структуру.

min / max — крайние элементы

Оба принимают Comparator<T> и возвращают Optional<T> (поскольку поток может быть пустым):

Optional<Person> oldest  = people.stream().max(Comparator.comparingInt(Person::age));
Optional<String> shortest = words.stream().min(Comparator.comparingInt(String::length));

Примитивные специализации проще — IntStream.max() возвращает OptionalInt, компаратор не нужен:

OptionalInt highest = nums.stream().mapToInt(Integer::intValue).max();
int hi = highest.orElse(Integer.MIN_VALUE);

min/max выполняют короткое замыкание только на ограниченных источниках. На бесконечном потоке max никогда не завершится.

findFirst / findAny — получить один элемент

Оба возвращают Optional<T>, оба выполняют короткое замыкание. Разница — в том, какой элемент они обещают вернуть:

Optional<Person> first = people.stream().filter(p -> p.age() >= 30).findFirst();
Optional<Person> any   = people.stream().filter(p -> p.age() >= 30).findAny();
  • findFirst возвращает первый элемент в порядке встречи. На последовательном потоке это буквально первый. На параллельном потоке это обходится дороже, чем findAny, потому что JVM должна координировать.
  • findAny возвращает какой-нибудь подходящий элемент — первый, который найдёт любой рабочий поток. В параллельном режиме это дешевле. В последовательном оба возвращают одно и то же.

Используйте findAny, когда какое именно совпадение вы получаете, действительно не важно (это единственная проверка существования, которой нужно значение, а не просто boolean). Используйте findFirst, когда вы имеете в виду «первый».

anyMatch / allMatch / noneMatch — квантификаторы существования

Принимают Predicate<T> и возвращают boolean. Все три выполняют короткое замыкание:

boolean hasAdult  = people.stream().anyMatch(p -> p.age() >= 18);
boolean allAdult  = people.stream().allMatch(p -> p.age() >= 18);
boolean noChildren = people.stream().noneMatch(p -> p.age() < 13);
  • anyMatch останавливается, как только один элемент проходит.
  • allMatch останавливается, как только один элемент не проходит.
  • noneMatch — это !anyMatch(p) — останавливается при первом совпадении и возвращает false.

Семантика пустого потока (правило, которое один раз сбивает с толку всех): anyMatch на пустом потоке возвращает false. allMatch и noneMatch на пустом потоке оба возвращают true — вакуумно, потому что нет контрпримеров. Это может быть именно тем, что вам нужно, или именно тем, что вам не нужно, в зависимости от вопроса. Если «пустой» — это возможность, которую стоит обрабатывать, сначала проверьте isEmpty (или count() == 0).

reduce — свёртка к единственному значению

Наиболее общий агрегатор. Три перегрузки, каждая для немного разной формы:

reduce(identity, accumulator) с двумя аргументами — свёртка с начальным значением, возвращает T (без Optional, потому что identity — это ответ для пустого потока):

int sum = nums.stream().reduce(0, Integer::sum);
String all = words.stream().reduce(\"\", String::concat);

reduce(accumulator) с одним аргументом — без identity; возвращает Optional<T> для случая пустого потока:

Optional<Integer> sum = nums.stream().reduce(Integer::sum);
Optional<String> longest = words.stream()
    .reduce((a, b) -> a.length() >= b.length() ? a : b);

reduce(identity, accumulator, combiner) с тремя аргументами — используется, когда аккумулятор производит тип, отличный от типа элементов (и обязателен в параллельном режиме). combiner объединяет два частичных результата:

int totalLength = words.stream()
    .reduce(0,
            (acc, w) -> acc + w.length(),     // BiFunction<Integer, String, Integer>
            Integer::sum);                     // BinaryOperator<Integer>

Три правила для reduce, которые не дают конвейеру пойти незаметно не так:

  1. Аккумулятор должен быть ассоциативным: f(f(a, b), c) == f(a, f(b, c)). Суммы и конкатенация строк удовлетворяют этому; вычитание — нет.
  2. Identity должен быть истинной единицей: f(id, x) == x для всех x. 0 для +, 1 для *, \"\" для concat.
  3. Аккумулятор и combiner должны быть без состояния и без побочных эффектов.

Нарушьте любое из этих правил, и последовательный конвейер по-прежнему будет давать правильный ответ большую часть времени — параллельный вас удивит. (Это тот же контракт, на который опираются Collectors.reducing и параллельный reduce.)

sum / average — примитивные агрегаторы

Только для примитивных потоков. sum возвращает примитив; average возвращает OptionalDouble:

int total      = IntStream.rangeClosed(1, 100).sum();
OptionalDouble avg = nums.stream().mapToInt(Integer::intValue).average();
double mean = avg.orElse(0.0);

Для более богатых числовых сводок — count, sum, min, max, average за один проход — см. IntSummaryStatistics:

IntSummaryStatistics stats = nums.stream().mapToInt(Integer::intValue).summaryStatistics();
System.out.println(stats);   // {count=N, sum=..., min=..., average=..., max=...}

Это один проход, одно выделение памяти и намного дешевле, чем вычислять каждое значение отдельно.

toArray и toList — материализация

Два сокращённых терминала «дай мне всё»:

Object[] anyArr = stream.toArray();                     // Object[]
String[] strArr = stream.toArray(String[]::new);        // typed via constructor ref
List<String> immutable = stream.toList();               // Java 16+, unmodifiable

stream.toList() (Java 16+) — это современный способ материализовать поток в List и правильный выбор в 95% случаев. Он неизменяем и может содержать null; если вам нужен изменяемый список, конкретная реализация или Set/Map, вернитесь к collect(Collectors.toCollection(ArrayList::new)) или его аналогам в следующей главе.

toArray(T[]::new) — единственный способ получить типизированный массив из потока объектов — форма IntFunction<T[]> даёт среде выполнения тип компонента массива.

iterator и spliterator — аварийные выходы

Поток можно превратить в Iterator<T> или Spliterator<T> для передачи коду, который их ожидает:

for (Iterator<String> it = stream.iterator(); it.hasNext(); ) {
    use(it.next());
}

Оба являются терминальными операциями — они потребляют поток. Они существуют для совместимости, а не для «я хочу цикл for»; если вам нужен цикл, используйте его без создания потока.

Короткое замыкание против потребления — таблица безопасности

Терминальная операцияКороткое замыкание на бесконечном источнике?
findFirst / findAnyда
anyMatch / allMatch / noneMatchда
limit(n) (промежуточная) затем что угоднода
forEach / forEachOrderedнет — потребляет всё
countнет — потребляет всё
min / maxнет — потребляет всё
reduceнет — потребляет всё
sum / average / summaryStatisticsнет — потребляет всё
toList / toArray / collectнет — потребляет всё

Закономерность очевидна: любая терминальная операция, которой нужно рассмотреть каждый элемент для получения ответа, не выполняет короткое замыкание, и сочетание её с бесконечным источником без limit выше по конвейеру зависит в JVM. Поисковики и квантификаторы — единственные «безопасные для бесконечных потоков» терминальные операции.

Рабочий пример: все формы терминальных операций на одном конвейере

Программа ниже строит небольшой поток, вызывает каждую терминальную операцию, которую мы рассмотрели, и показывает ответы для пустого потока для трёх матчеров и для min / findFirst / reduce.

java— editable, runs on the server

Что следует вынести из запуска:

  • «Поисковые» терминалы — findFirst, findAny, anyMatch, allMatch, noneMatch — и «потребляющие всё» терминалы — count, min/max, reduce, sum, toList — чётко делят главу. Поисковые терминалы выполняют короткое замыкание; потребляющие всё — нет. Сочетайте вторую группу с бесконечным источником только за limit.
  • allMatch на пустом потоке вернул true. Так же сделал noneMatch. Это вакуумная истина — стандартный ответ, и это наиболее распространённая причина того, что продуктовый код «неправильно проходит» граничный случай с пустым вводом. Если пустое значимо, проверьте его сначала.
  • Три перегрузки reduce покрывают три паттерна. Двухаргументный с истинным identity возвращает T. Одноаргументный возвращает Optional<T>, потому что нет identity. Трёхаргументный позволяет типу аккумулятора отличаться от типа элемента — и это форма, которая действительно безопасна в параллельном режиме, потому что combiner говорит JVM, как объединять частичные результаты.
  • summaryStatistics() сделал за один проход то, что вызовы min, max, sum, average и count по отдельности сделали бы за пять. На любом нетривиальном числовом потоке предпочитайте его.
  • toList() вернул неизменяемый список. Это значение по умолчанию в Java 16+ и почти всегда то, что вам нужно; следующая глава показывает форму Collectors.toCollection(...) для случаев, когда вам нужен изменяемый список, конкретная реализация или Set / Map.

Что дальше

collect — единственная терминальная операция, которую мы отложили, и ворота к половине API. Следующая глава, Java Stream Collectors, охватывает инструментарий Collectors: toList/toSet/toMap, groupingBy, partitioningBy, joining, counting, summingInt, averagingDouble, mapping, reducing и паттерн downstream, который их компонует.

Практика

Практика
Поток содержит ноль элементов. Что из этого возвращает `true`?
Поток содержит ноль элементов. Что из этого возвращает `true`?
Was this page helpful?