W3docs

Промежуточные операции Java Stream

Ленивые преобразования потоков Java: filter, map, flatMap, sorted, distinct, peek, limit и skip.

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

Эта глава — обзор всех промежуточных операций. Каждая описывается по одной схеме: что делает, какой тип колбэка принимает, является ли она stateless или stateful, и один-два нюанса, определяющих корректность конвейера.

filter — оставить совпадающие элементы

Отбрасывает элементы, не удовлетворяющие Predicate<T>:

List<Integer> evens = nums.stream()
    .filter(n -> n % 2 == 0)
    .toList();

Stateless, ленивая, сохраняет порядок. Предикат должен быть свободен от побочных эффектов — если он мутирует что-либо видимое снаружи, параллельные конвейеры преподнесут сюрпризы, и даже последовательные станут трудночитаемыми.

filter не меняет тип элемента. Чтобы одновременно отфильтровать подмножество и изменить тип, используйте filter затем map, или mapMulti (Java 16+) для редкого случая, когда один входной элемент превращается в ноль-или-один элемент другого типа.

map — преобразовать каждый элемент

Применяет Function<T, R> к каждому элементу и возвращает поток из R:

List<Integer> lengths = words.stream()
    .map(String::length)
    .toList();

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

  • mapToInt, mapToLong, mapToDouble → примитивный поток (без boxing, доступен sum()).
  • mapToObj на примитивном потоке → обратно в Stream<R>.
int totalLength = words.stream().mapToInt(String::length).sum();

flatMap — заменить каждый элемент потоком других

Function<T, Stream<R>>, которая «разворачивает» каждый элемент в несколько выходных (или ни одного, или один):

List<List<String>> grouped = List.of(List.of("a", "b"), List.of("c"));
List<String> flat = grouped.stream()
    .flatMap(List::stream)
    .toList();                       // [a, b, c]

Ментальная модель: «каждый элемент становится подпотоком, а flatMap конкатенирует их». Именно так выполняется переход от потока контейнеров (Stream<List<T>>) к потоку содержимого (Stream<T>), именно так разбивается каждый текст на слова и именно так поток Optional<T> превращается в поток присутствующих значений (через Optional::stream).

Существуют и примитивные специализации — flatMapToInt, flatMapToLong, flatMapToDouble — для разворачивания в примитивный поток.

Частая ошибка: map(s -> s.split(" ")) даёт Stream<String[]> — поток массивов, а не плоский поток слов. Для выравнивания используйте flatMap(s -> Arrays.stream(s.split(" "))).

mapMulti — выдать ноль, один или несколько элементов на входной

mapMulti (Java 16+) — более эффективная альтернатива flatMap для случаев, когда каждый входной элемент порождает небольшое переменное количество выходных и создание отдельного Stream на элемент излишне:

people.stream()
    .<String>mapMulti((p, downstream) -> {
        if (p.age() >= 18) downstream.accept(p.name());
        if (p.email() != null) downstream.accept(p.email());
    })
    .forEach(System.out::println);

Используйте flatMap, когда у вас уже есть поток/список для эмиссии; используйте mapMulti, когда иначе пришлось бы создавать крошечный поток из одного-двух элементов на каждый входной только ради сигнатуры flatMap.

distinct — удалить дубликаты

Удаляет равные элементы с помощью equals / hashCode:

List<String> unique = words.stream().distinct().toList();

Stateful — чтобы определить, является ли элемент дубликатом, distinct должен помнить уже выданные элементы. В упорядоченном потоке сохраняется первое вхождение. В неупорядоченном потоке JVM может оптимизировать параллельную обработку. В бесконечном потоке distinct почти никогда не применяют без предшествующего limit.

sorted — упорядочить элементы

Две формы — естественный порядок и Comparator<T>:

List<String> az  = words.stream().sorted().toList();
List<String> byLen = words.stream().sorted(Comparator.comparingInt(String::length)).toList();

Stateful и блокирующий до конца: sorted должен буферизовать все элементы, прежде чем сможет выдать хотя бы один. Это делает его наиболее дорогостоящей промежуточной операцией, которую следует применять осознанно. Размещение sorted перед limit(n) не экономит работу — JVM всё равно должна просмотреть все входные данные, чтобы понять, какие n оставить. (Для конвейера «top N» лучше использовать ограниченную PriorityQueue или Collectors.toList() с последующим subList после sorted, в зависимости от N и общего числа элементов.)

Кроме того, не вызывайте sorted на потоке из бесконечного источника — он никогда не завершится.

peek — наблюдать без изменений

Consumer<T>, срабатывающий для каждого вытянутого элемента. Возвращает поток без изменений:

words.stream()
    .peek(s -> System.out.println("seen: " + s))
    .filter(s -> s.length() > 3)
    .toList();

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

Stream.iterate(1, n -> n + 1)
    .peek(n -> System.out.println("considered " + n))
    .filter(n -> n > 100)
    .findFirst();                        // pulls 1..101 -- peek fires 101 times, then stops

Не помещайте реальную логику в peek. JVM вправе слить, переупорядочить или пропустить вызовы peek при определённых условиях на немодифицированных потоках, а в параллельных потоках порядок не определён.

limit(n) — оставить не более n элементов

Останавливает конвейер после того, как через него пройдут n элементов:

List<Integer> firstFive = Stream.iterate(1, i -> i + 1).limit(5).toList();

Stateful (считает элементы) и с коротким замыканием (нижележащая часть останавливается, как только достигнуто n). В упорядоченном потоке оставляет первые n. В неупорядоченном параллельном потоке оставляет какие-то n — порядок не гарантирован, и параллельный limit на упорядоченном потоке несёт затраты на упорядочение. Если вам не важно, какие именно n получить, stream.unordered().limit(n) быстрее при параллельной обработке.

