Stream API в Java: обработка данных с примерами. Практические Задания
Запустите одну строку: Arrays.asList(new int[]{1, 2, 3}).size(). Ожидаете 3, а получаете 1. Компилятор молчит, тест падает, и полчаса уходит на поиски того, чего нет. Это задание №4 на этой странице — и таких мест в Stream API и Optional хватает.
Здесь собраны девять практических заданий по Java Stream API, Optional и Collectors: от простого пайплайна filter → map → collect до группировки с downstream-коллекторами и цепочек Optional без единого if. Задания идут по нарастающей сложности, поэтому выполняйте их по порядку: каждое следующее опирается на приёмы предыдущих. Сначала пишите код сами, и только потом сверяйтесь с разбором.
Что повторить перед началом
Каждое задание опирается на конкретный урок модуля. Если условие кажется непонятным — вернитесь к соответствующей теории, а не подглядывайте сразу в решение.
| Урок | Что нужно знать | Задания |
|---|---|---|
| Что такое Stream API | Источник, промежуточные и терминальные операции, ленивость | 1, 2 |
| Методы Stream API | filter, map, limit, reduce, min, max | 1, 2, 3 |
| Преобразование массива | Массив ↔ List, boxed(), mapToInt(), поведение Arrays.asList | 4 |
| Collectors в Java | groupingBy, partitioningBy, counting, averagingDouble, joining | 5, 6, 7 |
| Что такое Optional | Зачем нужен Optional, of, ofNullable, empty | 8 |
| Методы Optional | filter, map, orElse, orElseGet, orElseThrow | 8, 9 |
Уроки про Optional и Collectors выходят в ближайшее время — ссылки уже ведут на будущие адреса, добавьте их в закладки.
1. Базовый пайплайн: filter, map, collect
Дан список названий фильмов. Соберите новый список, в который попадут только названия длиннее 10 символов, приведённые к верхнему регистру. Исходный список изменять нельзя. Решите задачу одним пайплайном, без циклов for и без промежуточных переменных.
import java.util.List;
import java.util.stream.Collectors;
public class Task1 {
public static void main(String[] args) {
List<String> movies = List.of(
"Интерстеллар", "Мгла", "Начало",
"Список Шиндлера", "Джанго", "Побег из Шоушенка");
List<String> result = /* ваш код */;
System.out.println(result);
// Ожидаемый вывод:
// [ИНТЕРСТЕЛЛАР, СПИСОК ШИНДЛЕРА, ПОБЕГ ИЗ ШОУШЕНКА]
}
} Усложнение: отсортируйте результат по длине названия по убыванию, не добавляя новых промежуточных коллекций.
2. Бесконечный поток и limit
Потоки бывают не только из коллекций. Напишите два метода, каждый из которых возвращает List<Long>, построенный на бесконечном источнике, ограниченном limit():
powersOfTwo(int n)— первыеnстепеней двойки, начиная с 1 (то есть 20).fibonacci(int n)— первыеnчисел Фибоначчи, начиная с 0 и 1.
Для второго метода пригодится приём с «состоянием из двух элементов»: в качестве значения потока используется пара соседних чисел, а наружу отдаётся только первое из них.
import java.util.List;
import java.util.stream.Stream;
public class Task2 {
public static List<Long> powersOfTwo(int n) {
return Stream.iterate(1L, /* ваш код */)
.limit(n)
/* ваш код */;
}
public static List<Long> fibonacci(int n) {
return Stream.iterate(new long[]{0, 1}, pair -> new long[]{/* ваш код */})
/* ваш код */;
}
public static void main(String[] args) {
System.out.println(powersOfTwo(5)); // [1, 2, 4, 8, 16]
System.out.println(fibonacci(10)); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
}
} Вопрос на понимание: что произойдёт, если в fibonacci убрать limit()? Ответьте до запуска, потом проверьте.
3. Терминальные операции: reduce, min, max
Задание из двух частей.
- Методом
reduce()посчитайте произведение всех чисел списка. Для пустого списка результат должен быть 1. - Найдите самое длинное и самое короткое слово в списке, используя
max()иmin()сComparator. Оба метода возвращаютOptional— разберитесь, почему, и обработайте пустой случай явно.
import java.util.Comparator;
import java.util.List;
public class Task3 {
public static int product(List<Integer> numbers) {
return /* ваш код: reduce */;
}
public static String longest(List<String> words) {
return /* ваш код: max + Comparator, пустой список -> "" */;
}
public static String shortest(List<String> words) {
return /* ваш код: min + Comparator, пустой список -> "" */;
}
public static void main(String[] args) {
System.out.println(product(List.of(2, 3, 4))); // 24
System.out.println(product(List.of())); // 1
System.out.println(longest(List.of("кот", "собака", "як"))); // собака
System.out.println(shortest(List.of("кот", "собака", "як"))); // як
}
} 4. Массив в список и обратно
Реализуйте четыре преобразования и проверьте одно распространённое заблуждение:
int[]→List<Integer>;int[]→ArrayList<Integer>явно (чтобы список гарантированно был изменяемым — добавьте в него элемент и убедитесь, что исключения нет);List<Integer>→int[];- выведите размер
Arrays.asList(numbers), гдеnumbers— этоint[], и объясните полученное число.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Task4 {
public static void main(String[] args) {
int[] numbers = {5, 3, 9, 1};
List<Integer> list = /* ваш код */;
ArrayList<Integer> arrayList = /* ваш код */;
arrayList.add(42);
int[] back = /* ваш код */;
System.out.println(list); // [5, 3, 9, 1]
System.out.println(arrayList); // [5, 3, 9, 1, 42]
System.out.println(Arrays.toString(back)); // [5, 3, 9, 1]
System.out.println(Arrays.asList(numbers).size()); // сколько здесь будет?
}
} Неочевидный момент задания 4
Arrays.asList(numbers) для int[] вернёт List<int[]> размером 1: дженерики не работают с примитивами, поэтому весь массив становится единственным элементом списка. Компилятор ошибки не покажет. Рабочие варианты — Arrays.stream(numbers).boxed() или массив объектов Integer[]. И помните, что сам Arrays.asList возвращает список фиксированного размера: add() на нём бросит UnsupportedOperationException.
5. Группировка через Collectors.groupingBy
Дан список сотрудников (запись Employee с полями name, department, salary). Напишите два метода:
countByDepartment—Map<String, Long>: сколько сотрудников в каждом отделе (groupingBy+counting());averageSalaryByDepartment—Map<String, Double>: средняя зарплата по отделу (groupingBy+averagingDouble()).
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Task5 {
record Employee(String name, String department, double salary) { }
public static Map<String, Long> countByDepartment(List<Employee> employees) {
return /* ваш код */;
}
public static Map<String, Double> averageSalaryByDepartment(List<Employee> employees) {
return /* ваш код */;
}
public static void main(String[] args) {
List<Employee> employees = List.of(
new Employee("Анна", "IT", 3000),
new Employee("Борис", "IT", 5000),
new Employee("Вера", "HR", 2000),
new Employee("Глеб", "HR", 2500),
new Employee("Дина", "Sales", 4000));
System.out.println(countByDepartment(employees));
// {HR=2, IT=2, Sales=1}
System.out.println(averageSalaryByDepartment(employees));
// {HR=2250.0, IT=4000.0, Sales=4000.0}
}
} Усложнение: сделайте так, чтобы результат возвращался в виде TreeMap с отделами по алфавиту. Подсказка: у groupingBy есть перегрузка с фабрикой map.
6. Разделение на две группы: partitioningBy
Дан список результатов экзамена. Разделите студентов на сдавших (балл 60 и выше) и не сдавших, используя Collectors.partitioningBy. Затем выведите количество в каждой группе, не создавая отдельного потока для подсчёта.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Task6 {
record Result(String student, int score) { }
public static void main(String[] args) {
List<Result> results = List.of(
new Result("Анна", 91),
new Result("Борис", 45),
new Result("Вера", 60),
new Result("Глеб", 59));
Map<Boolean, List<Result>> byPassed = /* ваш код */;
Map<Boolean, Long> counts = /* ваш код: partitioningBy + counting */;
System.out.println(byPassed.get(true)); // [Result[student=Анна, score=91], Result[student=Вера, score=60]]
System.out.println(byPassed.get(false)); // [Result[student=Борис, score=45], Result[student=Глеб, score=59]]
System.out.println(counts); // {false=2, true=2}
}
} Вопрос на понимание: чем partitioningBy отличается от groupingBy(r -> r.score() >= 60)? Ответ проверьте на пустом списке — разница будет видна сразу.
7. Сборка строки через Collectors.joining
Соберите из списка товаров одну строку: элементы через запятую с пробелом, вся строка в квадратных скобках. Названия приведите к формату «первая буква заглавная», пустые и пробельные названия отбросьте.
import java.util.List;
import java.util.stream.Collectors;
public class Task7 {
public static String format(List<String> products) {
return products.stream()
/* ваш код: отбросить пустые, нормализовать регистр */
.collect(Collectors.joining(/* разделитель, префикс, суффикс */));
}
public static void main(String[] args) {
System.out.println(format(List.of("apple", " ", "banana", "CHERRY", "")));
// [Apple, Banana, Cherry]
System.out.println(format(List.of()));
// []
}
} 8. Optional вместо null при поиске
Есть хранилище пользователей на основе Map<Long, User>. Напишите метод findById(long id), который возвращает Optional<User> и никогда не возвращает null. На его основе реализуйте два сценария вызова:
getOrThrow(long id)— бросает собственное исключениеUserNotFoundExceptionчерезorElseThrow();getOrGuest(long id)— возвращает «гостевого» пользователя черезorElseGet(), причём объект-заглушка должен создаваться только тогда, когда пользователь действительно не найден.
import java.util.Map;
import java.util.Optional;
public class Task8 {
record User(long id, String name) { }
static class UserNotFoundException extends RuntimeException {
UserNotFoundException(long id) {
super("Пользователь не найден: id=" + id);
}
}
private static final Map<Long, User> USERS = Map.of(
1L, new User(1, "Анна"),
2L, new User(2, "Борис"));
public static Optional<User> findById(long id) {
return /* ваш код */;
}
public static User getOrThrow(long id) {
return /* ваш код: orElseThrow */;
}
public static User getOrGuest(long id) {
return /* ваш код: orElseGet */;
}
public static void main(String[] args) {
System.out.println(findById(1)); // Optional[User[id=1, name=Анна]]
System.out.println(findById(99)); // Optional.empty
System.out.println(getOrGuest(99)); // User[id=-1, name=Гость]
System.out.println(getOrThrow(99)); // UserNotFoundException
}
} Как не свести Optional к обычному null
Связка if (opt.isPresent()) { opt.get(); } формально работает, но это тот же самый if (x != null), только многословнее. В задании 8 сознательно требуются orElseThrow() и orElseGet(): они выражают намерение прямо в сигнатуре вызова. Метод get() без предварительной проверки бросает NoSuchElementException — начиная с Java 10 у него есть более честный по названию аналог orElseThrow() без аргументов.
9. Цепочка Optional без проверок на null
Метод получает строку, которая может быть null. Нужно вернуть длину строки, если она не null и не состоит из одних пробелов, иначе — 0. Запрещено использовать if и тернарный оператор: соберите цепочку Optional.ofNullable → filter → map → orElse.
Вторая часть — эксперимент. Реализуйте lengthOrElse и lengthOrElseGet, где значение по умолчанию вычисляется методом с побочным эффектом (печатью в консоль), и запустите оба варианта на непустой строке. Посчитайте, сколько раз напечатается сообщение в каждом случае, и объясните результат.
import java.util.Optional;
public class Task9 {
public static int length(String text) {
return Optional.ofNullable(text)
/* ваш код: filter + map */
.orElse(0);
}
static int expensiveDefault() {
System.out.println("Считаем значение по умолчанию...");
return 0;
}
public static int lengthOrElse(String text) {
return Optional.ofNullable(text).map(String::length).orElse(expensiveDefault());
}
public static int lengthOrElseGet(String text) {
return Optional.ofNullable(text).map(String::length).orElseGet(Task9::expensiveDefault);
}
public static void main(String[] args) {
System.out.println(length("Java")); // 4
System.out.println(length(" ")); // 0
System.out.println(length(null)); // 0
lengthOrElse("Java"); // сколько строк напечатается?
lengthOrElseGet("Java"); // а здесь?
}
} Где чаще всего ошибаются в задании 9
Аргумент orElse(expensiveDefault()) — это обычный вызов метода: Java вычисляет его ДО вызова orElse, даже если Optional не пуст. Поэтому сообщение напечатается и для непустой строки. У orElseGet аргумент — Supplier, и он выполняется только при пустом Optional. Правило простое: если значение по умолчанию — константа или уже готовый объект, берите orElse; если это запрос в базу, создание объекта или любой расчёт — только orElseGet.
Про изменяемость результата
В заданиях 1 и 7 результат можно собрать двумя способами. Stream.toList() (Java 16 и новее) короче, но возвращает неизменяемый список: add() на нём бросит UnsupportedOperationException. collect(Collectors.toList()) не даёт гарантий по типу и изменяемости, хотя на практике возвращает ArrayList. Если список нужно потом менять — пишите явно collect(Collectors.toCollection(ArrayList::new)), как в задании 4.
Проверьте себя перед тем как смотреть решения
Пройдитесь по списку до того, как открывать разбор. Если хотя бы один пункт вызывает сомнение — вернитесь к соответствующему заданию.
- Весь ли ваш код компилируется и выдаёт ровно тот вывод, который указан в комментариях к заданиям?
- Остались ли где-то циклы
forили проверкиif (x != null)там, где условие требовало пайплайна или цепочкиOptional? - Можете ли вы объяснить вслух, почему
max()иmin()возвращаютOptional, аcount()— нет? - Понятно ли, почему
Arrays.asList(new int[]{...})даёт размер 1, и как получить список из четырёх элементов? - Сможете ли вы сходу назвать разницу между
orElseиorElseGetи привести пример, где выбор влияет на производительность?
Часто задаваемые вопросы
Что использовать в заданиях: collect(Collectors.toList()) или toList()?
Оба варианта засчитываются. stream.toList() появился в Java 16 и короче, но возвращает неизменяемый список — попытка добавить в него элемент завершится UnsupportedOperationException. collect(Collectors.toList()) работает начиная с Java 8 и на практике возвращает ArrayList, но спецификация не гарантирует ни конкретный класс, ни изменяемость. Когда изменяемость важна (задание 4), пишите намерение явно: collect(Collectors.toCollection(ArrayList::new)).
Почему нельзя переиспользовать один и тот же Stream в нескольких заданиях?
Поток одноразовый: после терминальной операции он закрыт, и повторное обращение к нему бросает IllegalStateException: stream has already been operated upon or closed. Если из одних данных нужно получить два результата — например, и количество, и среднее из задания 5, — создавайте поток заново из коллекции либо используйте один коллектор, который считает обе величины сразу: Collectors.summarizingDouble или Collectors.teeing (Java 12 и новее).
Стоит ли решать эти задания через parallelStream(), чтобы было быстрее?
Нет. На списках из нескольких элементов параллельный поток почти всегда медленнее последовательного: накладные расходы на разбиение данных и синхронизацию превышают выигрыш. Кроме того, в параллельном режиме forEach не сохраняет порядок элементов, а коллекторы вроде groupingBy требуют аккуратного выбора между обычной и конкурентной версией. Параллелизм оправдан на больших объёмах данных и независимых операциях без общего изменяемого состояния — это отдельная тема, а не способ ускорить учебный пример.
Можно ли использовать Optional как поле класса или параметр метода?
Не рекомендуется, и на собеседовании об этом спрашивают часто. Optional задумывался как тип возвращаемого значения для методов, которые могут не найти результат — именно так он используется в задании 8. Он не сериализуем, добавляет лишнюю обёртку на каждый объект и в поле сам может оказаться null, что возвращает исходную проблему. Для параметров вместо Optional лучше сделать перегрузку метода, а для полей — хранить обычное значение и отдавать Optional из геттера.
Комментарии