W3docs

Коллекторы потоков Java

Сворачивайте потоки Java в коллекции и другие результаты с помощью java.util.stream.Collectors.

collect — это терминальная операция, которую мы откладывали. Она принимает Collector<T, A, R> — рецепт накопления элементов потока в результат R через промежуточный контейнер A — и выполняет его. Рецепты живут в фабричном классе java.util.stream.Collectors и охватывают большинство того, что иначе пришлось бы писать вручную с помощью цикла for, Map и нескольких вызовов compute*. Когда вы научитесь читать groupingBy(..., counting()), API перестанет казаться загадочным.

В главе инструменты рассматриваются по тому, каким должен быть результат: список, множество, карта, одно число, строка или — через паттерн нисходящего коллектора — вложенная комбинация любого из них.

Списки, множества и конкретные коллекции

Два базовых варианта:

List<String> list = words.stream().collect(Collectors.toList());
Set<String>  set  = words.stream().collect(Collectors.toSet());

Заметки:

  • Collectors.toList() возвращает некоторый List — как правило, изменяемый, но без гарантий. Для неизменяемой формы, которая нужна большую часть времени, используйте stream.toList() (терминальная операция, а не коллектор).
  • Collectors.toSet() не упорядочен — как правило, HashSet. Если нужен стабильный порядок итерации, запросите его явно через toCollection(LinkedHashSet::new).
  • Collectors.toUnmodifiableList() и toUnmodifiableSet() (Java 10+) возвращают неизменяемые результаты — это эквиваленты stream.toList() в форме коллектора.

Для конкретной реализации используйте toCollection:

ArrayDeque<String> queue = words.stream()
    .collect(Collectors.toCollection(ArrayDeque::new));

TreeSet<String> sorted = words.stream()
    .collect(Collectors.toCollection(TreeSet::new));

Поставщик — это ссылка на конструктор; коллектор подключает его, сливает в него поток и возвращает результат.

toMap — связать каждый элемент с ключом

toMap(keyMapper, valueMapper) превращает каждый элемент в Map.Entry и накапливает их:

Map<String, Integer> nameAge = people.stream()
    .collect(Collectors.toMap(Person::name, Person::age));

Дублирующиеся ключи выбрасывают IllegalStateException. Это единственное правило, которое сбивает с толку всех при первом использовании. Если два объекта Person имеют одинаковое имя, toMap по умолчанию вылетает. Решение — перегрузка с функцией слияния:

Map<String, Integer> sumAgePerName = people.stream()
    .collect(Collectors.toMap(
        Person::name,
        Person::age,
        Integer::sum));                 // merge: existingAge + newAge

Для конкретного типа карты — LinkedHashMap для сохранения порядка вставки, TreeMap для сортировки ключей — передайте поставщик:

Map<String, Integer> ordered = people.stream()
    .collect(Collectors.toMap(
        Person::name, Person::age,
        (a, b) -> a,                    // keep first on collision
        LinkedHashMap::new));

toUnmodifiableMap — неизменяемый вариант (Java 10+).

groupingBy — разбиение на группы по ключу

Коллектор, к которому прибегают все, когда понимают, что toMap — не тот инструмент:

Map<String, List<Person>> byRole = people.stream()
    .collect(Collectors.groupingBy(Person::role));

Для каждого элемента классификатор вырабатывает ключ, и элемент добавляется в группу этого ключа (нисходящий коллектор по умолчанию: toList()). Сравнение с toMap:

РезультатПри дублирующемся ключе
toMapMap<K, V> (одно V на K)Выбрасывает исключение, если не задан объединитель
groupingByMap<K, List<V>> (группа на K)Добавляет в группу

Используйте toMap, когда по задумке на ключ приходится не более одного значения (id → строка, код → метка). Используйте groupingBy, когда значений может быть много.

Вся мощь groupingBy раскрывается через параметр downstream (нисходящий), который указывает, что делать с элементами, имеющими общий ключ. По умолчанию это toList; его можно заменить другим коллектором — и этот коллектор сам может быть groupingBy. Раздел «downstream» далее в главе открывает API по-настоящему.

partitioningBy — разбиение по предикату

Специализированный groupingBy для бинарных предикатов. Возвращает Map<Boolean, List<T>>:

Map<Boolean, List<Person>> adultsOrNot = people.stream()
    .collect(Collectors.partitioningBy(p -> p.age() >= 18));

List<Person> adults  = adultsOrNot.get(true);
List<Person> minors  = adultsOrNot.get(false);

