сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Stream API

Коллекторы и группировка в Java

Коллекторы: группировка и агрегация за один проход

Собрал четыре заказа по городам с суммами одним выражением: groupingBy(Order::city, summingInt(Order::total)). Ответ - {Казань=50, Анапа=20, Москва=400}, один проход, ни одного ручного цикла с мапой-накопителем.

Ради этого стримы и держат в проде. На собесе дают практическую задачу вроде «сгруппируй заказы по городу и посчитай сумму», и по решению видно, умеешь ли ты вкладывать коллекторы друг в друга.

// Формулировки: «сгруппировать и агрегировать одним проходом», «чем partitioningBy отличается от groupingBy?», «что будет с toMap на дубликате ключа?».

toList, toMap и две мины под ними

Собрал один и тот же стрим двумя способами. stream.toList() вернул объект класса ListN, и попытка добавить элемент дала UnsupportedOperationException. collect(toList()) вернул обычный ArrayList, и добавление прошло. Разница появилась в Java 16 и регулярно всплывает на код-ревью.

Дальше toMap, у которого две мины. Первая: собрал три имени по первой букве - «аня», «антон», «борис» - и получил IllegalStateException: Duplicate key а (attempted merging values аня and антон). На тестовых данных ключи уникальны, на проде нет. Лечится третьим аргументом - функцией слияния: toMap(k, v, (a, b) -> b) означает «побеждает последний», и тогда вышло {а=антон, б=борис}.

Вторая мина - null. Значение null в toMap даёт NullPointerException при сборке. И то же самое, я проверил, делает groupingBy при null в КЛЮЧЕ. Так что сначала фильтр или подстановка значения по умолчанию, потом сборка.

users.stream().collect(toMap(User::email, u -> u));
// IllegalStateException: Duplicate key а
//   (attempted merging values аня and антон)

users.stream().collect(toMap(User::email, u -> u, (a, b) -> b));  // побеждает последний
функция слияния в toMap
третий аргумент: что делать при совпадении ключей

groupingBy и вложенный коллектор

groupingBy с одним аргументом делает то же, что GROUP BY в SQL: раскладывает элементы по ключу и отдаёт Map<Ключ, List<Элемент>>. Вся сила во втором аргументе - вложенном коллекторе, который агрегирует ВНУТРИ каждой группы тем же проходом. counting() даст размер группы, summingInt и averagingDouble - сумму и среднее, mapping(f, toList()) сначала преобразует элементы, потом соберёт.

Сравни две почти одинаковые строки на одних данных [1, 2, 3] с условием «больше ста». partitioningBy дал {false=[1, 2, 3], true=[]} - ОБЕ ветки, включая пустую. groupingBy с тем же условием дал {false=[1, 2, 3]} - только одну. Поэтому после partitioningBy можно смело брать get(true) и не проверять на null, а после groupingBy - нельзя.

// Порядок групп не гарантирован: groupingBy собирает в HashMap, и мои города вышли как Казань, Анапа, Москва - ни по алфавиту, ни в порядке появления. Нужен порядок - подставь фабрику мапы вторым аргументом: groupingBy(Order::city, TreeMap::new, summingInt(Order::total)) дал Анапа, Казань, Москва.

вложенный коллектор
второй аргумент groupingBy: агрегация внутри каждой группы
partitioningBy
деление надвое по условию, обе ветки есть всегда

Два агрегата за один проход и сборка строк

Понадобились сразу количество и сумма - взял teeing: он пропускает элементы через ДВА коллектора одновременно и склеивает их результаты своей функцией. На моих четырёх заказах вышло «4 шт на 470» за один проход вместо двух отдельных обходов данных. Появился в Java 12.

Для строк есть joining: joining(", ", "[", "]") собрал города в [Москва, Казань, Анапа] - с разделителем и обрамлением, без ручного StringBuilder и проверок «а это последний элемент или нет».

// Свой коллектор с нуля пишут редко, но устройство знать полезно: он состоит из поставщика пустого накопителя, функции добавления элемента, функции слияния двух накопителей и финальной обработки. Функция слияния вступает в дело только в параллельном случае - и именно поэтому её нельзя писать наугад.

teeing
два коллектора за один проход плюс склейка результатов
joining
сборка строк с разделителем и обрамлением

Как отвечать: «Заказы: по городу - количество и сумма. Как соберёшь?»

Одним проходом через groupingBy с вложенным коллектором. Если нужна только сумма - groupingBy(Order::city, summingInt(Order::total)), на моих данных это сразу дало Казань 50, Анапа 20, Москва 400. Нужны обе метрики - ставлю внутрь teeing: он гонит counting и summingInt по одним и тем же элементам и сливает два результата в один объект, у меня вышло «4 штуки на 470» за один обход. Если города должны идти по алфавиту, добавляю фабрику TreeMap::new вторым аргументом, потому что по умолчанию собирается HashMap и порядок там произвольный - я проверял, города вышли не по алфавиту и не в порядке появления. А если бы нужен был просто список заказов по городам, хватило бы groupingBy без второго аргумента.

Почему это сильный ответ: задача решена за один проход с вложением коллекторов, и отдельно назван порядок групп - деталь, которая отличает пользователя библиотеки от человека, понимающего её устройство.

