Раздел 8. Stream API и функциональный стиль в Java
Глава 4: Искусство агрегации. Коллекторы и группировки
Коллектор — это рецепт агрегации
Предыдущие главы посвящены преобразованиям потоков: фильтрации, отображению, уплощению. Но поток — эфемерная абстракция. Чтобы извлечь из него ценность, нужно свернуть его в конкретную структуру данных: список, множество, карту, число, строку. Эту свёртку выполняет Collector — центральный интерфейс терминальных операций агрегации.
Collector — не просто утилита для создания List. Это декларативный рецепт, описывающий, как создать контейнер для результатов, как наполнять его элементами, как объединять частичные результаты при параллельном выполнении, и как выполнить финальное преобразование. Понимание его компонентов раскрывает полную мощь Stream API.
Анатомия Collector
Интерфейс Collector<T, A, R> параметризован тремя типами: T — тип элементов входного потока, A — тип промежуточного контейнера аккумуляции, R — тип конечного результата.
Четыре функциональных компонента определяют поведение:
Supplier<A> — фабрика начального состояния. Вызывается один раз для каждого сегмента обработки (при параллельном выполнении — для каждого потока). Создаёт пустой контейнер, готовый к наполнению.
BiConsumer<A, T> accumulator — функция накопления. Добавляет элемент потока в контейнер. Вызывается для каждого элемента, не требует возврата значения — мутирует контейнер.
BinaryOperator<A> — функция слияния. Объединяет два контейнера в один при параллельном выполнении. Критична для корректности parallelStream: без неё частичные результаты останутся разрозненными.
Function<A, R> finisher — финальное преобразование. Превращает контейнер аккумуляции в конечный результат. Для toList() это тождественное преобразование (list -> list), но для сложных коллекторов может быть нетривиальным: например, collectingAndThen оборачивает результат в unmodifiable коллекцию.
Характеристики коллектора: оптимизационные подсказки
Третий параметр Collector — Set<Characteristics> — метаданные, влияющие на исполнение:
CONCURRENT — сигнализирует, что один контейнер может безопасно наполняться из нескольких потоков одновременно. При наличии этой характеристики фреймворк создаёт единственный контейнер и вызывает accumulator из множества потоков без предварительного разделения. Пример: Collector.of(ConcurrentHashMap::new, ..., ..., Characteristics.CONCURRENT).
UNORDERED — гарантия, что порядок элементов в результате не важен. Позволяет оптимизировать параллельное выполнение, устраняя накладные расходы на сохранение порядка при слиянии сегментов. toSet() имеет эту характеристику, toList() — нет.
IDENTITY_FINISH — утверждение, что finisher является тождественным преобразованием (A совпадает с R). Фреймворк может пропустить вызов finisher, возвращая контейнер напрямую. Это оптимизация, но также контракт: если объявить эту характеристику и предоставить нетривиальный finisher, получим ClassCastException при попытке привести A к R.
#Java #для_новичков #beginner #stream_api #Collectors #toMap
Глава 4: Искусство агрегации. Коллекторы и группировки
Коллектор — это рецепт агрегации
Предыдущие главы посвящены преобразованиям потоков: фильтрации, отображению, уплощению. Но поток — эфемерная абстракция. Чтобы извлечь из него ценность, нужно свернуть его в конкретную структуру данных: список, множество, карту, число, строку. Эту свёртку выполняет Collector — центральный интерфейс терминальных операций агрегации.
Collector — не просто утилита для создания List. Это декларативный рецепт, описывающий, как создать контейнер для результатов, как наполнять его элементами, как объединять частичные результаты при параллельном выполнении, и как выполнить финальное преобразование. Понимание его компонентов раскрывает полную мощь Stream API.
Анатомия Collector
Интерфейс Collector<T, A, R> параметризован тремя типами: T — тип элементов входного потока, A — тип промежуточного контейнера аккумуляции, R — тип конечного результата.
Четыре функциональных компонента определяют поведение:
Supplier<A> — фабрика начального состояния. Вызывается один раз для каждого сегмента обработки (при параллельном выполнении — для каждого потока). Создаёт пустой контейнер, готовый к наполнению.
// Для toList(): () -> new ArrayList<T>()
// Для toSet(): () -> new HashSet<T>()
// Для toMap(): () -> new HashMap<K, V>()
BiConsumer<A, T> accumulator — функция накопления. Добавляет элемент потока в контейнер. Вызывается для каждого элемента, не требует возврата значения — мутирует контейнер.
// Для toList(): (list, element) -> list.add(element)
// Для toMap(): (map, element) -> map.put(keyExtractor.apply(element), valueExtractor.apply(element))
BinaryOperator<A> — функция слияния. Объединяет два контейнера в один при параллельном выполнении. Критична для корректности parallelStream: без неё частичные результаты останутся разрозненными.
// Для toList(): (left, right) -> { left.addAll(right); return left; }
// Для toSet(): (left, right) -> { left.addAll(right); return left; }Function<A, R> finisher — финальное преобразование. Превращает контейнер аккумуляции в конечный результат. Для toList() это тождественное преобразование (list -> list), но для сложных коллекторов может быть нетривиальным: например, collectingAndThen оборачивает результат в unmodifiable коллекцию.
// collectingAndThen(toList(), Collections::unmodifiableList)
// finisher: list -> Collections.unmodifiableList(list)
Характеристики коллектора: оптимизационные подсказки
Третий параметр Collector — Set<Characteristics> — метаданные, влияющие на исполнение:
CONCURRENT — сигнализирует, что один контейнер может безопасно наполняться из нескольких потоков одновременно. При наличии этой характеристики фреймворк создаёт единственный контейнер и вызывает accumulator из множества потоков без предварительного разделения. Пример: Collector.of(ConcurrentHashMap::new, ..., ..., Characteristics.CONCURRENT).
UNORDERED — гарантия, что порядок элементов в результате не важен. Позволяет оптимизировать параллельное выполнение, устраняя накладные расходы на сохранение порядка при слиянии сегментов. toSet() имеет эту характеристику, toList() — нет.
IDENTITY_FINISH — утверждение, что finisher является тождественным преобразованием (A совпадает с R). Фреймворк может пропустить вызов finisher, возвращая контейнер напрямую. Это оптимизация, но также контракт: если объявить эту характеристику и предоставить нетривиальный finisher, получим ClassCastException при попытке привести A к R.
#Java #для_новичков #beginner #stream_api #Collectors #toMap
👍4
Конструирование собственного коллектора
Понимание компонентов позволяет создавать специализированные коллекторы. Рассмотрим задачу: собрать строки в единую строку с ограничением длины, добавляя "[truncated]" при превышении.
Этот коллектор демонстрирует все компоненты: мутабельный контейнер (StringBuilderWithFlag), накопление с условной логикой, сложное слияние при параллелизме, финальное преобразование. Характеристика UNORDERED разрешает оптимизации, но требует корректной реализации combiner для любого порядка элементов.
toMap: мощь и ловушки
Коллектор toMap — один из наиболее полезных и опасных. Он превращает поток пар ключ-значение в Map, но содержит скрытые предположения, нарушение которых ведёт к исключениям.
Ловушка первая: коллизии ключей
При отсутствии функции разрешения коллизий toMap бросает IllegalStateException, если встречает дубликат ключа:
Решение — явная функция разрешения (oldValue, newValue) -> ...:
Последний паттерн настолько распространён, что имеет специализированную реализацию: groupingBy(Book::author, toList()).
#Java #для_новичков #beginner #stream_api #Collectors #toMap
Понимание компонентов позволяет создавать специализированные коллекторы. Рассмотрим задачу: собрать строки в единую строку с ограничением длины, добавляя "[truncated]" при превышении.
public static Collector<String, ?, String> joiningWithLimit(int maxLength) {
class StringBuilderWithFlag {
StringBuilder builder = new StringBuilder();
boolean truncated = false;
}
return Collector.of(
StringBuilderWithFlag::new, // supplier
(container, str) -> { // accumulator
if (container.truncated) return;
if (container.builder.length() + str.length() > maxLength) {
container.builder.append("[truncated]");
container.truncated = true;
} else {
if (container.builder.length() > 0) container.builder.append(", ");
container.builder.append(str);
}
},
(left, right) -> { // combiner
if (left.truncated) return left;
if (right.truncated) {
left.builder.append("[truncated]");
left.truncated = true;
return left;
}
if (left.builder.length() + right.builder.length() > maxLength) {
left.builder.append(", ").append(right.builder).append("[truncated]");
left.truncated = true;
} else {
if (left.builder.length() > 0) left.builder.append(", ");
left.builder.append(right.builder);
}
return left;
},
container -> container.builder.toString(), // finisher
Characteristics.UNORDERED // порядок не важен
);
}
// Использование
String summary = tags.stream()
.collect(joiningWithLimit(100));Этот коллектор демонстрирует все компоненты: мутабельный контейнер (StringBuilderWithFlag), накопление с условной логикой, сложное слияние при параллелизме, финальное преобразование. Характеристика UNORDERED разрешает оптимизации, но требует корректной реализации combiner для любого порядка элементов.
toMap: мощь и ловушки
Коллектор toMap — один из наиболее полезных и опасных. Он превращает поток пар ключ-значение в Map, но содержит скрытые предположения, нарушение которых ведёт к исключениям.
Ловушка первая: коллизии ключей
При отсутствии функции разрешения коллизий toMap бросает IllegalStateException, если встречает дубликат ключа:
// Опасно: бросит исключение при повторяющемся авторе
Map<String, Book> bookByAuthor = books.stream()
.collect(toMap(Book::author, book -> book)); // Crash на второй книге того же автора
Решение — явная функция разрешения (oldValue, newValue) -> ...:
// Сохраняем первую встреченную книгу автора
Map<String, Book> firstByAuthor = books.stream()
.collect(toMap(
Book::author,
book -> book,
(existing, replacement) -> existing // Игнорируем повторы
));
// Сохраняем последнюю, обновляем
Map<String, Book> lastByAuthor = books.stream()
.collect(toMap(
Book::author,
book -> book,
(oldBook, newBook) -> newBook // Перезаписываем
));
// Агрегируем: список всех книг автора
Map<String, List<Book>> allByAuthor = books.stream()
.collect(toMap(
Book::author,
book -> new ArrayList<>(List.of(book)), // Создаём список из одной книги
(existingList, newList) -> {
existingList.addAll(newList); // Добавляем к существующему
return existingList;
}
));
Последний паттерн настолько распространён, что имеет специализированную реализацию: groupingBy(Book::author, toList()).
#Java #для_новичков #beginner #stream_api #Collectors #toMap
👍3🔥2
Ловушка вторая: null-значения
Стандартные реализации Map в Java (HashMap, TreeMap) не поддерживают null в качестве ключа или значения при определённых операциях, а toMap внутри использует Map.merge, который отклоняет null-значения выбросом NullPointerException.
Решения: фильтрация null до коллектора, обёртка в Optional, использование специального значения-заполнителя:
Ловушка третья: изменяемые ключи
Если ключом Map становится объект, чей hashCode или equals зависят от изменяемого состояния, целостность Map разрушается при модификации ключа после вставки. Это не специфика toMap, но частая ошибка при потоковой агрегации:
Правило: ключи в toMap должны быть неизменяемыми (immutable), с стабильными hashCode и equals.
Композиция коллекторов: building blocks
Коллекторы проектируются для композиции.
Collectors предоставляет адаптеры, оборачивающие базовые коллекторы в более сложные:
collectingAndThen — применяет функцию к результату коллектора:
filtering — предварительная фильтрация перед коллектором (Java 9+):
mapping — трансформация элемента перед передачей downstream коллектору:
Эти примитивы композиции позволяют строить сложные агрегации без явного создания собственных коллекторов, сохраняя декларативность и читаемость.
#Java #для_новичков #beginner #stream_api #Collectors #toMap
Стандартные реализации Map в Java (HashMap, TreeMap) не поддерживают null в качестве ключа или значения при определённых операциях, а toMap внутри использует Map.merge, который отклоняет null-значения выбросом NullPointerException.
// Опасно: NPE если book.getDescription() возвращает null
Map<String, String> descriptions = books.stream()
.collect(toMap(Book::title, Book::description)); // Crash на null
Решения: фильтрация null до коллектора, обёртка в Optional, использование специального значения-заполнителя:
// Фильтрация
Map<String, String> validDescriptions = books.stream()
.filter(b -> b.description() != null)
.collect(toMap(Book::title, Book::description));
// Обёртка в Optional (требует адаптации типа)
Map<String, Optional<String>> optionalDescriptions = books.stream()
.collect(toMap(
Book::title,
b -> Optional.ofNullable(b.description())
));
// Заполнитель с последующей фильтрацией
String NULL_MARKER = "\u0000";
Map<String, String> markedDescriptions = books.stream()
.collect(toMap(
Book::title,
b -> b.description() != null ? b.description() : NULL_MARKER
));
// При использовании: if (!value.equals(NULL_MARKER))
Ловушка третья: изменяемые ключи
Если ключом Map становится объект, чей hashCode или equals зависят от изменяемого состояния, целостность Map разрушается при модификации ключа после вставки. Это не специфика toMap, но частая ошибка при потоковой агрегации:
// Опасно: ключ — изменяемый объект
record MutableKey(String name) {
public void setName(String name) { this.name = name; } // Mutable!
}
Map<MutableKey, Book> map = books.stream()
.collect(toMap(book -> new MutableKey(book.author()), book -> book));
// Последующая модификация ключа делает Map некорректным
map.keySet().iterator().next().setName("Changed"); // Неопределённое поведение
Правило: ключи в toMap должны быть неизменяемыми (immutable), с стабильными hashCode и equals.
Композиция коллекторов: building blocks
Коллекторы проектируются для композиции.
Collectors предоставляет адаптеры, оборачивающие базовые коллекторы в более сложные:
collectingAndThen — применяет функцию к результату коллектора:
// Немодифицируемый список
List<Book> unmodifiable = books.stream()
.collect(collectingAndThen(toList(), Collections::unmodifiableList));
// Строка из списка с префиксом и суффиксом
String joined = books.stream()
.map(Book::title)
.collect(collectingAndThen(
joining(", "),
str -> "Titles: " + str + "."
));
filtering — предварительная фильтрация перед коллектором (Java 9+):
// Группировка только ненулевых описаний по жанру
Map<Genre, List<String>> descriptionsByGenre = books.stream()
.collect(groupingBy(
Book::genre,
filtering(
b -> b.description() != null,
mapping(Book::description, toList())
)
));
mapping — трансформация элемента перед передачей downstream коллектору:
// Средняя цена по авторам (автор -> средняя цена его книг)
Map<String, Double> avgPriceByAuthor = books.stream()
.collect(groupingBy(
Book::author,
mapping(Book::price, averagingDouble(BigDecimal::doubleValue))
));
Эти примитивы композиции позволяют строить сложные агрегации без явного создания собственных коллекторов, сохраняя декларативность и читаемость.
#Java #для_новичков #beginner #stream_api #Collectors #toMap
👍5