Stream.peek: примеры (JAVA)

Работа метода peek в Stream API
Раздел: Потоки данных (Stream API) - промежуточные операции
Stream.peek(Consumer action): Stream

Описание и сигнатура

Метод peek интерфейса java.util.stream.Stream представляет собой промежуточную операцию, принимающую аргумент типа Consumer<? super T> и возвращающую тот же поток элементов (Stream<T>). Основное назначение метода - выполнить побочные действия по каждому элементу в процессе прохождения конвейера, например, логирование или отладочные проверки, не изменяя структуру потока.

Сигнатура:

default Stream<T> peek(Consumer<? super T> action)

Аргументы и поведение:

  • action - функция, принимающая элемент и выполняющая побочное действие. Может выбрасывать runtime-исключения; проверяемые исключения требует обработки внутри лямбды.
  • Операция является промежуточной и ленивой: исполнение action происходит только при выполнении терминальной операции.
  • Возвращаемое значение - тот же тип потока, содержащий исходные элементы без явного преобразования. Метод возвращает новый поток, но семантически это продолжение конвейера с добавленным побочным действием.
  • В параллельных потоках вызовы action могут выполняться в произвольном порядке и в разных потоках. Сохранение исходного порядка не гарантируется для неблокирующих параллельных операций, если не применены методы, гарантирующие порядок (например, forEachOrdered).
  • Использование peek для критически важных побочных эффектов не рекомендуется, так как эти эффекты могут не произойти при оптимизациях, отсутствии терминальной операции или при короткозамкнутых терминалах.

Типичные случаи применения: отладка и логирование содержимого потока, подсчёт промежуточных значений для мониторинга, неглубокие мутации объектов (с осторожностью), сбор диагностической информации без изменения результата конвейера.

Короткие иллюстрации применения

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

1) Простейший вывод элементов:

List<String> list = List.of("a", "b", "c");
list.stream()
    .peek(s -> System.out.println("peek: " + s))
    .collect(Collectors.toList());

Результат (stdout):

peek: a
peek: b
peek: c

2) peek до фильтра - все вызовы, затем фильтрация:

List<String> list = List.of("ab", "bc", "cd");
List<String> res = list.stream()
    .peek(s -> System.out.println("before filter: " + s))
    .filter(s -> s.startsWith("b"))
    .peek(s -> System.out.println("after filter: " + s))
    .collect(Collectors.toList());
System.out.println(res);

Результат (stdout):

before filter: ab
before filter: bc
before filter: cd
after filter: bc
[bc]

3) Короткозамкнутый терминал (findFirst) - не все элементы могут быть обработаны:

List<Integer> nums = List.of(1,2,3,4,5);
Integer firstEven = nums.stream()
    .peek(n -> System.out.println("seen: " + n))
    .filter(n -> n % 2 == 0)
    .findFirst()
    .orElse(null);
System.out.println(firstEven);

Возможный вывод:

seen: 1
seen: 2
2

4) Параллельный поток - порядок вызовов не гарантируется:

List<Integer> nums = IntStream.range(0,10).boxed().collect(Collectors.toList());
nums.parallelStream()
    .peek(n -> System.out.println(Thread.currentThread().getName() + ": " + n))
    .count();

Возможный вывод (фрагмент):

ForkJoinPool.commonPool-worker-3: 2
main: 0
ForkJoinPool.commonPool-worker-5: 5
... 

Аналоги в Java и их отличия

  • forEach - терминальная операция. Выполняет побочные действия, но завершает конвейер. Используется для финального потребления; нельзя продолжить работу с потоком после вызова.
  • forEachOrdered - терминал для параллельных потоков с сохранением порядка. Применяется, когда важен последовательный вывод при параллельном выполнении.
  • map - промежуточная операция для преобразования элементов. В отличие от peek возвращает новый поток с изменёнными элементами и предназначено именно для трансформации, а не для побочных эффектов.
  • onClose (Stream<T>#onClose) - регистрирует действие при закрытии потока. Выполняется при вызове close() или при использовании try-with-resources.

Рекомендации по выбору: если нужно изменить элементы - использовать map. Для окончательных операций и побочных эффектов при потреблении - forEach или forEachOrdered. Для временного логирования внутри конвейера без изменения данных - peek.

Параллели в других языках

Короткое сравнение аналогичных приемов в популярных языках и примеры.

  • JavaScript (Array): forEach и map. Для отладки чаще используют forEach или вставку console.log в map.
// JS
const arr = [1,2,3];
arr.map(x => { console.log('peek', x); return x; });
// Консоль:
// peek 1
// peek 2
// peek 3
[1,2,3]
  • Python: генераторы и выражения списков. Для «peek» используют генераторы с побочным действием или itertools. Прямого аналога нет, но часто применяют for в цепочке или вставляют вызов внутри генератора.
# Python
xs = [1,2,3]
res = [ (print('peek', x), x)[1] for x in xs ]
print(res)
peek 1
peek 2
peek 3
[1, 2, 3]
  • C#: LINQ использует Select для трансформации; эквивалентом для побочных эффектов служит Do из Rx или ForEach при окончательном перечислении. Для похожего поведения можно вставить побочный вызов внутрь Select.
// C# (LINQ)
var arr = new[]{1,2,3};
var res = arr.Select(x => { Console.WriteLine("peek " + x); return x; }).ToList();
// Консоль:
// peek 1
// peek 2
// peek 3
// res: [1,2,3]
  • Kotlin: у Sequence есть метод onEach, близкий по семантике к peek. Разница - синтаксис и наличие расширенной стандартной библиотеки.
// Kotlin
val seq = sequenceOf(1,2,3).onEach { println("peek $it") }.toList()
peek 1
peek 2
peek 3
[1, 2, 3]
  • Go, PHP, Lua и SQL обычно не имеют ленивых потоков в standard library с прямым эквивалентом. Там применяются явные циклы, массивные операции или функции высшего порядка (array_map, foreach). Отличие: в Java peek ленив и зависит от терминального вызова.

Взаимные отличия: в динамических языках побочные действия выполняются сразу при вызове, в Java с Stream API этот вызов ленив и может быть отложен или частично выполнен.

Частые ошибки и примеры

Ниже перечислены распространённые заблуждения и ошибки при использовании peek.

1) Ожидание выполнения без терминальной операции:

