сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Stream API

Операции Stream API

Stream API: конвейер, который сам решает, что не выполнять

Собрал стрим из трёх строк, поставил peek(seen::add) - хук, который просто складывает проходящие элементы в список, - и позвал count(). Результат: count вернул 3, а список остался ПУСТОЙ. Добавил в цепочку filter - и тот же peek вдруг увидел все три элемента.

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

// Формулировки: «чем промежуточные операции отличаются от терминальных?», «map или flatMap?», «можно ли пройти по стриму дважды?».

Ленивость: ничего не происходит до терминальной операции

Разберём тот замер. Конвейер устроен так: источник, затем промежуточные операции (filter, map, sorted и другие), затем ровно ОДНА терминальная (count, collect, forEach). Промежуточная операция ничего не выполняет, она только дописывает шаг в план; запускает всё терминальная. Собрал цепочку и не поставил терминальную - не выполнилось вообще ничего, я это тоже проверил: peek промолчал.

Почему count() пропустил peek: чтобы посчитать элементы, ему хватило размера источника - список знает свою длину, гонять элементы незачем. А как только появился filter, размер стал заранее неизвестен (фильтр может выбросить что угодно), и элементы честно потекли через peek. Отсюда правило: peek годится для отладки, но логику в него класть нельзя, она может не выполниться.

Ленивость даёт и короткое замыкание - остановку, как только ответ уже ясен. Я взял миллион чисел по порядку и искал первое больше 500 000 через findFirst: конвейер просмотрел 500 001 элемент и остановился, а не миллион.

var seen = new ArrayList<String>();
long c = list.stream().peek(seen::add).count();
// c = 3, seen = []            - элементы не текли

long c2 = list.stream().peek(seen::add).filter(s -> true).count();
// c2 = 3, seen = [a, b, c]    - размер стал неизвестен, потекли
промежуточная / терминальная
шаг плана, ничего не выполняет / запуск всего конвейера
короткое замыкание
остановка, как только ответ известен: findFirst, anyMatch, limit

takeWhile против filter: разница в тысячи раз

Взял миллион чисел по возрастанию и посчитал, сколько из них выбирает условие «меньше ста», двумя способами. Оба дали 99. Но takeWhile просмотрел 100 элементов, а filter - все 1 000 000.

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

// Рядом живёт dropWhile: он наоборот выбрасывает начальный кусок, пока условие выполняется, и отдаёт весь остаток. Обе операции появились в Java 9.

sorted.stream().takeWhile(x -> x < 100).count();
// результат 99, просмотрено 100

sorted.stream().filter(x -> x < 100).count();
// результат 99, просмотрено 1 000 000
takeWhile / dropWhile
взять начальный кусок по условию / отбросить его

map, flatMap и одноразовость стрима

map преобразует один в один: был элемент - стал элемент. flatMap применяют, когда функция возвращает не значение, а целый стрим значений: все эти стримы склеиваются в один плоский поток. Практический признак: если после map получился Stream<List<Позиция>>, а нужен Stream<Позиция>, значит нужен был flatMap.

Стрим одноразовый. Позвал терминальную операцию во второй раз на том же объекте - получил IllegalStateException с текстом stream has already been operated upon or closed. Нужно пройтись дважды - создавай новый стрим из источника, это дёшево. Сам источник при этом цел: стримы его не меняют, filter возвращает новые данные.

// А вот менять источник во время обхода нельзя. Я добавил элемент в список прямо внутри forEach по этому же списку и получил ConcurrentModificationException. Иногда вместо исключения выходит тихий мусор - поведение тут не определено, полагаться не на что.

flatMap
функция вернула стрим, все стримы склеились в один
одноразовость
вторая терминальная операция на том же стриме бросает исключение

Как отвечать: «Чем map отличается от flatMap?»

map преобразует один к одному: применил функцию, получил ровно один элемент на каждый входной. flatMap нужен, когда функция возвращает не значение, а стрим значений: каждый элемент разворачивается в поток, и все потоки склеиваются в один плоский. Практический признак у меня такой: если после map получился Stream<List<Item>>, а нужен Stream<Item> - это сигнал заменить на flatMap(list -> list.stream()). Тот же приём работает у Optional: flatMap для функций, которые сами возвращают Optional, иначе получается матрёшка - я проверял, map(Optional::of) честно даёт Optional[Optional[Казань]], а flatMap разворачивает до Optional[Казань]. Суть одна: map кладёт результат в контейнер как есть, flatMap распаковывает вложенный контейнер.

Почему это сильный ответ: правило дано через практический признак с типом-матрёшкой, и показано, что приём общий для Stream и Optional - это понимание идеи, а не заучивание одного метода.

На чём валят

  • Конвейер без терминальной операции. «Стрим не работает» - он и не запускался, промежуточные операции только строят план.
  • Логика внутри peek. Под count() без фильтров он у меня не выполнился ни разу: список остался пустым.
  • Вторая терминальная операция на том же стриме. IllegalStateException, стрим одноразовый.
  • Менять список внутри его же стрима. ConcurrentModificationException или тихая порча данных.
  • sorted() и distinct() на бесконечном источнике. Им нужно увидеть все элементы, поэтому конвейер копит поток в памяти до отказа.