partitioningBy всегда содержит оба ключа — true и false, даже если одна группа пуста. Это единственное преимущество перед groupingBy(p -> p.age() >= 18) — который не включал бы ключ, если группа пуста.

Как и groupingBy, partitioningBy принимает нисходящий коллектор.

counting, summingInt, averagingDouble, minBy, maxBy

Нисходящие коллекторы, выдающие одно число на группу:

Map<String, Long> headcount = people.stream()
    .collect(Collectors.groupingBy(Person::role, Collectors.counting()));

Map<String, Integer> totalAgePerRole = people.stream()
    .collect(Collectors.groupingBy(Person::role,
             Collectors.summingInt(Person::age)));

Map<String, Double> avgAgePerRole = people.stream()
    .collect(Collectors.groupingBy(Person::role,
             Collectors.averagingDouble(Person::age)));

Map<String, Optional<Person>> oldestPerRole = people.stream()
    .collect(Collectors.groupingBy(Person::role,
             Collectors.maxBy(Comparator.comparingInt(Person::age))));
  • counting()Long, размер группы.
  • summingInt/Long/Double(toX) — сумма проецируемого примитива.
  • averagingInt/Long/Double(toX) — среднее значение Double.
  • minBy(cmp) / maxBy(cmp) — экстремум Optional<T>.
  • summarizingInt/Long/Double(toX)IntSummaryStatistics и т.д., полный набор count/sum/min/max/average.

joining — конкатенация строк

Для потоков CharSequence:

String csv = words.stream().collect(Collectors.joining(","));
String pretty = words.stream().collect(Collectors.joining(", ", "[", "]"));

Три перегрузки: без аргументов (простая конкатенация), с разделителем, с разделителем + префиксом + суффиксом. Быстрее, чем reduce("", String::concat), потому что использует StringBuilder под капотом и не выделяет память квадратично. Это правильный инструмент, когда результатом конвейера должна быть одна строка.

mapping — преобразование перед сбором

Оборачивает другой коллектор так, чтобы элементы сначала преобразовывались. Чаще всего используется внутри groupingBy, когда нужно группировать по одному признаку, но собирать проекцию элементов, а не сами элементы:

Map<String, List<String>> namesByRole = people.stream()
    .collect(Collectors.groupingBy(
        Person::role,
        Collectors.mapping(Person::name, Collectors.toList())));

Без mapping нисходящий toList() собирал бы целые объекты Person; с mapping(Person::name, ...) собираются только имена. Используйте его всякий раз, когда иначе пришлось бы писать groupingBy(...).entrySet().stream().map(...).collect(...) в два прохода подряд.

filtering (Java 9+) — соответствующая обёртка «отбросить некоторые перед сбором»:

Map<String, List<Person>> adultsByRole = people.stream()
    .collect(Collectors.groupingBy(
        Person::role,
        Collectors.filtering(p -> p.age() >= 18, Collectors.toList())));

Отличие от stream.filter(...) до коллектора: filtering сохраняет ключ в результирующей карте, даже если ни один элемент не прошёл — его группа просто пуста.

reducing — полная общая редукция в виде коллектора

Форма коллектора для reduce, применяется как нисходящий коллектор, когда стандартные не подходят:

Map<String, Optional<Person>> oldestPerRole = people.stream()
    .collect(Collectors.groupingBy(
        Person::role,
        Collectors.reducing(BinaryOperator.maxBy(Comparator.comparingInt(Person::age)))));

Есть три перегрузки (одноаргументная, двухаргументная с идентичностью, трёхаргументная с идентичностью + маппером + аккумулятором), соответствующие трём формам reduce из предыдущей главы. Двухаргументная форма наиболее распространена как нисходящий коллектор, поскольку возвращает обычный T вместо Optional<T>.

Редко приходится писать reducing наверху конвейера — для этого есть терминальная операция reduce. Его пишут как нисходящий коллектор для groupingBy/partitioningBy, когда нужна редукция внутри каждой группы.

collectingAndThen — постобработка результата

Оборачивает коллектор завершающей функцией. Стандартное применение — сделать собранный List/Map неизменяемым или извлечь финальное значение из результата summarizing*:

List<String> immutableNames = people.stream()
    .map(Person::name)
    .collect(Collectors.collectingAndThen(
        Collectors.toList(),
        Collections::unmodifiableList));

Map<String, Long> immutableCounts = people.stream()
    .collect(Collectors.collectingAndThen(
        Collectors.groupingBy(Person::role, Collectors.counting()),
        Collections::unmodifiableMap));

Также это способ превратить результат groupingBy(..., minBy(...)) из Optional<T> в обычное значение — завершающая функция разворачивает Optional с известным значением по умолчанию.

