Коллекторы внутри группы
Для кого. Ты прочитал статью 5:
collect,groupingBy,counting(),TreeMap::new. ПомнишьOptionalиз статьи 2, компараторы из статьи 3 иflatMapиз статьи 4.Что будет. Как получить в каждой группе то, что нужно, а не список заказов: номера, сумму, среднее, самый крупный заказ; почему
maxByотвечаетOptionalи как его снять; чемfilteringотличается отfilter; что такоеflatMappingиteeing.Откуда серия. Её пишет команда платформы Hammerhall. Задачи на стримы — в кузнице Stream Forge на платформе; здесь — то, что нужно знать, чтобы их решать. Примеры учебные: каждый запускается одним файлом.
Зачем коллекторы внутри группы
Хозяйка «Наковальни» спрашивает: сколько потратил за день каждый гость? Статья 5 дала группы — списки заказов по гостям. Остаётся пройти по каждому списку и сложить суммы:
Map<String, List<Order>> byGuest = orders.stream()
.collect(Collectors.groupingBy(Order::guest, TreeMap::new, Collectors.toList()));
Map<String, Integer> spentLoop = new TreeMap<>();
for (String guest : byGuest.keySet()) { // по каждому гостю
int sum = 0;
for (Order order : byGuest.get(guest)) { // по каждому его заказу
sum += order.total();
}
spentLoop.put(guest, sum);
}keySet() — все ключи карты. Работает, но стрим собрал
группы только затем, чтобы два цикла тут же их разобрали. Есть ещё
toMap с функцией слияния из статьи 5 — для суммы он
годится. Но попроси номера заказов списком, средний чек или самый
крупный заказ — и функция слияния станет головоломкой.
То же без циклов:
Map<String, Integer> spent = orders.stream()
.collect(Collectors.groupingBy(Order::guest, TreeMap::new, Collectors.summingInt(Order::total)));{Анна=600, Борис=960, Вера=1110, Глеб=820, Дина=650, Егор=150, Жанна=200}Последний аргумент groupingBy — коллектор для
группы (в документации — downstream, «ниже по течению»):
рецепт, что сделать с каждой группой. Первым таким был
counting() из статьи 5. Здесь — остальные.
🔑 groupingBy раскладывает заказы по стопкам, коллектор
для группы решает, что сделать с каждой стопкой: пересчитать, сложить,
взять самый крупный. Меняется вопрос — меняется только он.
Часть 1. Превратить, сложить, усреднить
Статический импорт#
Строка из «Зачем» — уже за сто символов, а дальше коллекторы будут
вкладываться друг в друга по два и по три. Collectors.
перед каждым съест строку. Поэтому с этой статьи в примерах —
статический импорт:
import static java.util.stream.Collectors.*;Обычный import разрешает писать класс без пакета,
import static — статический метод без класса:
groupingBy(…) вместо Collectors.groupingBy(…).
Звёздочка — все такие методы класса сразу. Импорт стоит вверху файла, в
«Примере целиком»; в коротких кусках его не видно.
mapping — map внутри группы#
Хозяйке нужны номера заказов каждого гостя, а не заказы целиком:
Map<String, List<Integer>> numbersByGuest = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new, mapping(Order::number, toList())));{Анна=[1, 5], Борис=[2, 9], Вера=[3, 8], Глеб=[4], Дина=[6], Егор=[7], Жанна=[10]}mapping — тот же map из статьи 1, только
внутри группы. Аргумента два: функция — во что превратить каждый элемент
группы (заказ — в номер) — и ещё один коллектор: куда сложить
превращённое. Вложенность читается слева направо: «по гостю, в
TreeMap, номера — списком».
toList() здесь — коллектор из Collectors,
пришедший статическим импортом, а не метод стрима: в группу, помнишь,
передают только коллектор.
summingInt — сложить#
summingInt складывает число, которое функция достаёт из
каждого элемента группы. Выручка с гостя в «Зачем» —
summingInt(Order::total). А вот и чашки, обещанные в статье
5:
Map<String, Integer> portionsByItem = orders.stream()
.flatMap(order -> order.items().stream())
.collect(groupingBy(Item::name, TreeMap::new, summingInt(Item::qty)));{американо=1, какао=1, капучино=4, круассан=3, латте=4, раф=1, чизкейк=3, эспрессо=3}Латте — 4: counting() считал позиции,
summingInt(Item::qty) складывает штуки. Ответ —
Integer. Для чисел long и double
есть summingLong и summingDouble.
averagingInt — среднее, и оно Double
Средний чек гостя:
Map<String, Double> averageByGuest = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new, averagingInt(Order::total)));{Анна=300.0, Борис=480.0, Вера=555.0, Глеб=820.0, Дина=650.0, Егор=150.0, Жанна=200.0}Суммы заказов целые, а ответ — Double, с
.0. Среднее целых чисел редко бывает целым, и
averagingInt его не округляет. Здесь просто повезло: у
каждого гостя суммы делятся нацело.
⚠️ Объявишь Map<String, Integer> — компилятор
ответит ошибкой того же вида, что в статье 5, только в конце первой
строки будет
incompatible equality constraints Integer,D,Double:
объявленный Integer не сходится с Double,
который отдаёт averagingInt, а D между ними —
тип значения внутри groupingBy.
Часть 2. maxBy и minBy: Optional внутри карты
Самый крупный заказ гостя#
Map<String, Optional<Order>> biggestMaybe = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new,
maxBy(Comparator.comparingInt(Order::total))));
IO.println(biggestMaybe.get("Вера"));Optional[Order[number=8, guest=Вера, items=[Item[name=латте, price=250, qty=1], Item[name=чизкейк, price=320, qty=2]]]]maxBy берёт компаратор, как max из статьи
3, и находит самый большой элемент группы; minBy — самый
маленький. В статье 3 ты так искал самый крупный заказ одной Веры, а
maxBy находит его для каждого гостя за один проход. Ответ
завёрнут в Optional: в пустом стриме максимума нет, и
maxBy, как и max, честно говорит «может не
быть».
Почему здесь Optional никогда не пуст
Вспомни статью 5: groupingBy заводит группу, только
когда в неё попал первый элемент. Пустых групп не бывает — значит, у
каждого гостя есть хотя бы один заказ, и максимум найдётся всегда.
Optional здесь — лишняя обёртка: каждый, кто достанет
значение из карты, будет снимать её заново.
Снять её один раз, прямо в коллекторе, помогает
collectingAndThen — «собери, а потом сделай с ответом вот
это»:
Map<String, Order> biggest = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new,
collectingAndThen(
maxBy(Comparator.comparingInt(Order::total)),
Optional::orElseThrow)));Аргументы — коллектор и функция, которую применят к его ответу.
Функция здесь — orElseThrow из статьи 2: «дай значение, а
нет его — брось исключение». Бросать не придётся: групп без заказов нет.
Тип значения в карте теперь просто Order.
💡 В интернете вместо Optional::orElseThrow часто пишут
Optional::get — работает так же. Документация
get() сама советует orElseThrow(): по имени
видно, что пустота здесь — ошибка.
Напечатаем:
biggest.forEach((guest, order) ->
IO.println(guest + ": заказ " + order.number() + ", " + order.total() + " ₽"));Анна: заказ 1, 430 ₽
Борис: заказ 9, 660 ₽
Вера: заказ 8, 890 ₽
Глеб: заказ 4, 820 ₽
Дина: заказ 6, 650 ₽
Егор: заказ 7, 150 ₽
Жанна: заказ 10, 200 ₽У карты есть свой forEach: лямбда получает ключ и
значение, пару за парой, в порядке карты — у TreeMap это
алфавит.
Часть 3. filtering и flatMapping
filter до группировки или filtering внутри
Вопрос хозяйки: сколько крупных заказов, от 500 ₽, у каждого гостя? Первое, что приходит в голову, — отсеять мелкие заказы и сгруппировать оставшиеся:
Map<String, Long> bigByFilter = orders.stream()
.filter(order -> order.total() >= 500)
.collect(groupingBy(Order::guest, TreeMap::new, counting()));Второй способ — отсеивать внутри группы, коллектором
filtering:
Map<String, Long> bigByFiltering = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new,
filtering(order -> order.total() >= 500, counting())));Ответы разные:
filter: {Борис=1, Вера=1, Глеб=1, Дина=1}
filtering: {Анна=0, Борис=1, Вера=1, Глеб=1, Дина=1, Егор=0, Жанна=0}Анна, Егор и Жанна в первой карте пропали. filter отсеял
их заказы до раскладки: к groupingBy они не доехали, и
стопок для этих гостей никто не завёл. filtering работает
внутри группы: заказ сначала попадает в стопку своего гостя и только там
проверяется. Стопка заводится в любом случае — пусть и пустая, с
нулём.
Какой вариант верный, решает вопрос. «У кого были крупные заказы» —
filter, нули там лишние. «Отчёт по всем гостям дня» —
filtering: «у Анны крупных нет» — тоже ответ, и в отчёте он
нужен.
🔑 filter до группировки решает, какие группы будут.
filtering решает, что окажется внутри групп, а сами группы
не трогает.
💡 Теперь группа бывает пустой — и Optional из части 2
оживает. С filtering(…, maxBy(…)) у Анны вместо заказа
будет Optional.empty, и orElseThrow там бросил
бы исключение.
flatMapping — flatMap внутри группы
Что брал каждый гость? Позиции лежат внутри заказов, их достаёт
flatMap из статьи 4. Но поставь его перед группировкой — и
компилятор остановит:
Map<String, List<String>> itemsByGuest = orders.stream()
.flatMap(order -> order.items().stream())
.collect(groupingBy(item -> item.guest(), TreeMap::new, mapping(Item::name, toList())));Coffee.java:97: error: cannot find symbol
.collect(groupingBy(item -> item.guest(), TreeMap::new, mapping(Item::name, toList())));
^
symbol: method guest()
location: variable item of type ItemТа же ошибка, что в статье 1: после flatMap по конвейеру
едут позиции, а позиция не знает, чей это заказ, — гость остался в
Order. Раскладывать по гостю надо, пока едут заказы, а
позиции доставать уже внутри группы:
Map<String, List<String>> itemsByGuest = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new,
flatMapping(order -> order.items().stream().map(Item::name), toList())));{Анна=[латте, круассан, американо], Борис=[эспрессо, капучино], Вера=[капучино, латте, чизкейк], Глеб=[латте, чизкейк], Дина=[раф, круассан], Егор=[эспрессо], Жанна=[какао]}flatMapping — flatMap внутри группы.
Функция превращает заказ в стрим — здесь стрим названий его позиций, — а
коллектор вторым аргументом складывает всё, что из этих стримов вышло.
Возьмёт гость латте в двух заказах — латте попадёт в список дважды;
нужны названия без повторов — пиши toSet() вместо
toList(), выйдет множество.
💡 Вторым аргументом у mapping, filtering и
flatMapping может стоять любой коллектор: не только
toList(), но и counting(),
summingInt(…) или maxBy(…).
Три новых коллектора — близнецы операций, которые ты знаешь:
| в конвейере | внутри группы | что делает |
|---|---|---|
map |
mapping |
превращает каждый элемент |
filter |
filtering |
оставляет подходящие (filtering — не убирая групп) |
flatMap |
flatMapping |
превращает элемент в несколько |
Часть 4. teeing: два ответа за один проход
Новый вопрос хозяйки: какую долю выручки дают крупные заказы? Нужны
два числа — вся выручка и выручка крупных — и одно действие над ними.
Можно пройти по заказам двумя конвейерами, а можно одним — через
teeing:
int bigShare = orders.stream()
.collect(teeing(
summingInt(Order::total), // вся выручка
filtering(order -> order.total() >= 500, summingInt(Order::total)), // выручка крупных
(all, big) -> big * 100 / all)); // доля, %
IO.println("крупные заказы дали " + bigShare + "% выручки");крупные заказы дали 67% выручкиteeing (с Java 12) берёт два коллектора и функцию.
Каждый элемент уходит в оба коллектора. В конце функция получает оба
ответа — здесь 4490 и 3020 — и делает из них один. Название — от
английского tee, тройник в трубах: поток делится на два и сходится
снова.
Деление здесь целочисленное: 302 000 / 4490 — это 67,26…, и дробную
часть Java отбросила. Нужна точность — считай в double.
Для списка в памяти два конвейера — тоже честный ответ.
teeing выручает, когда второго прохода нет: внутри
groupingBy, где каждая группа собирается один раз, или
когда данные читаются из файла и второй раз их не прочесть.
Проверь себя#
Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.
- Что такое коллектор для группы? Какой из них ты знал ещё по статье 5?
- Что делает
mappingи зачем ему второй коллектор? - Почему
counting()дал для латте 3, аsummingInt(Item::qty)— 4? - Почему
averagingIntотвечаетDouble, даже если все суммы целые? - Почему
maxByотвечаетOptional? Почему внутриgroupingByон не бывает пустым и как его снять? - Чем
filteringвнутри группы отличается отfilterперед группировкой? Когда нужен каждый? - Почему нельзя развернуть заказы в позиции
flatMap-ом, а потом сгруппировать по гостю? - Что делает
teeing? Что получает его третий аргумент?
Реши в кузнице#
Задачи на то же самое — filtering,
flatMapping, averagingInt, maxBy
внутри группы — в кузнице Stream Forge на платформе Hammerhall, на
странах мира. Задачи бесплатны, нужен только вход:
- «Для каждого континента построить список стран с населением более 50 миллионов»;
- «Найти среднее население городов по каждому континенту»;
- «Найти самый густонаселённый город на каждом континенте».
Что дальше#
Статья 7 — стрим по Map: как отсортировать карту по
значению, найти три самые прибыльные позиции и не потеряться в
ничьих.
Первоисточники#
- Class
Collectors — Java SE 25 —
mapping,summingInt,averagingInt,maxBy,collectingAndThen,flatMapping,teeing; уfiltering— пример, чем он отличается отfilterстрима. - Class
Optional — Java SE 25 — у
get()совет предпочитатьorElseThrow(). - JLS
25, §7.5.4 Static-Import-on-Demand Declarations — что делает
import static … .*.
Пример целиком#
Учебный пример — один файл. Нужен JDK 25: проверь командой
java -version, первая строка должна начинаться с
openjdk version "25 (или java version "25). С
JDK из курса, 17 или 21, файл не запустится.
Записи стоят прямо вверху файла, а методы orders() и
main — без класса вокруг: в Java 25 такой файл сам
становится классом, а void main() без
public static и без параметров — точкой входа. Сохрани файл
как Coffee.java и запусти из его папки:
java Coffee.javaJava сама скомпилирует файл и выполнит main — ни Maven,
ни проекта не нужно. Если вместо русских букв в выводе вопросы или
кракозябры, запусти с явной кодировкой — аргументы в кавычках, так их
поймёт и PowerShell:
java "-Dstdout.encoding=UTF-8" "-Dstderr.encoding=UTF-8" Coffee.java;
в командной строке Windows перед этим выполни chcp 65001.
Второй аргумент — для текста исключений и ошибок: они печатаются
отдельно от обычного вывода.
// Coffee.java — учебный пример статьи 6 серии «Стримы по шагам».
// Запуск: java Coffee.java (нужен JDK 25)
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import static java.util.stream.Collectors.*;
/** Позиция заказа: напиток или десерт, цена за штуку в рублях, сколько штук. */
record Item(String name, int price, int qty) {}
/** Заказ: номер за день, имя гостя, позиции. */
record Order(int number, String guest, List<Item> items) {
/** Сумма заказа в рублях. Пока циклом — стримом перепишем позже в серии. */
int total() {
int sum = 0;
for (Item item : items) {
sum += item.price() * item.qty();
}
return sum;
}
}
/** Заказы кофейни «Наковальня» за один день. */
List<Order> orders() {
return List.of(
new Order(1, "Анна", List.of(new Item("латте", 250, 1), new Item("круассан", 180, 1))),
new Order(2, "Борис", List.of(new Item("эспрессо", 150, 2))),
new Order(3, "Вера", List.of(new Item("капучино", 220, 1))),
new Order(4, "Глеб", List.of(new Item("латте", 250, 2), new Item("чизкейк", 320, 1))),
new Order(5, "Анна", List.of(new Item("американо", 170, 1))),
new Order(6, "Дина", List.of(new Item("раф", 290, 1), new Item("круассан", 180, 2))),
new Order(7, "Егор", List.of(new Item("эспрессо", 150, 1))),
new Order(8, "Вера", List.of(new Item("латте", 250, 1), new Item("чизкейк", 320, 2))),
new Order(9, "Борис", List.of(new Item("капучино", 220, 3))),
new Order(10, "Жанна", List.of(new Item("какао", 200, 1))));
}
void main() {
List<Order> orders = orders();
// 1. Номера заказов каждого гостя: mapping — map внутри группы
Map<String, List<Integer>> numbersByGuest = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new, mapping(Order::number, toList())));
IO.println(numbersByGuest);
// 2. Сколько потратил каждый гость: summingInt складывает
Map<String, Integer> spentByGuest = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new, summingInt(Order::total)));
IO.println(spentByGuest);
// 3. Сколько штук каждой позиции продано: складываем qty
Map<String, Integer> portionsByItem = orders.stream()
.flatMap(order -> order.items().stream())
.collect(groupingBy(Item::name, TreeMap::new, summingInt(Item::qty)));
IO.println(portionsByItem);
// 4. Средний чек гостя: averagingInt отвечает в Double
Map<String, Double> averageByGuest = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new, averagingInt(Order::total)));
IO.println(averageByGuest);
// 5. Самый крупный заказ гостя: maxBy отвечает в Optional
Map<String, Optional<Order>> biggestMaybe = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new,
maxBy(Comparator.comparingInt(Order::total))));
IO.println(biggestMaybe.get("Вера"));
// 6. Пустой группы не бывает — снимаем Optional через collectingAndThen
Map<String, Order> biggest = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new,
collectingAndThen(
maxBy(Comparator.comparingInt(Order::total)),
Optional::orElseThrow)));
biggest.forEach((guest, order) ->
IO.println(guest + ": заказ " + order.number() + ", " + order.total() + " ₽"));
// 7. filter до группировки: гости без крупных заказов пропадают
Map<String, Long> bigByFilter = orders.stream()
.filter(order -> order.total() >= 500)
.collect(groupingBy(Order::guest, TreeMap::new, counting()));
IO.println("filter: " + bigByFilter);
// 8. filtering внутри группы: гости остаются, у кого-то — 0
Map<String, Long> bigByFiltering = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new,
filtering(order -> order.total() >= 500, counting())));
IO.println("filtering: " + bigByFiltering);
// 9. Что брал каждый гость: flatMapping — flatMap внутри группы
Map<String, List<String>> itemsByGuest = orders.stream()
.collect(groupingBy(Order::guest, TreeMap::new,
flatMapping(order -> order.items().stream().map(Item::name), toList())));
IO.println(itemsByGuest);
// 10. teeing: два коллектора за один проход — доля крупных заказов в выручке
int bigShare = orders.stream()
.collect(teeing(
summingInt(Order::total),
filtering(order -> order.total() >= 500, summingInt(Order::total)),
(all, big) -> big * 100 / all));
IO.println("крупные заказы дали " + bigShare + "% выручки");
}Сделай руками:
- Запусти файл. Первая строка —
{Анна=[1, 5], Борис=[2, 9], …}; найди в третьей строкелатте=4. - В блоке
// 6.замениmaxByнаminBy— выйдут самые маленькие заказы:Анна: заказ 5, 170 ₽. Верни как было. - В блоке
// 4.замениMap<String, Double>наMap<String, Integer>и прочитай ошибку компилятора:Integer,D,Doubleв конце первой строки. Верни как было. - Замени блок
// 9.куском из части 3, гдеflatMapстоит перед группировкой, — увидишьlocation: variable item of type Item. Верни как было. - В блоке
// 8.замениcounting()наmaxBy(Comparator.comparingInt(Order::total)), аMap<String, Long>— наMap<String, Optional<Order>>: у Анны, Егора и Жанны будетOptional.empty. Верни как было. - В блоке
// 10.поменяй порог 500 на 300 — выйдеткрупные заказы дали 83% выручки.