Основные концепции Stream API
Stream API в Java разработан в соответствии со следующими концепциями:
- Ленивость вычислений. Промежуточные операции не выполняются сразу: они откладываются до тех пор, пока не будет вызвана терминальная. Это позволяет выполнять только необходимые вычисления и снижать общее количество операций, оптимизируя использование ресурсов.
- Параллельная обработка. Обычно операции выполняются друг за другом. Однако если нужно обработать большой объем данных, можно воспользоваться методом parallelStream() — так операция будет выполнена быстрее.
- Однократное использование стрима. После того как была вызвана терминальная операция, поток нельзя использовать повторно — для этого нужно создать новый стрим.
- Отсутствие хранения данных. Стримы не выступают хранилищем, они лишь используют (не изменяют) данные из указанных источников.
- Декларативный стиль. Stream API не нужно предоставлять конкретную реализацию операций над данными: достаточно описать, что нужно с ними сделать. Это значительно сокращает код и делает его более читаемым. Рассмотрим пример, иллюстрирующий эту концепцию:
// Стандартная реализация цикла
public class StreamVsLoop {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> result = new ArrayList<>();
for (Integer number : numbers) {
if (number % 2 == 0) {
result.add(number * 2);
}
}
for (Integer number : result) {
System.out.println(number);
}
}
}
// Эквивалентный пример с Stream API в Java
public class StreamVsLoop {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
numbers.stream()
.filter(n -> n % 2 == 0)
.map(n -> n * 2)
.forEach(System.out::println);
}
}
Создание Stream
Для создания стрима из коллекций используется метод stream():
List<Integer> list = Arrays.asList(1, 2, 3, 4);
Stream<Integer> stream = list.stream();
Для преобразования массива в поток предусмотрен метод stream() класса Arrays:
int[] numbers = {1, 2, 3, 4};
IntStream intStream = Arrays.stream(numbers);
Для примитивных типов данных также можно использовать метод boxed() — он преобразует элементы в объекты.
Метод Stream.of() позволяет создать поток из набора элементов:
Stream<String> stream = Stream.of("a", "b", "c");
stream.forEach(System.out::println);
Создать бесконечный поток можно с помощью метода generate(), однако лучше явно ограничивать поток с помощью limit():
Stream<Integer> infiniteStream = Stream.generate(() -> 1 );
infiniteStream.limit( 5 ).forEach(System.out::println);
Класс Stream.Builder позволяет построить стрим из добавленных элементов:
Stream<String> stream = Stream.<String>builder()
.add("a")
.add("b")
.add("c")
.build();
Для создания пустых стримов используется метод empty() — он позволяет не допустить выброса NullPointerException:
Stream<String> emptyStream = Stream.empty();
Промежуточные операции
Промежуточные методы возвращают новые стримы и позволяют формировать цепочки из нескольких операций. Промежуточные операции выполняются не сразу, а только при вызове терминальной операции.
Рассмотрим основные промежуточные методы Stream API в Java:
- filter(Predicate) возвращает новый поток с элементами, соответствующими указанному условию:
// Останутся только четные элементы
Stream<Integer> filtered = Stream.of(1, 2, 3, 4, 5, 6, 7)
.filter(n -> n % 2 == 0);
filtered.forEach(System.out::println); // 2, 4, 6
- map(Function) выполняет преобразование каждого элемента потока в соответствии с указанным лямбда-выражением или ссылки на метод. Map() в Java Stream API также возвращает новый поток, в то время как исходный не меняется:
// Преобразование элементов в верхний регистр
Stream<String> mapped = Stream.of("Math", "Biology", "Chemistry")
.map(String::toUpperCase);
mapped.forEach(System.out::println); // MATH, BIOLOGY, CHEMISTRY
- flatMap() преобразует каждый элемент в поток, а затем разворачивает каждый из этих потоков в единый стрим. Метод используется для работы с вложенными структурами данных:
List<List<Integer>> nestedLists = Arrays.asList(
Arrays.asList(1, 2, 3),
Arrays.asList(4, 5),
Arrays.asList(6, 7, 8, 9)
);
List<Integer> flatList = nestedLists.stream()
.flatMap(List::stream)
flatList.forEach(System.out::println); // 1, 2, 3, 4, 5, 6, 7, 8, 9
- sorted() сортирует элементы в естественном порядке или с помощью предоставленного компаратора:
// В естественном порядке
Stream<Integer> sorted = Stream.of(3, 1, 4, 2)
.sorted();
sorted.forEach(System.out::println); // 1, 2, 3, 4
// С компаратором
Stream<String> sortedDesc = Stream.of("Math", "Biology", "Chemistry")
.sorted(Comparator.reverseOrder());
sortedDesc.forEach(System.out::println); // Biology, Chemistry, Math
- distinct() предназначен для удаления дублирующихся элементов потока. Для проверки используется метод equals():
Stream<Integer> distinct = Stream.of(1, 2, 2, 3, 4, 4)
.distinct();
distinct.forEach(System.out::println); // 1, 2, 3, 4
- limit(n) возвращает стрим, содержащий ограниченное n число элементов. Если в потоке меньше элементов, чем n, то стрим вернется со всеми из них:
Stream<Integer> limited = Stream.of(1, 2, 3, 4, 5)
.limit(3);
limited.forEach(System.out::println); // 1, 2, 3
- skip(n) возвращает новый поток, пропуская первые n элементов. Если в потоке меньше элементов, чем n, то вернется пустой стрим:
Stream<Integer> skipped = Stream.of(1, 2, 3, 4, 5)
.skip(3);
limited.forEach(System.out::println); // 4, 5
- peek() предназначен для добавления промежуточных операций для каждого элемента потока, при этом не изменяя исходный стрим. Обычно этот метод в Stream API Java используется для отладки и логирования:
Stream<Integer> peeked = Stream.of(1, 2, 3)
.peek(n -> System.out.println("Step: " + n))
.map(n -> n * 2);
peeked.forEach(System.out::println);
/*
Step: 1
2
Step: 2
4
Step: 3
6
*\
Из промежуточных операций можно собирать цепочки, например:
import java.util.*;
import java.util.stream.*;
public class FlatMapExample {
public static void main(String[] args) {
// Список списков предметов
List<List<String>> studentCourses = Arrays.asList(
Arrays.asList("Mathematics", "Physics", "Chemistry"),
Arrays.asList("Biology", "Chemistry"),
Arrays.asList("History", "Geography", "Philosophy")
);
// flatMap() для получения плоского списка предметов
List<String> allCourses = studentCourses.stream()
.flatMap(List::stream) // Преобразование предметов в стрим и «развертывание»
.distinct() // Удаление дубликатов
.sorted() // Сортировка по алфавиту
.collect(Collectors.toList()); // Сбор в список
System.out.println(allCourses);
}
}
// [Biology, Chemistry, Geography, History, Mathematics, Philosophy, Physics]
Терминальные операции
Терминальные операции нужны для запуска выполнения всей цепочки операций над потоком. Они завершают поток — после вызова терминальной операции работать со стримом больше нельзя.
Рассмотрим главные промежуточные методы:
- forEach(Consumer) применяет операцию к каждому элементу потока. Этот метод ничего не возвращает, но производит побочный эффект:
List<String> names = Arrays.asList("Mary", "Ann", "Nick");
names.stream()
.forEach(System.out::println);
/*
Mary
Ann
Nick
*\
- collect(Collector) собирает элементы в структуру данных: строку, множество, список и т. д. Конкретная структура определяется методом из класса Collector, например, toSet() для преобразования во множество, toList — в список, joining() — в строку:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println(evenNumbers); // [2, 4]
- count() возвращает количество элементов в потоке:
long count = Stream.of(1, 2, 3, 4, 5)
.filter(n -> n % 2 == 0)
.count();
System.out.println(count); // 2
- allMatch(Predicate), anyMatch(Predicate), noneMatch(Predicate) проверяют соответствие элементов определенному условию. allMatch(Predicate) возвращает true, если все элементы соответствуют предикату, anyMatch(Predicate) — если хотя бы один, noneMatch(Predicate) — если все элементы не удовлетворяют условию:
boolean allEven = Stream.of(2, 4, 6).allMatch(n -> n % 2 == 0);
System.out.println(allEven); // true
boolean hasOdd = Stream.of(2, 4, 6, 7).anyMatch(n -> n % 2 != 0);
System.out.println(hasOdd); // true
boolean noGreaterThanTen = Stream.of(2, 4, 6).noneMatch(n -> n > 10);
System.out.println(noGreaterThanTen); // true
- reduce() формирует один результат из всех элементов потока на основе указанной операции. Обычно используется для сложения, умножения и других операций агрегирования:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
int sum = numbers.stream()
.reduce(0, Integer::sum);
System.out.println(sum); // 10
- findFirst() возвращает первый элемент потока, а findAny() — любой. findAny() используется для параллельных потоков, так как позволяет быстро получить результат:
Optional<Integer> first = Stream.of(1, 2, 3, 4).findFirst();
first.ifPresent(System.out::println); // 1
Параллельные потоки
Параллельная обработка данных позволяет ускорить выполнение операций на многоядерных процессорах. Stream API поддерживает такую возможность через методы parallelStream() (для коллекций) или parallel():
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.parallelStream()
.reduce(0, Integer::sum);
System.out.println("Sum: " + sum); // 10
Эти методы не гарантируют порядок обработки элементов, однако есть метод forEachOrdered(), который сохраняет последовательность, но снижает производительность. Параллельная обработка в Stream API работает за счет разделения потока на подзадачи и их последующего независимого выполнения на разных ядрах. После завершения обработки каждого параллельного потока результаты выполнения объединяются.
Шпаргалка по Stream API в Java
- Механизм позволяет писать более читаемый код в декларативном стиле.
- Стрим может быть создан из разных источников: коллекций, массивов, строк и других.
- Промежуточные методы позволяют формировать цепочки операций, они не выполняются, пока не будет вызвана терминальная операция.
- Поддерживается параллельный режим обработки с помощью parallelStream() или parallel().