1. Імперативний vs функціональний стиль
Давайте почнемо з простого питання: навіщо взагалі потрібен так званий «функціональний стиль»? Чим він кращий за звичний підхід через цикли? І що взагалі означає «функціональний стиль» у Java?
Імперативний стиль
Імперативний стиль — це коли ви говорите комп’ютеру, як щось зробити, крок за кроком. Наприклад, якщо потрібно зі списку рядків отримати список довжин цих рядків, залишити тільки непарні довжини та відсортувати їх за спаданням, ви пишете приблизно так:
List<String> words = Arrays.asList("кіт", "слон", "носоріг", "тигр", "миша");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
Тут ми явно створюємо проміжний список, вручну додаємо елементи, сортуємо — усе по кроках.
Функціональний стиль
Функціональний стиль — це коли ви описуєте, що ви хочете отримати, а не як це робиться. У Java це реалізовано через Stream API:
List<String> words = Arrays.asList("кіт", "слон", "носоріг", "тигр", "миша");
List<Integer> result = words.stream()
.map(String::length)
.filter(len -> len % 2 != 0)
.sorted(Comparator.reverseOrder())
.toList();
System.out.println(result); // [7, 5, 3]
Тут ми наче будуємо «конвеєр» обробки даних: спочатку перетворюємо слова на їх довжини (map), потім фільтруємо непарні (filter), потім сортуємо (sorted). Усе це — в одному ланцюжку, без явних проміжних колекцій і циклів.
Порівняння: імперативний vs функціональний стиль
У звичному імперативному підході ми пишемо цикл, у якому крок за кроком пояснюємо комп’ютеру, що робити: пройтися по кожному елементу, перевірити умову, додати до нового списку або вивести на екран. Код працює, але займає більше рядків, і чим складніше завдання, тим важче його читати й супроводжувати.
Функціональний стиль дає змогу описувати не сам процес, а те, що ми хочемо отримати. Замість довгого циклу ми будуємо ланцюжок операцій: відфільтрувати, перетворити, зібрати результат. Це коротше, наочніше й зменшує ризик помилок, тому що менше «ручної роботи» зі змінюваними колекціями.
Є й зворотний бік. Для новачка такий ланцюжок може виглядати заплутано: кілька лямбд поспіль читаються складніше, ніж простий цикл. Тому функціональний стиль виграє у стислості та виразності, але потребує звички та досвіду.
2. Основні операції Stream API
Stream API — це не просто «новий вид циклу», а цілий набір інструментів для обробки колекцій у функціональному стилі. Давайте розберемося з основними операціями.
Як отримати Stream?
List<String> list = List.of("a", "bb", "ccc");
Stream<String> stream = list.stream();
Проміжні операції
- map — перетворює елементи потоку
- filter — фільтрує елементи за умовою
- flatMap — перетворює кожен елемент на потік і розгортає їх
- sorted — сортування
- distinct — прибирає дублікати
- limit / skip — обмежити/пропустити елементи
Термінальні операції
- forEach — виконати дію для кожного елемента
- collect — зібрати результат у колекцію
- reduce — звести потік до одного значення (наприклад, суми)
- count — порахувати кількість елементів
- anyMatch, allMatch, noneMatch — перевірки умов
Приклад: ланцюжок обробки
List<String> names = List.of("Анна", "Борис", "Віка", "Гліб", "Даша");
List<String> filtered = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList();
System.out.println(filtered); // [БОРИС, ВІКА, ДАША]
Візуальна схема «конвеєра»:
[Анна, Борис, Віка, Гліб, Даша]
| filter (length>3)
[Борис, Віка, Даша]
| map (toUpperCase)
[БОРИС, ВІКА, ДАША]
| sorted
[БОРИС, ВІКА, ДАША]
| toList
Кожна операція не змінює вихідну колекцію — створюється новий потік.
3. Потокове виконання та лінивість
Проміжні й термінальні операції
У Stream’ів є два типи операцій. Перші — проміжні, такі як map, filter або sorted. Вони повертають новий потік і наче обіцяють щось зробити, але насправді ще нічого не виконують. Другі — термінальні, наприклад forEach, collect або reduce. Саме вони вже справді запускають усю обробку. Ключовий момент у тому, що доки ви не викликали термінальну операцію, потік залишається «лінивим» — обчислення не починаються.
Приклад:
Stream<String> stream = List.of("a", "bb", "ccc").stream()
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase();
});
System.out.println("Перед forEach");
stream.forEach(System.out::println);
Виведення:
Перед forEach
map: a
A
map: bb
BB
map: ccc
CCC
Видно, що map не виконується, доки не почався forEach.
Чому це круто?
Такий підхід дає змогу без зайвих витрат будувати як завгодно довгі ланцюжки перетворень. Потік почне працювати лише тоді, коли це справді знадобиться. Завдяки цьому можна без проблем обробляти величезні обсяги даних або навіть нескінченні послідовності. А ще лінивість допомагає економити пам’ять і виконувати обчислення ефективніше.
4. Практика: завдання «Рядки → довжини → непарні → за спаданням»
Давайте крок за кроком розв’яжемо завдання: «Зі списку рядків отримати список довжин рядків, залишити тільки непарні довжини, відсортувати за спаданням.»
Імперативне розв’язання
List<String> words = Arrays.asList("кіт", "слон", "носоріг", "тигр", "миша");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
Функціональне розв’язання зі Stream API
List<String> words = Arrays.asList("кіт", "слон", "носоріг", "тигр", "миша");
List<Integer> result = words.stream()
.map(String::length) // перетворюємо рядки на їх довжини
.filter(len -> len % 2 != 0) // лишаємо лише непарні довжини
.sorted(Comparator.reverseOrder()) // сортуємо за спаданням
.toList(); // збираємо в List (Java 16+)
System.out.println(result); // [7, 5, 3]
Пояснення:
- map(String::length) — для кожного рядка отримуємо його довжину.
- filter(len -> len % 2 != 0) — залишаємо тільки непарні довжини.
- sorted(Comparator.reverseOrder()) — сортуємо за спаданням.
- toList() — збираємо потік у новий список.
Аналогія
Це якби ви будували «стрічку» на заводі: на кожному етапі деталі обробляються по-новому, і тільки в самому кінці все складається в коробку.
5. Ще приклади: map, filter, forEach, collect
Приклад 1: Фільтрація і друк
List<String> names = List.of("Анна", "Борис", "Віка", "Гліб", "Даша");
names.stream()
.filter(name -> name.contains("а"))
.forEach(System.out::println);
// Виведе: Анна, Даша
Приклад 2: Перетворення і збирання в Set
Set<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toSet());
System.out.println(upperNames); // [АННА, БОРИС, ВІКА, ГЛІБ, ДАША]
Приклад 3: Отримання суми довжин усіх рядків
int totalLength = names.stream()
.mapToInt(String::length)
.sum();
System.out.println("Сумарна довжина: " + totalLength);
Приклад 4: Використання Predicate і Function
Predicate<String> longName = name -> name.length() > 4;
Function<String, String> greet = name -> "Привіт, " + name + "!";
names.stream()
.filter(longName)
.map(greet)
.forEach(System.out::println);
// Привіт, Борис!
// Привіт, Даша!
6. Особливості функціонального стилю зі Stream API
Відсутність змінюваного стану
Стиль Stream API заохочує «чисті» функції — без побічних ефектів. Це означає, що краще не змінювати зовнішні змінні всередині лямбд.
Погано:
List<String> result = new ArrayList<>();
names.stream()
.filter(name -> name.startsWith("А"))
.forEach(result::add); // побічний ефект!
Краще:
List<String> result = names.stream()
.filter(name -> name.startsWith("А"))
.toList();
Композиція операцій
Можна будувати дуже довгі ланцюжки, комбінуючи map, filter, sorted та інші методи. Головне — не захоплюватися: якщо ланцюжок став довшим за екран, можливо, варто розбити його на частини.
Лінивість обчислень
Stream API нічого не робить, доки не дійде до термінальної операції. Це дає змогу економити ресурси й будувати ефективні пайплайни обробки даних.
Незмінюваність вихідних колекцій
Stream не змінює вихідну колекцію! Усі перетворення повертають новий потік/колекцію.
7. Коли використовувати Stream API
Stream API чудово підходить, коли:
- Потрібно швидко обробити колекцію (фільтрація, перетворення, сортування).
- Потрібен лаконічний, читабельний код.
- Не хочеться вручну створювати проміжні колекції.
- Потрібно легко додати паралелізм (через parallelStream()).
Імперативний стиль інколи кращий, якщо:
- Потрібна складна логіка з кількома вкладеними циклами та умовами.
- Важливо зберегти максимальну продуктивність у критичних місцях (Stream API інколи трохи повільніший).
- Потрібно працювати зі змінюваним станом (наприклад, оновлювати елементи «на місці»).
8. Типові помилки під час роботи зі Stream API
Помилка № 1: Використання forEach для збирання колекції. Багато новачків використовують forEach для додавання елементів у нову колекцію. Це не функціональний стиль! Замість цього використовуйте collect або toList().
Помилка № 2: Передчасна оптимізація. Не намагайтеся одразу використовувати parallelStream() — паралелізм потрібен лише для справді великих колекцій і CPU-інтенсивних задач.
Помилка № 3: Змішування Stream API та змінюваних колекцій. Stream API передбачає роботу з незмінюваними даними. Не варто всередині лямбд змінювати елементи колекції.
Помилка № 4: «Втрата» результату. Забули викликати термінальну операцію — нічого не станеться.
Помилка № 5: Надто складні лямбди. Якщо лямбда-вираз став довшим за один–два рядки — винесіть його в окремий метод із зрозумілою назвою.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