1. Створюємо потік
Щоб користуватися Stream API, спочатку потрібно отримати потік із колекції чи масиву.
Приклади створення потоку
// Зі списку
List<String> names = List.of("Anna", "Boris", "Alex", "Alina");
Stream<String> stream = names.stream();
// З масиву
int[] numbers = {1, 2, 3, 4, 5};
IntStream intStream = Arrays.stream(numbers);
// З окремих значень
Stream<String> letters = Stream.of("A", "B", "C");
Стисло:
- list.stream() — для колекцій
- Arrays.stream(array) — для масивів
- Stream.of(...) — для окремих значень
Приклад у контексті нашого застосунку
Припустімо, у нас є список користувачів:
List<String> users = List.of("Ivan", "Anna", "Petr", "Alexey");
Stream<String> userStream = users.stream();
Проміжні та термінальні операції
Важливий момент: операції Stream API поділяються на два типи.
- Проміжні операції (наприклад, filter, map, distinct) — описують етапи обробки. Вони повертають новий потік, але самі по собі не запускають виконання.
- Термінальні операції (наприклад, collect, forEach, count) — запускають конвеєр і повертають результат.
Потік працює «ледачо»: доки не буде викликано термінальну операцію, жодних обчислень не відбудеться. Саме тому ми часто завершуємо ланцюжок викликом collect(...) — це і є точка, де потік перетворюється назад на колекцію або інший результат.
2. Операція filter: фільтруємо елементи за умовою
filter — це проміжна операція, яка пропускає лише ті елементи, що відповідають заданій умові.
Сигнатура
Stream<T> filter(Predicate<? super T> predicate);
Predicate — це функціональний інтерфейс, що приймає елемент і повертає true (залишити) або false (відсіяти).
Приклад: залишити лише парні числа
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println(evenNumbers); // [2, 4, 6]
Що відбувається?
- n -> n % 2 == 0 — лямбда-вираз, який перевіряє, чи ділиться число на 2 без остачі.
- filter залишає лише парні числа.
Приклад: фільтруємо імена, що починаються з «A»
List<String> names = List.of("Anna", "Boris", "Alex", "Alina", "Ivan");
List<String> aNames = names.stream()
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());
System.out.println(aNames); // [Anna, Alex, Alina]
Важливий момент: filter не змінює колекцію — він створює новий потік, у якому є лише потрібні елементи.
3. Операція map: перетворюємо елемент на щось інше
map — це операція перетворення. Вона бере кожен елемент потоку, застосовує до нього функцію й повертає нове значення.
Сигнатура
<R> Stream<R> map(Function<? super T, ? extends R> mapper)
Function — це інтерфейс, що приймає елемент і повертає значення (може бути іншого типу).
Приклад: отримати довжини рядків
List<String> names = List.of("Anna", "Boris", "Alex");
List<Integer> nameLengths = names.stream()
.map(name -> name.length())
.collect(Collectors.toList());
System.out.println(nameLengths); // [4, 5, 4]
Що відбувається?
- map перетворює рядок на його довжину (name -> name.length()).
- У результаті маємо потік чисел.
Приклад: перетворити рядки на верхній регістр
List<String> names = List.of("Anna", "Boris", "Alex");
List<String> upperNames = names.stream()
.map(name -> name.toUpperCase())
.collect(Collectors.toList());
System.out.println(upperNames); // [ANNA, BORIS, ALEX]
4. Операція collect: збираємо результат назад у колекцію
collect — це термінальна операція, тобто вона завершує роботу потоку й збирає результат у колекцію або інший контейнер.
Сигнатура
<R, A> R collect(Collector<? super T, A, R> collector)
Не лякайтеся «страшної» сигнатури! У 99 % випадків ви використовуєте готові колектори з класу Collectors.
Collectors — це допоміжний клас із набором «збирачів». Він підказує потоку, у яку форму зібрати результат: список, множину, рядок тощо.
Приклади:
- Collectors.toList() — у List
- Collectors.toSet() — у Set
- Collectors.joining(", ") — у рядок через кому
Тобто Collectors — це як набір коробок різної форми, у які ви пакуєте елементи потоку.
Приклад: зібрати результат у List
List<String> filtered = names.stream()
.filter(name -> name.length() > 3)
.collect(Collectors.toList());
Приклад: зібрати результат у Set
Set<String> uniqueNames = names.stream()
.map(String::toLowerCase)
.collect(Collectors.toSet());
Приклад: зібрати рядки через кому
String result = names.stream()
.collect(Collectors.joining(", "));
System.out.println(result); // Anna, Boris, Alex
5. Ланцюжок операцій: фільтрація + перетворення + збирання результату
Найбільша сила Stream API — це можливість ланцюжити операції одну за одною.
Приклад: отримати довжини імен, що починаються з «A»
List<String> names = List.of("Anna", "Boris", "Alex", "Alina", "Ivan");
List<Integer> aNameLengths = names.stream()
.filter(name -> name.startsWith("A"))
.map(String::length)
.collect(Collectors.toList());
System.out.println(aNameLengths); // [4, 4, 5]
Покроково:
- .stream() — створюємо потік зі списку.
- .filter(name -> name.startsWith("A")) — залишаємо тільки імена, що починаються на "A".
- .map(String::length) — перетворюємо кожне імʼя на його довжину.
- .collect(Collectors.toList()) — збираємо результат у список.
Аналогічний імперативний код
Ось як би це виглядало «по‑старому»:
List<Integer> result = new ArrayList<>();
for (String name : names) {
if (name.startsWith("A")) {
result.add(name.length());
}
}
Порівняйте: Stream API — одним рядком, читається як «що робимо», а не «як робимо».
6. Практика: кілька коротких завдань
Потренуймося! Усі приклади можна запускати в одному файлі — просто змінюйте дані.
Завдання 1: залишити лише непарні числа й звести їх у квадрат
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7);
List<Integer> oddSquares = numbers.stream()
.filter(n -> n % 2 != 0)
.map(n -> n * n)
.collect(Collectors.toList());
System.out.println(oddSquares); // [1, 9, 25, 49]
Завдання 2: зі списку рядків отримати список перших літер
List<String> names = List.of("Anna", "Boris", "Alex");
List<Character> initials = names.stream()
.map(name -> name.charAt(0))
.collect(Collectors.toList());
System.out.println(initials); // [A, B, A]
Завдання 3: відфільтрувати рядки довжиною понад 3 та зібрати їх у Set
List<String> words = List.of("cat", "dog", "elephant", "ant", "bear");
Set<String> longWords = words.stream()
.filter(word -> word.length() > 3)
.collect(Collectors.toSet());
System.out.println(longWords); // [bear, elephant]
7. Типові помилки під час роботи з filter, map, collect
Помилка № 1: забули collect — результату немає!
Stream API ледачий, як кіт на підвіконні: доки не викличете термінальну операцію (наприклад, collect або forEach), нічого не відбудеться. Якщо написати лише users.stream().filter(...).map(...); — це не виконає жодних дій.
Помилка № 2: filter і map сплутані місцями
Іноді новачки спочатку роблять map, а потім filter. Наприклад, names.stream().map(String::length).filter(len -> len > 3) — це поверне числа, а не рядки. Якщо вам потрібні рядки довжиною понад 3, спершу фільтруйте, потім перетворюйте.
Помилка № 3: забули про незмінність
Операції Stream API не змінюють вихідну колекцію! Вони повертають новий результат. Після List<String> upper = names.stream().map(String::toUpperCase).collect(Collectors.toList()); — колекція names залишиться незмінною.
Помилка № 4: спроба використовувати зовнішній змінюваний список
Не варто робити так:
List<String> result = new ArrayList<>();
names.stream().filter(...).forEach(name -> result.add(name));
Краще використовувати collect — це безпечніше й коротше.
Помилка № 5: NullPointerException
Якщо в колекції можуть бути null-елементи, то виклик name.startsWith("A") на null призведе до помилки. Додавайте перевірку на null, якщо це можливо:
.filter(name -> name != null && name.startsWith("A"))
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