На чём валят

  • toMap без функции слияния. Первый же дубликат ключа в проде даёт IllegalStateException с текстом Duplicate key.
  • null в toMap или в ключе groupingBy. И то и другое даёт NullPointerException при сборке - фильтруй заранее.
  • Считать порядок групп у groupingBy стабильным. Это HashMap; нужен порядок - фабрика TreeMap::new вторым аргументом.
  • Два прохода по данным ради двух агрегатов. teeing или вложенный коллектор делают это за один.
  • Собирать группы в списки только чтобы взять их размер. counting() как вложенный коллектор не создаёт списков вообще.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 20, остальные разбираются в тренажёре.

  1. #collectors_grouping1 / 5
    Что произойдёт при Collectors.toMap с двумя элементами, дающими одинаковый ключ?
    A)Второй элемент молча перезапишет первый, и в карте останется значение последнего по этому ключу
    B)Оба значения автоматически сложатся в список, и по ключу будет храниться List из двух элементов
    C)IllegalStateException (duplicate key), если не задать функцию слияния
    D)Первый элемент останется, а второй будет тихо отброшен без какого-либо исключения или сообщения
    показать ответ и разбор
    +C)IllegalStateException (duplicate key), если не задать функцию слияния

    // разбор: toMap(keyFn, valueFn) с ДВУМЯ элементами, дающими один ключ, бросает IllegalStateException: Duplicate key — это защита от незаметной потери данных. Чтобы разрешить конфликт, передают третий аргумент — mergeFunction (например, (a, b) -> b — взять последнее, или a + b — сложить). Если по смыслу нужен список значений на ключ — берут groupingBy, а не toMap. Забытый merge — классическая причина падения при, казалось бы, безобидной сборке в карту.

  2. #collectors_grouping2 / 5
    Что делает Collectors.joining(", ", "[", "]")?
    A)Разбивает каждую строку по запятой и собирает получившиеся части в один общий плоский список
    B)Соединяет элементы, но только если каждый заключён в квадратные скобки, иначе пропускает его
    C)Оборачивает каждый элемент в скобки по отдельности и возвращает список таких обёрнутых строк
    D)Склеивает строки через разделитель с префиксом и суффиксом
    показать ответ и разбор
    +D)Склеивает строки через разделитель с префиксом и суффиксом

    // разбор: joining(delimiter, prefix, suffix) сводит стрим строк (CharSequence) в ОДНУ строку: между элементами — разделитель, в начале — префикс, в конце — суффикс. Например, элементы a,b,c → "[a, b, c]". Есть варианты без аргументов (просто конкатенация) и только с разделителем. Под капотом использует StringBuilder — эффективно, в отличие от ручной склейки в reduce. Частый способ собрать человекочитаемое представление или CSV-строку из стрима.

  3. #collectors_grouping3 / 5
    Зачем нужен downstream-коллектор во втором аргументе groupingBy?
    A)Задать, как агрегировать элементы внутри каждой группы (счёт, сумма, mapping)
    B)Чтобы отсортировать сами группы по ключу перед тем, как вернуть итоговую карту результата
    C)Чтобы ограничить число групп в результирующей карте заданным сверху числом самых крупных групп
    D)Чтобы задать тип возвращаемой карты (HashMap или TreeMap); на содержимое групп он не влияет
    показать ответ и разбор
    +A)Задать, как агрегировать элементы внутри каждой группы (счёт, сумма, mapping)

    // разбор: У groupingBy есть форма (classifier, downstream): downstream-коллектор говорит, ЧТО делать со значениями внутри каждой группы вместо сборки в List по умолчанию. Так одной строкой считают: counting() — число в группе; summingInt/averagingDouble — сумму/среднее; mapping(f, toList()) — преобразованные значения; maxBy/minBy; reducing(...). Downstream можно вкладывать (группировка внутри группировки). Это превращает стримы в мощный инструмент агрегаций, близкий к SQL GROUP BY с агрегатными функциями.

  4. #collectors_grouping4 / 5
    Чем partitioningBy отличается от groupingBy?
    A)PartitioningBy делит стрим на произвольное число групп по значению, как и groupingBy, отличий по сути нет
    B)Делит ровно на две группы по предикату (ключи true/false)
    C)PartitioningBy случайным образом распределяет элементы по нескольким разделам для параллельной обработки
    D)PartitioningBy сортирует элементы на две половины по медиане, а groupingBy группирует по равенству ключа
    показать ответ и разбор
    +B)Делит ровно на две группы по предикату (ключи true/false)

    // разбор: partitioningBy(predicate) — частный случай группировки на ДВЕ группы по булеву условию: возвращает Map<Boolean, List<T>> с ГАРАНТИРОВАННО присутствующими ключами true и false (даже если одна группа пуста — там пустой список). groupingBy делит на произвольное число групп по значению классификатора и ключ есть только для встретившихся значений. Когда нужно «прошедшие/непрошедшие проверку» — partitioningBy читаемее и предсказуемее (оба ключа всегда есть), плюс поддерживает downstream.

  5. #collectors_grouping5 / 5
    Чем reduce отличается от collect как способ сведения стрима?
    A)Reduce работает только с числами, а collect — только с объектами, поэтому они не пересекаются
    B)Collect можно применять лишь к последовательным стримам, а reduce — только к параллельным
    C)reduce сворачивает в одно неизменяемое значение; collect накапливает в изменяемый контейнер
    D)Reduce и collect эквивалентны и взаимозаменяемы без разницы в эффективности
    показать ответ и разбор
    +C)reduce сворачивает в одно неизменяемое значение; collect накапливает в изменяемый контейнер

    // разбор: reduce выполняет ИММУТАБЕЛЬНУЮ свёртку: комбинирует элементы в одно значение бинарной операцией (сумма, произведение, max) — каждый шаг возвращает новое значение. collect — MUTABLE reduction: накапливает элементы в ИЗМЕНЯЕМЫЙ контейнер (List, Map, StringBuilder) через supplier/accumulator/combiner, что эффективнее для «строящихся» структур (иначе reduce плодил бы новые контейнеры на каждый шаг — квадратично). Правило: одно значение → reduce; коллекция/строка → collect.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.