Коллекторы и группировка в 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, остальные разбираются в тренажёре.
- Что произойдёт при 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 — классическая причина падения при, казалось бы, безобидной сборке в карту.
- Что делает Collectors.joining(", ", "[", "]")?A)Разбивает каждую строку по запятой и собирает получившиеся части в один общий плоский списокB)Соединяет элементы, но только если каждый заключён в квадратные скобки, иначе пропускает егоC)Оборачивает каждый элемент в скобки по отдельности и возвращает список таких обёрнутых строкD)Склеивает строки через разделитель с префиксом и суффиксом
показать ответ и разбор
+D)Склеивает строки через разделитель с префиксом и суффиксом// разбор: joining(delimiter, prefix, suffix) сводит стрим строк (CharSequence) в ОДНУ строку: между элементами — разделитель, в начале — префикс, в конце — суффикс. Например, элементы a,b,c → "[a, b, c]". Есть варианты без аргументов (просто конкатенация) и только с разделителем. Под капотом использует StringBuilder — эффективно, в отличие от ручной склейки в reduce. Частый способ собрать человекочитаемое представление или CSV-строку из стрима.
- Зачем нужен 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 с агрегатными функциями.
- Чем 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.
- Чем 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.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.