Проверьте себя

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

  1. #stream_operations1 / 5
    Что такое короткое замыкание (short-circuit) операции стрима?
    A)Автоматическое прекращение стрима при первом же исключении в одной из промежуточных операций
    B)Оптимизация, при которой стрим выполняет все операции параллельно и берёт первый готовый результат
    C)Терминал/операция может завершиться, не обработав все элементы (findFirst, anyMatch, limit)
    D)Механизм, замыкающий два стрима в кольцо, чтобы элементы бесконечно циркулировали между ними
    показать ответ и разбор
    +C)Терминал/операция может завершиться, не обработав все элементы (findFirst, anyMatch, limit)

    // разбор: Короткозамыкающие операции могут дать результат, не обработав ВЕСЬ источник: anyMatch/allMatch/noneMatch (останавливаются, как только ответ ясен), findFirst/findAny, а из промежуточных — limit (берёт первые N). Благодаря ленивости стрим протягивает элементы по одному и прекращает, как только достаточно. Это, в частности, позволяет работать с БЕСКОНЕЧНЫМИ стримами (iterate/generate) при наличии limit/findFirst. Без короткого замыкания такие стримы висли бы вечно.

  2. #stream_operations2 / 5
    Изменяет ли stream() исходную коллекцию?
    A)Да, операции map и filter изменяют элементы прямо в исходной коллекции на месте, экономя память
    B)Да, sorted() сортирует исходный список, поэтому после стрима его порядок оказывается изменённым
    C)Зависит от терминальной операции: collect не меняет источник, а forEach его перезаписывает
    D)Нет — стрим не мутирует источник, а порождает новый результат
    показать ответ и разбор
    +D)Нет — стрим не мутирует источник, а порождает новый результат

    // разбор: Стрим НЕ модифицирует источник: filter/map/sorted и т.д. строят новый результат, а исходная коллекция остаётся как была (в отличие от, например, Collections.sort, которая сортирует список на месте). Изменять источник во время работы стрима вообще нельзя — это ведёт к неопределённому поведению/CME. Функциональный стиль: на входе — данные, на выходе — НОВЫЙ результат, без побочных эффектов на источник. Побочные эффекты в лямбдах стрима (особенно параллельного) — антипаттерн.

  3. #stream_operations3 / 5
    Что делает distinct() и на чём основано сравнение элементов?
    A)Убирает дубликаты, сравнивая элементы по equals/hashCode
    B)Убирает дубликаты, сравнивая элементы по ссылкам через ==, поэтому равные по значению остаются
    C)Сортирует элементы и удаляет только соседние повторы, идущие подряд в отсортированном порядке
    D)Оставляет только элементы, встретившиеся более одного раза, отбрасывая уникальные значения
    показать ответ и разбор
    +A)Убирает дубликаты, сравнивая элементы по equals/hashCode

    // разбор: distinct() оставляет по одному экземпляру каждого элемента, определяя равенство через equals (и hashCode). Поэтому у объектов должны быть корректно переопределены эти методы, иначе «одинаковые» объекты не схлопнутся. В последовательном стриме сохраняется порядок первых вхождений; в параллельном distinct дороже (нужна согласованность). Для распознавания дубликатов по конкретному полю обычно применяют приём с filter + Set (seen) или toMap/groupingBy по ключу.

  4. #stream_operations4 / 5
    Чем отличаются sorted() и sorted(Comparator) и когда они выполняются?
    A)Sorted() сортирует по убыванию, а sorted(Comparator) — по возрастанию, аргумент задаёт направление
    B)Без аргумента — естественный порядок (Comparable); это ленивая, но stateful-операция
    C)Sorted() выполняется немедленно при вызове, а sorted(Comparator) откладывается до терминальной операции
    D)Sorted() работает только с числами, а для объектов обязателен sorted(Comparator) с явным правилом
    показать ответ и разбор
    +B)Без аргумента — естественный порядок (Comparable); это ленивая, но stateful-операция

    // разбор: sorted() сортирует по естественному порядку (элементы должны быть Comparable, иначе ClassCastException в рантайме), sorted(Comparator) — по заданному правилу. Обе — промежуточные и ленивые, НО «с состоянием» (stateful): чтобы отсортировать, стрим обязан СНАЧАЛА собрать все элементы, поэтому они ломают чистую поэлементную конвейерность и не работают на бесконечных стримах без предварительного limit. Это стоит памяти на буфер. Дешевле сортировать после filter, уменьшив число элементов.

  5. #stream_operations5 / 5
    Что делают limit(n) и skip(n)?
    A)Limit оставляет каждый n-й элемент, а skip удаляет каждый n-й, прореживая стрим с заданным шагом
    B)Limit ограничивает время выполнения стрима n миллисекундами, а skip задерживает старт на n мс
    C)limit берёт первые n элементов, skip пропускает первые n
    D)Limit и skip делают одно и то же — обрезают стрим до n элементов, отличаясь лишь стороной обрезки массива
    показать ответ и разбор
    +C)limit берёт первые n элементов, skip пропускает первые n

    // разбор: limit(n) оставляет первые n элементов и является короткозамыкающей — прекращает обработку, набрав n (позволяет усечь бесконечный стрим). skip(n) отбрасывает первые n и пропускает дальше остаток. Вместе (skip(a).limit(b)) их используют для пагинации. На УПОРЯДОЧЕННОМ стриме «первые n» детерминированы; на неупорядоченном/параллельном порядок может быть любым, а limit/skip дороже (нужна координация). Оба — промежуточные операции.

дальше

Теорию прочитали. Навык ставится повторением

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