انتقل إلى المحتوى
أمين أبوزيد
منشورBEG-036-L05

Collectors والتجميع

  • الدرس 5 من 8
  • 11 دقيقة قراءة
  • مبتدئ
في هذا الدرس 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 collect with toList, toSet, and joining.
  • Group items with groupingBy and read the resulting Map.
  • Count and total each group with the downstream collectors counting, summingDouble, and averagingDouble.
  • 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.

CollectorResultExample
Collectors.toList()List<T>gather all into a list
Collectors.toSet()Set<T>gather, drop duplicates
Collectors.joining(", ")Stringjoin strings with a separator
Collectors.groupingBy(f)Map<K, List<T>>group items by key f
Collectors.counting()Longcount items
Collectors.summingDouble(f)Doubleadd up a number from each item
Collectors.averagingDouble(f)Doubleaverage 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:

java
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:

java
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:

java
Collectors.groupingBy(Person::city, Collectors.mapping(Person::name, Collectors.toList()))

SQL parallel

SQLStream
SELECT city, COUNT(*) ... GROUP BY citygroupingBy(Person::city, counting())
SELECT category, SUM(amount) ... GROUP BY categorygroupingBy(Order::category, summingDouble(Order::amount))
SELECT category, AVG(amount) ... GROUP BY categorygroupingBy(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:

java
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)

java
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:

text
{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)

java
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:

text
{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)

java
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:

text
[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 are List<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.

java
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.

java
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.

java
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.

java
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.

java
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:

  1. a count per group,
  2. a total or average per group,
  3. 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

  • collect gathers a stream using a collector: toList, toSet, joining, groupingBy, and others.
  • groupingBy(classifier) gives Map<K, List<T>>; add a downstream collector to count or total each group.
  • counting() gives Long; summingDouble and averagingDouble give Double.
  • groupingBy is the in-memory cousin of SQL GROUP 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

  1. What does groupingBy(Person::city) return?
  2. What type does counting() produce?
  3. How do you rank the entries of a map by value?
  4. Why might a category be missing from a grouped map?
  5. What SQL clause does groupingBy resemble?
أظهر الإجابات
  1. A Map from each city to a List<Person> of the people in it.
  2. Long.
  3. Stream its entrySet(), sorted with Map.Entry.comparingByValue(), then limit as needed.
  4. A key exists only if at least one item belongs to it.
  5. GROUP BY.

References

  • Collectors, Java SE 21 API. Supports: groupingBy returns a Map of lists with no guarantee on the map's type, the downstream-collector form, counting() returns Long and 0 for no elements, summingDouble and averagingDouble rounding and NaN notes, joining forms, and toList() 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.

التطبيق

طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.

المختبر والتمارين الاختبار

تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.

اكتب للبحث في المستويات والمراحل والوحدات والدروس.