Stream.peek: примеры (JAVA)
Stream.peek(Consumer super T> 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) Сбор метрик прохождения элементов (счётчики и тайминги):
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) Локальная отладка с условной печатью и стек-трейсом:
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) Комбинация с параллельным сбором статистики (с осторожностью):
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) Встраивание диагностики в сложный конвейер без изменения логики:
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) Нестандартное применение: временные мутации объектов-значений при подготовке к сбору (требует осторожности):
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-]
Пояснение: мутация объектов возможна, но при параллельном выполнении и при наличии других ссылок на объекты это может привести к непредсказуемости.