teeing — запуск двух коллекторов в один проход

(Java 12+) Направляет каждый элемент в два коллектора одновременно и объединяет их результаты:

record Range(int min, int max) {}
Range range = nums.stream()
    .collect(Collectors.teeing(
        Collectors.minBy(Integer::compare),
        Collectors.maxBy(Integer::compare),
        (lo, hi) -> new Range(lo.orElseThrow(), hi.orElseThrow())));

Оба дочерних коллектора видят каждый элемент; объединитель получает два их результата. Полезно, когда иначе пришлось бы проходить поток дважды — например, вычислить среднее и найти выбросы.

Выбор подходящего коллектора

Желаемый результатИспользуйте
List<T> (неизменяемый, распространённый случай)stream.toList() (терминальная операция)
List<T> (изменяемый)Collectors.toList() или toCollection(ArrayList::new)
Set<T>Collectors.toSet() или toCollection(LinkedHashSet::new)
Конкретная коллекцияCollectors.toCollection(supplier)
Map<K, V> один к одномуCollectors.toMap(k, v) (+ функция слияния при необходимости)
Map<K, List<T>> группыCollectors.groupingBy(k)
Map<Boolean, List<T>>Collectors.partitioningBy(pred)
Одна строкаCollectors.joining(delim, pre, suf)
Счётчик/сумма/среднее по группеgroupingBy(k, counting() / summingInt(...) / ...)
Проекция по группеgroupingBy(k, mapping(proj, toList()))
Экстремум по группеgroupingBy(k, minBy(cmp) / maxBy(cmp))
Произвольная редукция по группеgroupingBy(k, reducing(...))
Два результата за один проходCollectors.teeing(c1, c2, merger)
Сделать результат неизменяемымобернуть в collectingAndThen(c, Collections::unmodifiableList)

Практический пример: все коллекторы на одном наборе данных

Программа ниже создаёт список записей Person и применяет к нему каждую форму коллектора.

java— editable, runs on the server

Что стоит отметить по результатам выполнения:

  • Незащищённый toMap(Person::name, Person::age) в конце выбросил IllegalStateException, потому что два объекта Person имеют имя "Alice". Стандартное решение — третий аргумент: BinaryOperator<V>, указывающий, как объединять значения при коллизии ключей. Выберите функцию слияния под свою семантику (оставить первый, оставить последний, сложить, конкатенировать) — именно так поступил более ранний вызов ageByName с (a, b) -> a.
  • groupingBy(Person::role) бесплатно создал Map<String, List<Person>>. Замена нисходящего toList() по умолчанию на counting(), summingInt(...), averagingDouble(...) или maxBy(...) превратила результат в каждой группе из «списка» в одно число — та же форма конвейера, другой рецепт в слоте нисходящего коллектора.
  • mapping(Person::name, toList()) — ответ на вопрос «я хочу группировать по роли, но в группах должны быть только имена, а не целые объекты Person.» Предварительное проецирование в нисходящем коллекторе почти всегда чище, чем сбор целых записей с последующим маппингом значений.
  • partitioningBy вернул оба ключа — true и false — даже когда одна половина могла быть пустой. Это предсказуемое поведение и есть его raison d'être по сравнению с groupingBy(predicate).
  • teeing собрал min и max за один проход, затем передал оба Optional объединителю, который создал запись Range. Всякий раз, когда иначе пришлось бы делать два прохода ради двух сводных значений, используйте teeing.
  • collectingAndThen(toList(), Collections::unmodifiableList) — классический трюк с завершающей функцией; та же форма разворачивает groupingBy(..., maxBy(...)) из Map<K, Optional<V>> в Map<K, V>, когда уже доказано, что каждая группа непуста.

Что дальше

Все коллекторы и промежуточные операции в этой части по умолчанию выполняются последовательно — по одному элементу за раз, в порядке встречи, в вызывающем потоке. В следующей главе, Java Parallel Streams, рассматривается альтернативное планирование — parallelStream() и stream().parallel() — что безопасно помещать в параллельный конвейер, а что нет (изменение общего состояния, чувствительный к порядку forEach, неассоциативный reduce), и как определить, действительно ли параллелизм помогает или делает программу медленнее.

Практика

Практика
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` выбрасывает `IllegalStateException`, когда два объекта `Person` имеют одинаковое имя. Какое исправление соответствует намерению 'суммировать возраст при коллизии имён'?
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` выбрасывает `IllegalStateException`, когда два объекта `Person` имеют одинаковое имя. Какое исправление соответствует намерению 'суммировать возраст при коллизии имён'?
Was this page helpful?