Коллекторы потоков 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:
| Результат | При дублирующемся ключе | |
|---|---|---|
toMap | Map<K, V> (одно V на K) | Выбрасывает исключение, если не задан объединитель |
groupingBy | Map<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 и применяет к нему каждую форму коллектора.
Что стоит отметить по результатам выполнения:
- Незащищённый
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), и как определить, действительно ли параллелизм помогает или делает программу медленнее.