Collectors والتجميع
في هذا الدرس 11 موضوعات
هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.
Lesson Overview
So far every pipeline ended in a list, a number, or a yes/no. Backend reports ask richer questions: "How many orders per city?", "How much revenue per category?", "Who are the top customers?". Each answer is a table of groups: a key such as a city, and a value such as a count or a total.
Java answers these with collect and a collector. On the conveyor belt, a collector is the packing worker at the end, and a smart one: instead of throwing everything into one box, it sorts parcels into several boxes by label, and can weigh or count each box. The star of this lesson is Collectors.groupingBy.
Why this matters: grouping and totalling is among the most common jobs in business code, and it is the in-memory twin of SQL GROUP BY (BEG-050).
This lesson builds on Lessons 1 to 4 and on Map from BEG-030. It uses records (previewed in BEG-026). The code assumes import java.util.*; and import java.util.stream.*;.
Learning Objectives
By the end of this lesson you will be able to:
- Use
collectwithtoList,toSet, andjoining. - Group items with
groupingByand read the resultingMap. - Count and total each group with the downstream collectors
counting,summingDouble, andaveragingDouble. - State the result type of each collector call.
- Turn a map of totals into a sorted "top N" list.
- Explain why you should not rely on the order or the exact type of a grouped map.
Written Explanation
What collect does
collect(collector) is the general terminal operation. A collector describes how to gather elements into a result. The Collectors class (note the s) provides ready-made ones, which you can use as Collectors.toList() or, with import static java.util.stream.Collectors.*;, simply as toList(). The examples below use the full form Collectors. so it is always clear where each method comes from.
| Collector | Result | Example |
|---|---|---|
Collectors.toList() | List<T> | gather all into a list |
Collectors.toSet() | Set<T> | gather, drop duplicates |
Collectors.joining(", ") | String | join strings with a separator |
Collectors.groupingBy(f) | Map<K, List<T>> | group items by key f |
Collectors.counting() | Long | count items |
Collectors.summingDouble(f) | Double | add up a number from each item |
Collectors.averagingDouble(f) | Double | average of that number |
For a plain list you can still use toList() on the stream itself (Lesson 1). Use collect when you want a set, a joined string, or a map. For Collectors.toList() (the collector), the Javadoc gives no guarantee about the type or mutability of the list, so do not rely on it being changeable; if you need that, copy into new ArrayList<>(...).
joining works on a stream of strings (it accepts CharSequence elements). It also has a form with a prefix and a suffix: joining(", ", "[", "]").
groupingBy: sorting into boxes
groupingBy takes a classifier: a function that says which group each item belongs to. It returns a Map from each key to a List of the items with that key:
Map<String, List<Person>> byCity = people.stream()
.collect(Collectors.groupingBy(Person::city));If three people live in Riyadh, byCity.get("Riyadh") is a list of those three. A key exists only if at least one item had it. On an empty stream the result is an empty map, so a missing category is a missing key, not a zero.
The downstream collector: counting and totalling each box
By default each box holds a list. A second argument, the downstream collector, says what to do with each box instead of listing it:
Map<String, Long> countByCity = people.stream()
.collect(Collectors.groupingBy(Person::city, Collectors.counting()));
Map<String, Double> revenue = orders.stream()
.collect(Collectors.groupingBy(Order::category,
Collectors.summingDouble(Order::amount)));Read the second one aloud: "group the orders by category, and in each group add up the amounts." The types follow directly: counting() gives Long, so the map is Map<String, Long>; summingDouble(...) gives Double, so the map is Map<String, Double>. The Javadoc says counting() returns 0 for no elements and that summingDouble and averagingDouble return 0 for an empty group; both can be affected by NaN values. It also warns that a double sum can vary slightly with the order of the values because of rounding, so do not compare grouped sums with ==.
averagingDouble(f) works the same way, for example the average order amount per category. For whole-number amounts there are summingInt, summingLong, and averagingInt for the same jobs.
Another useful downstream collector is Collectors.mapping, which changes each item before collecting it, for example listing just the names in each city:
Collectors.groupingBy(Person::city, Collectors.mapping(Person::name, Collectors.toList()))SQL parallel
| SQL | Stream |
|---|---|
SELECT city, COUNT(*) ... GROUP BY city | groupingBy(Person::city, counting()) |
SELECT category, SUM(amount) ... GROUP BY category | groupingBy(Order::category, summingDouble(Order::amount)) |
SELECT category, AVG(amount) ... GROUP BY category | groupingBy(Order::category, averagingDouble(Order::amount)) |
If you know SQL, read a groupingBy as GROUP BY; if you do not yet, this table is a head start for BEG-050.
The map is not sorted
The Javadoc makes no guarantee about the exact type, mutability, or thread-safety of the map that groupingBy returns; it only promises the static type Map. In practice you should therefore treat the order of the entries as unspecified. When you need stable output, sort when you print. A quick way to see sorted keys is new TreeMap<>(map); for a result ordered by value, use a new stream, as follows. (There is also an overload that takes a map factory such as TreeMap::new when you need a specific type; the Javadoc shows it.)
From totals to a "top N" list
A Map has no sorted, but its entries do: map.entrySet().stream() gives a stream of Map.Entry items, each with getKey() and getValue(). To rank by value:
List<String> top = totals.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(2)
.map(e -> e.getKey() + "=" + e.getValue())
.toList();Map.Entry.comparingByValue() builds a comparator that orders entries by value; reversed() makes it largest-first. The explicit <String, Double> helps the compiler infer the types when you call reversed(). Note that ties have no special treatment: two customers with the same total can appear in any order unless you add a second sort key with thenComparing.
Examples
Example 1: how many people per city (simple)
import java.util.*;
import java.util.stream.*;
public class CountByCity {
record Person(String name, String city, int age) { }
public static void main(String[] args) {
List<Person> people = List.of(
new Person("Sam", "Riyadh", 34), new Person("Lina", "Jeddah", 67),
new Person("Omar", "Riyadh", 71), new Person("Nora", "Dammam", 29),
new Person("Kai", "Riyadh", 40));
Map<String, Long> countByCity = people.stream()
.collect(Collectors.groupingBy(Person::city, Collectors.counting()));
System.out.println(new TreeMap<>(countByCity));
Map<String, List<Person>> byCity = people.stream()
.collect(Collectors.groupingBy(Person::city));
System.out.println(byCity.get("Jeddah"));
}
}Real output:
{Dammam=1, Jeddah=1, Riyadh=3}
[Person[name=Lina, city=Jeddah, age=67]]The TreeMap copy is only there so the keys print in alphabetical order. The second map holds the whole Person lists, not just counts.
Example 2: revenue and average per category (practical)
import java.util.*;
import java.util.stream.*;
public class RevenueByCategory {
record Order(String customer, String category, double amount) { }
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("Amal", "Books", 12.5), new Order("Bo", "Toys", 30.0),
new Order("Amal", "Toys", 20.0), new Order("Chen", "Books", 7.5),
new Order("Bo", "Books", 10.0));
Map<String, Double> revenue = orders.stream().collect(
Collectors.groupingBy(Order::category, Collectors.summingDouble(Order::amount)));
Map<String, Double> average = orders.stream().collect(
Collectors.groupingBy(Order::category, Collectors.averagingDouble(Order::amount)));
System.out.println(new TreeMap<>(revenue));
System.out.println(new TreeMap<>(average));
System.out.println(orders.stream().map(Order::customer)
.distinct().collect(Collectors.joining(", ", "[", "]")));
}
}Real output:
{Books=30.0, Toys=50.0}
{Books=10.0, Toys=25.0}
[Amal, Bo, Chen]Books: 12.5 + 7.5 + 10.0 = 30.0 over three orders, so the average is 10.0. Toys: 30.0 + 20.0 = 50.0 over two orders, so 25.0.
Example 3: the top two customers (realistic)
import java.util.*;
import java.util.stream.*;
public class TopCustomers {
record Order(String customer, String category, double amount) { }
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("Amal", "Books", 12.5), new Order("Bo", "Toys", 30.0),
new Order("Amal", "Toys", 20.0), new Order("Chen", "Books", 7.5),
new Order("Bo", "Books", 10.0));
Map<String, Double> byCustomer = orders.stream().collect(
Collectors.groupingBy(Order::customer, Collectors.summingDouble(Order::amount)));
List<String> top = byCustomer.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(2)
.map(e -> e.getKey() + "=" + e.getValue())
.toList();
System.out.println(top);
}
}Real output:
[Bo=40.0, Amal=32.5]Two pipelines in one: the first groups and totals, the second ranks the totals. Splitting them into two named steps keeps each readable.
Backend Insight
Report endpoints often return exactly these shapes: counts per status, revenue per product, signups per day. A Map<String, Long> converts neatly into a JSON object, which is why groupingBy appears so often in service code.
A guideline from experience: when the data lives in a database, a GROUP BY query returns only the few result rows, while loading a million rows to group them in Java moves far more data. Use groupingBy for data already in memory, such as one customer's orders, a cached list, or the results of a small query. Also remember that a grouped map is a snapshot: it will not update if the source list changes later.
One more backend-relevant caution: money. The examples use double because that is what summingDouble takes, and because the module's lab does too. Real financial code typically avoids double for money because of rounding error; later parts of the course may revisit alternatives such as whole cents or BigDecimal.
Common Mistakes
Mistake 1: Forgetting the downstream collector
- Incorrect:
groupingBy(Order::category)when you wanted totals, then trying to treat the values as numbers. The values areList<Order>. - Correct:
groupingBy(Order::category, Collectors.summingDouble(Order::amount)).
Mistake 2: Declaring the wrong value type
counting() returns Long, not Integer. Map<String, Integer> m = ...collect(groupingBy(..., counting())) does not compile. Use Map<String, Long>.
Mistake 3: Relying on the map's order
Do not print a grouped map and expect a particular order, and do not write code that depends on it. Sort keys or entries explicitly.
Mistake 4: Comparing grouped double sums with ==
Sums of doubles can differ in the last digits. For checks, compare with a small tolerance, or think about whole cents.
Mistake 5: Reading a missing key as zero
counts.get("Taif") is null if no item had that key, and unboxing it into a long throws NullPointerException. Use counts.getOrDefault("Taif", 0L).
Exercises
Try each exercise yourself before opening the solutions. Use record Order(String customer, String category, double amount) and record Person(String name, String city, int age) from the examples.
Level A: Foundation
A1. Which collector would you use as the second argument of groupingBy to count items in each group?
أظهر الحل A1
A1. Collectors.counting().
A2. What is the type of words.stream().collect(Collectors.groupingBy(String::length)) when words is a List<String>?
أظهر الحل A2
A2. Map<Integer, List<String>>: the key is the length, the value is a list of words of that length.
A3. Write a pipeline that turns List.of("kiwi", "fig", "apple") into the single string fig|kiwi|apple with the shortest word first. (Hint: joining keeps the order it receives, so sort first.)
أظهر الحل A3
A3.
String s = Stream.of("kiwi", "fig", "apple")
.sorted(Comparator.comparing(String::length).thenComparing(Comparator.naturalOrder()))
.collect(Collectors.joining("|"));Real output of this pipeline is fig|kiwi|apple (length 3, then 4, then 5). The point of the hint is that joining keeps encounter order, so you must sort first if you want an order.
Level B: Application
B1. For List.of("kiwi", "fig", "apple", "plum", "date"), group the words by length and print the map in key order.
أظهر الحل B1
B1.
Map<Integer, List<String>> byLen = words.stream()
.collect(Collectors.groupingBy(String::length));
System.out.println(new TreeMap<>(byLen));
// {3=[fig], 4=[kiwi, plum, date], 5=[apple]}B2. For a List<Order>, count how many orders each customer made, as a Map<String, Long>.
أظهر الحل B2
B2.
Map<String, Long> perCustomer = orders.stream()
.collect(Collectors.groupingBy(Order::customer, Collectors.counting()));B3. For a List<Order>, compute the average amount per category.
أظهر الحل B3
B3.
Map<String, Double> avg = orders.stream()
.collect(Collectors.groupingBy(Order::category,
Collectors.averagingDouble(Order::amount)));Level C: Challenge
C1. Return the name of the category with the highest total revenue. Use groupingBy with summingDouble, then take the entry with the largest value. Return "none" if the list is empty.
أظهر الحل C1
C1.
Map<String, Double> revenue = orders.stream().collect(
Collectors.groupingBy(Order::category, Collectors.summingDouble(Order::amount)));
String best = revenue.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("none");max returns an Optional (like findFirst), so map and orElse apply as in Lesson 3. For the sample orders this gives Toys.
C2. A report shows {Books=30.0} but the manager expected {Books=30.0, Toys=0.0} because Toys had no orders this week. Explain why Toys is missing and how you would add it.
أظهر الحل C2
C2. groupingBy only creates a key when at least one item belongs to it, so a category with no orders has no key. To show it, start from the list of all categories you care about and use revenue.getOrDefault(category, 0.0) for each, or add the missing keys to a copy of the map afterwards.
Mini Challenge
Using a list of at least eight records of your own (for example students with a class and a score, or tickets with a status and a priority), write a three-part report:
- a count per group,
- a total or average per group,
- the top two groups by that total or average.
Print each with a heading. Sort the keys when you print so the output is stable. Check your work: your counts add up to the number of items in your list, and the top two really are the two largest values.
Summary & Next Lesson
Key takeaways
collectgathers a stream using a collector:toList,toSet,joining,groupingBy, and others.groupingBy(classifier)givesMap<K, List<T>>; add a downstream collector to count or total each group.counting()givesLong;summingDoubleandaveragingDoublegiveDouble.groupingByis the in-memory cousin of SQLGROUP BY.- Do not rely on the order or exact type of the grouped map; sort when you need order.
Next lesson
The next lesson, "When not to use a stream", is about judgment: when a plain loop is clearer, and how to keep a long pipeline readable.
Self-check
- What does
groupingBy(Person::city)return? - What type does
counting()produce? - How do you rank the entries of a map by value?
- Why might a category be missing from a grouped map?
- What SQL clause does
groupingByresemble?
أظهر الإجابات
- A
Mapfrom each city to aList<Person>of the people in it. Long.- Stream its
entrySet(),sortedwithMap.Entry.comparingByValue(), thenlimitas needed. - A key exists only if at least one item belongs to it.
GROUP BY.
References
- Collectors, Java SE 21 API. Supports:
groupingByreturns aMapof lists with no guarantee on the map's type, the downstream-collector form,counting()returnsLongand0for no elements,summingDoubleandaveragingDoublerounding andNaNnotes,joiningforms, andtoList()giving no guarantee about mutability. - java.util.stream package summary, Java SE 21 API. Supports: reduction and the
groupingBy(..., summingInt(...))composition idea. - Output shown was produced by compiling and running with JDK 21. The statements about
Map.Entry.comparingByValue,getOrDefault, and the SQL comparison come from the course and from running the code; they were not checked against fetched pages. The conveyor-belt packing picture is this course's own analogy.
التطبيق
طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.
تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.