Stream.of(1,2,3).peek(System.out::println); // ничего не произойдёт

Результат: ничего не выведется, так как отсутствует терминал.

<пусто>

2) Использование для надёжных побочных эффектов (например, записи в базу) - риск пропуска операций:

AtomicInteger counter = new AtomicInteger();
Stream.of(1,2,3)
    .peek(n -> { if (n == 2) throw new RuntimeException("fail"); counter.incrementAndGet(); })
    .filter(n -> n > 0)
    .count();
System.out.println(counter.get());

Результат: в случае исключения или отсутствия терминала счётчик может не отразить ожиданий. Поведение зависит от порядка выполнения и от того, был ли терминал успешно выполнен.

3) Изменение неизменяемых ссылок или мутация в многопоточном контексте без синхронизации:

List<StringBuilder> builders = Arrays.asList(new StringBuilder("a"), new StringBuilder("b"));
builders.parallelStream()
    .peek(sb -> sb.append("X"))
    .forEach(System.out::println);

Результат: возможна гонка и непредсказуемый итог содержимого объектов.

4) Путаница с map и peek. Если требуется преобразование элементов, использование peek вместо map приводит к логическим ошибкам:

List<String> s = Stream.of("1","2")
    .peek(x -> x = x + "!" ) // эта операция не изменит поток
    .collect(Collectors.toList());
System.out.println(s);

Результат:

[1, 2]

Комментарии: в лямбде присваивание локальной переменной не влияет на элементы потока; для преобразования следует использовать map.

Эволюция метода

Метод peek введён в Java 8 вместе с Stream API и с тех пор не претерпел значительных изменений в сигнатуре или семантике. Основные замечания по истории:

  • Нет новых перегрузок или дополнительных параметров в последующих версиях Java.
  • Поведение в параллельных потоках и спецификация ленивости остались прежними.
  • Рекомендации по использованию не менялись: метод предназначен в первую очередь для отладки и диагностических целей.

Расширенные и редкие сценарии применения

Несколько продвинутых шаблонов использования peek с пояснениями.

1) Сбор метрик прохождения элементов (счётчики и тайминги):

Пример java
AtomicInteger seen = new AtomicInteger();
long total = IntStream.range(0,100)
    .boxed()
    .peek(i -> seen.incrementAndGet())
    .mapToInt(Integer::intValue)
    .sum();
System.out.println("sum=" + total + ", seen=" + seen.get());

Результат:

sum=4950, seen=100

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

2) Локальная отладка с условной печатью и стек-трейсом:

Пример java
List<String> data = List.of("ok","bad","ok");
List<String> res = data.stream()
    .peek(s -> { if (s.equals("bad")) new Exception("trace").printStackTrace(); })
    .collect(Collectors.toList());

Результат (фрагмент):

java.lang.Exception: trace
	at ...
[ok, bad, ok]

3) Комбинация с параллельным сбором статистики (с осторожностью):

Пример java
ConcurrentMap<Thread, AtomicInteger> map = new ConcurrentHashMap<>();
IntStream.range(0,1000).parallel().boxed()
    .peek(i -> map.computeIfAbsent(Thread.currentThread(), t -> new AtomicInteger()).incrementAndGet())
    .forEach(i -> {});
System.out.println(map.size());

Возможный результат:

8

Пояснение: подсчёт по потокам показывает степень параллелизма; стоит использовать потокобезопасные структуры.

4) Встраивание диагностики в сложный конвейер без изменения логики:

Пример java
Stream.of("a","bb","ccc")
    .filter(s -> s.length() > 1)
    .peek(s -> System.out.println("after filter len>1: " + s))
    .map(String::toUpperCase)
    .peek(s -> System.out.println("after map: " + s))
    .collect(Collectors.toList());

Результат:

after filter len>1: bb
after filter len>1: ccc
after map: BB
after map: CCC
[BB, CCC]

5) Нестандартное применение: временные мутации объектов-значений при подготовке к сбору (требует осторожности):

Пример java
List<StringBuilder> list = Arrays.asList(new StringBuilder("a"), new StringBuilder("b"));
List<String> out = list.stream()
    .peek(sb -> sb.append('-'))
    .map(StringBuilder::toString)
    .collect(Collectors.toList());
System.out.println(out);

Результат:

[a-, b-]

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

джава Stream.peek function comments

En
Stream.peek Returns a stream consisting of the elements, performing the provided action on each element