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?
- Для виведення на екран (наприклад, друк звіту).
- Для запису до журналу.
- Для виклику зовнішніх сервісів (наприклад, надсилання електронних листів).
- Для збирання статистики (наприклад, збільшення лічильника).
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 з ручним додаванням. Це порушує декларативність і може призвести до помилок у багатопоточних сценаріях.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