Collectors

Роль

Collector<T,A,R> описывает изменяемое свёртывание элементов Stream:

  • T — входной элемент;
  • A — внутренний аккумулятор;
  • R — итог.

Collectors предоставляет готовые фабрики таких свёрток.

Map<String, List<Order>> ordersByStatus = orders.stream()
        .collect(Collectors.groupingBy(Order::status));

Терминальная операция collect запускает конвейер. Collector знает, как создать аккумулятор, добавить элемент, объединить частичные результаты и получить итог.

Механика Collector

У коллектора есть:

  1. supplier — создаёт пустой аккумулятор;
  2. accumulator — добавляет один элемент;
  3. combiner — объединяет два аккумулятора;
  4. finisher — превращает аккумулятор в результат;
  5. characteristics — сообщает свойства, например IDENTITY_FINISH, UNORDERED, CONCURRENT.

Combiner особенно важен для параллельного потока. Операция объединения должна быть ассоциативной, а части не должны повреждать общее mutable-состояние.

Собственный collector для простого случая:

Collector<String, StringJoiner, String> csvCollector =
        Collector.of(
                () -> new StringJoiner(","),
                StringJoiner::add,
                StringJoiner::merge,
                StringJoiner::toString
        );

Сборка коллекций

List<String> names = users.stream()
        .map(User::name)
        .collect(Collectors.toList());
 
Set<String> unique = users.stream()
        .map(User::name)
        .collect(Collectors.toSet());
 
ArrayList<String> concrete = users.stream()
        .map(User::name)
        .collect(Collectors.toCollection(ArrayList::new));

Collectors.toList() не обещает конкретный класс, mutability или serializability результата. Если нужен конкретный контракт, используйте toCollection. toUnmodifiableList возвращает немодифицируемый результат и не допускает null.

Stream.toList() — отдельный терминальный метод, а не collector; он возвращает немодифицируемый список и может отличаться от Collectors.toList().

toMap и дубликаты

Map<Long, User> byId = users.stream()
        .collect(Collectors.toMap(
                User::id,
                Function.identity()
        ));

Повторный ключ без merge-функции приводит к IllegalStateException. Политику нужно выбрать явно:

Map<String, User> latestByEmail = users.stream()
        .collect(Collectors.toMap(
                User::email,
                Function.identity(),
                (left, right) -> right,
                LinkedHashMap::new
        ));

«Оставить последний» корректно только если порядок определён и является частью требования.

Группировка и downstream collector

Map<String, Long> countByStatus = orders.stream()
        .collect(Collectors.groupingBy(
                Order::status,
                Collectors.counting()
        ));
 
Map<String, Set<Long>> idsByStatus = orders.stream()
        .collect(Collectors.groupingBy(
                Order::status,
                Collectors.mapping(
                        Order::id,
                        Collectors.toSet()
                )
        ));

groupingBy группирует по произвольному ключу. partitioningBy всегда создаёт две логические группы по boolean-предикату:

Map<Boolean, List<Order>> partition = orders.stream()
        .collect(Collectors.partitioningBy(Order::paid));

Строки, числа, финальное преобразование

String joined = names.stream()
        .collect(Collectors.joining(", ", "[", "]"));
 
IntSummaryStatistics stats = orders.stream()
        .collect(Collectors.summarizingInt(Order::itemCount));
 
List<String> immutable = names.stream()
        .collect(Collectors.collectingAndThen(
                Collectors.toList(),
                List::copyOf
        ));

Для чисел также есть summingInt, averagingLong, maxBy, minBy, reducing.

Ловушки

  • Использовать toMap без политики повторных ключей.
  • Предполагать, что Collectors.toList() вернул ArrayList или неизменяемый список.
  • Путать groupingBy и partitioningBy.
  • Писать collector с неассоциативным combiner.
  • Захватывать внешнюю mutable-коллекцию в accumulator параллельного потока.
  • Делать сложную вложенную группировку, когда явный цикл легче проверить.

Вопросы на собеседовании

  1. За что отвечают supplier, accumulator, combiner и finisher?
  2. Что произойдёт при повторном ключе в toMap?
  3. Чем groupingBy отличается от partitioningBy?
  4. Какие гарантии даёт Collectors.toList()?
  5. Чем Stream.toList() отличается от Collectors.toList()?
  6. Почему combiner должен быть ассоциативным?

Связанные заметки

Stream API · Терминальные операции · ФИ стандартной библиотеки Java · Map · List · Set