1. Метод forEach: финальное действие над элементами
Перед тем как нырять в детали, давайте ещё раз вспомним, что такое терминальные и промежуточные операции потока.
- Промежуточные операции (например, filter, map, distinct, peek) — возвращают новый поток и обычно не выполняют действий до вызова терминальной операции.
- Терминальные операции (например, forEach, collect, count, anyMatch) — запускают обработку элементов потока и возвращают результат (или ничего не возвращают, как forEach).
После терминальной операции поток считается закрытым, и к нему больше нельзя применять другие операции. Это как пытаться доесть уже съеденное мороженое — не получится: поток уже «использован».
Теперь познакомимся с двумя важными методами для работы с побочными действиями: forEach и peek.
Что делает forEach?
forEach — это терминальная операция потока, которая выполняет заданное действие для каждого элемента потока. Обычно используется для вывода на экран, записи в лог, подсчёта статистики и других побочных эффектов (side effects).
Сигнатура метода:
void forEach(Consumer<? super T> action)
Consumer<T> — это функциональный интерфейс, принимающий один аргумент и ничего не возвращающий (например, System.out::println).
Пример: Вывести все элементы списка
Допустим, у нас есть список имён пользователей:
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
Вывести всех пользователей на экран с помощью Stream API очень просто:
users.stream().forEach(System.out::println);
Результат:
Anna
Boris
Alex
Alina
Dmitry
Можно использовать и лямбда-выражение:
users.stream().forEach(name -> System.out.println("Пользователь: " + name));
Результат:
Пользователь: Anna
Пользователь: Boris
Пользователь: Alex
Пользователь: Alina
Пользователь: Dmitry
Важно: forEach завершает поток
После вызова forEach поток «закрывается». Нельзя продолжать цепочку:
users.stream()
.filter(name -> name.startsWith("A"))
.forEach(System.out::println)
.map(String::toUpperCase); // Ошибка! Поток уже закрыт.
Попытка вызвать что-то после forEach приведёт к ошибке компиляции: терминальная операция возвращает void, а не новый поток.
2. Метод peek: подглядываем, но не вмешиваемся
peek — это промежуточная операция. Она позволяет выполнить действие для каждого элемента на определённом этапе обработки, не изменяя сам элемент и не завершая поток.
Сигнатура метода:
Stream<T> peek(Consumer<? super T> action)
- peek возвращает новый поток, в котором для каждого элемента будет выполнено действие action.
- Обычно используется для отладки, логирования или мониторинга состояния потока.
Пример: Логирование после фильтрации
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
List<Integer> nameLengths = users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("Прошёл фильтр: " + name))
.map(String::length)
.collect(Collectors.toList());
Результат в консоли:
Прошёл фильтр: Anna
Прошёл фильтр: Alex
Прошёл фильтр: Alina
Содержимое nameLengths:
[4, 4, 5]
Где удобно использовать peek?
- Для отладки цепочки операций: посмотреть, что происходит на каждом этапе.
- Для сбора статистики (например, подсчёта количества элементов).
- Для логирования данных на промежуточных этапах.
Важно: peek не должен использоваться для изменения элементов потока. Для преобразований есть map. peek — это «подсмотреть», а не «вмешаться».
3. forEach vs peek: в чём разница?
| Метод | Тип операции | Когда применяется | Можно продолжать цепочку? | Для чего лучше подходит |
|---|---|---|---|---|
|
Терминальная | В самом конце обработки потока | Нет | Финальные действия (вывод, логирование, запись в БД) |
|
Промежуточная | В середине цепочки операций | Да | Отладка, промежуточное логирование, подсчёт |
Пример: разница в использовании
// Пример с forEach
users.stream()
.filter(name -> name.startsWith("A"))
.map(String::toUpperCase)
.forEach(System.out::println); // Здесь поток завершается
// Пример с peek
users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("Фильтр прошёл: " + name))
.map(String::toUpperCase)
.collect(Collectors.toList()); // Можно продолжать цепочку
Важно помнить
- forEach — это точка невозврата: после неё с потоком больше ничего не сделать.
- peek не гарантирует выполнение действий, если не будет вызвана терминальная операция. Если написать только цепочку из промежуточных операций — ничего не произойдёт.
4. Неочевидные моменты: forEach не всегда лучший выбор!
Почему не стоит использовать forEach для изменения коллекций?
Многие новички пытаются с помощью forEach изменять элементы коллекции или саму коллекцию (например, удалять элементы). Но это плохая практика: потоки не предназначены для модификации исходных коллекций.
Пример неправильного использования:
List<String> names = new ArrayList<>(List.of("Anna", "Boris", "Alex"));
names.stream().forEach(name -> {
if (name.startsWith("A")) {
names.remove(name); // Может привести к ConcurrentModificationException!
}
});
Результат: ошибка времени выполнения — нельзя изменять коллекцию во время обхода потока (ConcurrentModificationException).
Для чего всё-таки использовать forEach?
- Для вывода на экран (например, печать отчёта).
- Для записи в лог.
- Для вызова внешних сервисов (например, отправка e-mail).
- Для сбора статистики (например, увеличение счётчика).
5. Ещё раз про peek: только для отладки!
Очень хочется использовать peek для изменения элементов, например, увеличить возраст пользователя:
users.stream()
.peek(user -> user.setAge(user.getAge() + 1)) // Плохо!
.collect(Collectors.toList());
Почему плохо?
- Это нарушает декларативность и чистоту Stream API.
- Такой код становится трудно поддерживать и тестировать.
- Побочные эффекты в промежуточной операции могут вести к неочевидным багам.
Лучше использовать map для преобразования данных:
List<User> olderUsers = users.stream()
.map(user -> new User(user.getName(), user.getAge() + 1))
.collect(Collectors.toList());
Схема: разница между forEach и peek
users.stream()
.filter(...) // промежуточная операция
.peek(...) // промежуточная операция, "подсматриваем"
.map(...) // промежуточная операция
.forEach(...) // терминальная операция, "делаем действие"
Пояснение:
— Всё, что до forEach, можно комбинировать, переставлять, добавлять.
— После forEach поток закрыт.
6. Типичные ошибки при работе с forEach и peek
Ошибка №1: Использовать peek для изменения данных. peek предназначен только для наблюдения, а не для изменения элементов потока. Для преобразований используйте map.
Ошибка №2: Ожидать, что peek всегда выполнится. peek выполняется только если после него есть терминальная операция (collect, forEach, count и т.д.). Без терминальной операции ничего не произойдёт.
Ошибка №3: Пытаться продолжить поток после forEach. forEach — терминальная операция. После неё нельзя вызывать другие методы потока.
Ошибка №4: Модифицировать коллекцию внутри forEach. Изменять исходную коллекцию (удалять или добавлять элементы) во время обхода через forEach — прямой путь к ConcurrentModificationException.
Ошибка №5: Использовать forEach вместо collect для сбора результата. Если вы хотите собрать элементы в новую коллекцию, используйте collect(Collectors.toList()), а не forEach с ручным добавлением. Это нарушает декларативность и может привести к ошибкам в многопоточных сценариях.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