Стандартный шаблон укрощения любого бесконечного источника: каждый Stream.iterate / Stream.generate завершается либо limit, либо трёхаргументным ограниченным iterate, либо терминальной операцией с коротким замыканием типа findFirst.

skip(n) — пропустить первые n

Дополнение к limit. Пропускает первые n элементов, затем выдаёт остальные:

List<Integer> rest = nums.stream().skip(2).toList();   // drops nums[0], nums[1]

Stateful (ведёт обратный отсчёт). В упорядоченном потоке смысл точный; в параллельном упорядоченном потоке несёт затраты на упорядочение. Вместе с limit обеспечивает постраничный доступ:

list.stream().skip(page * pageSize).limit(pageSize).toList();

Это работает, но для большого skip над List сложность всё равно O(skip + limit). Прямой list.subList(...) дешевле, если List уже есть под рукой.

takeWhile / dropWhile — оконный просмотр по префиксу

Две промежуточные операции с коротким замыканием (Java 9+), действующие на префикс потока:

// take elements while predicate holds, stop at the first miss
List<Integer> small = Stream.of(1, 2, 3, 10, 4, 5)
    .takeWhile(n -> n < 5)
    .toList();                                // [1, 2, 3]

// drop elements while predicate holds, then emit the rest
List<Integer> rest = Stream.of(1, 2, 3, 10, 4, 5)
    .dropWhile(n -> n < 5)
    .toList();                                // [10, 4, 5]

Это не filter. filter проверяет каждый элемент. takeWhile останавливается при первом несоответствии (включая элементы, которые прошли бы filter позже). На отсортированном потоке они позволяют дёшево выразить «всё до порогового значения».

boxed / asLongStream / asDoubleStream — переход между примитивными мирами

Примитивные потоки имеют несколько собственных промежуточных операций для возврата в объектный мир:

IntStream.range(0, 5).boxed().toList();           // Stream<Integer> [0, 1, 2, 3, 4]
IntStream.range(0, 3).asLongStream().sum();        // 0L + 1L + 2L
IntStream.range(0, 3).asDoubleStream().average();

boxed — мост от примитива к Stream<Integer/Long/Double>. Обратный путь — mapToInt/mapToLong/mapToDouble.

Stateless vs. stateful — почему это важно

StatelessStateful
filterdistinct
map / mapToXsorted
flatMap / mapMultilimit
peekskip
boxed / asLongStream / asDoubleStreamtakeWhile / dropWhile

Stateful-операции должны помнить что-то между элементами. sorted вынужден буферизовать всё. distinct должен помнить каждый выданный элемент. limit и skip нужен счётчик. Это делает их дороже (особенно при параллельной обработке) и требует осознанного применения.

Порядок важен — ранняя фильтрация, позднее преобразование

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

// Good: filter first, then the expensive map runs only on survivors.
people.stream()
    .filter(p -> p.age() >= 18)
    .map(this::expensiveLookup)
    .toList();

// Bad: every element pays for the map, then most are thrown away.
people.stream()
    .map(this::expensiveLookup)
    .filter(r -> r.score() > 0.5)
    .toList();

Общее правило: фильтруй рано, преобразуй поздно, сортируй один раз, удаляй дубликаты один раз. JVM не переупорядочивает ваши промежуточные операции — это делаете вы.

Разбор примера: весь словарный запас в одном конвейере

Программа ниже строит поток из небольшого списка, проходит через все рассмотренные операции, выводит результат каждой и доказывает ленивость/короткое замыкание с помощью peek и бесконечного iterate.

java— editable, runs on the server

Что важно из этого запуска:

  • filter и map — рабочие лошадки; остальные промежуточные операции «один-в-один» (mapToInt, mapToObj, boxed) — дешёвые конверсии между объектными и примитивными потоками.
  • flatMap и mapMulti — это то, как один входной элемент становится несколькими выходными. Конструкция Stream.of("a b") -> Arrays.stream(split(...)) является каноническим шаблоном токенизации; mapMulti — более дешёвый выбор, когда иначе пришлось бы создавать крошечный поток на элемент.
  • distinct и sorted являются statefuldistinct должен был помнить каждый ранее выданный Person, чтобы отбросить дубликат «Alice», а sorted вынужден был буферизовать весь входной поток. Именно поэтому обе операции применяются осознанно, как правило один раз и обычно ближе к концу.
  • peek срабатывал один раз для каждого вытянутого элемента бесконечного iterate — строк «considered N» было ровно столько, сколько элементов findFirst пришлось рассмотреть. Без короткого замыкания этот конвейер никогда бы не завершился.
  • Два блока подсчёта вызовов lookup в конце сделали правило порядка наглядным. Фильтрация сначала запустила дорогостоящее преобразование на гораздо меньшем числе элементов, чем преобразование до фильтрации. Этот компромисс — ваш выбор.

Что дальше

Промежуточные операции фиксируют форму работы; ничего не выполняется, пока терминальная операция не начнёт вытягивать элементы. Следующая глава, Java Stream Terminal Operations, содержит полный словарь терминальных операций — forEach, count, min/max, findFirst/findAny, anyMatch/allMatch/noneMatch, reduce, toArray, toList и переход к следующей главе — collect.

Практика

Практика
В каком конвейере `sorted` вынужден буферизовать *каждый* входной элемент, прежде чем сможет выдать хотя бы один выходной?
В каком конвейере `sorted` вынужден буферизовать *каждый* входной элемент, прежде чем сможет выдать хотя бы один выходной?
Was this page helpful?