Терминальные операции 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, которые не дают конвейеру пойти незаметно не так:
- Аккумулятор должен быть ассоциативным:
f(f(a, b), c) == f(a, f(b, c)). Суммы и конкатенация строк удовлетворяют этому; вычитание — нет. - Identity должен быть истинной единицей:
f(id, x) == xдля всехx.0для+,1для*,\"\"дляconcat. - Аккумулятор и 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+, unmodifiablestream.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.
Что следует вынести из запуска:
- «Поисковые» терминалы —
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, который их компонует.