Collectors in Java: Grouping and Partitioning

Collectors turn a stream into a collection, a map or a summary, and grouping is where they become genuinely powerful.

What a Collector does

collect is a mutable reduction: it accumulates elements into a container. The Collectors class supplies ready made collectors for almost every shape you need.

To a collection

List<String> list = titles.stream().collect(Collectors.toList());
Set<String> set = titles.stream().collect(Collectors.toSet());
List<String> immutable = titles.stream().collect(Collectors.toUnmodifiableList());

TreeSet<String> sorted = titles.stream()
        .collect(Collectors.toCollection(TreeSet::new));   // a chosen implementation

Joining

String plain = titles.stream().collect(Collectors.joining());
String commas = titles.stream().collect(Collectors.joining(", "));
String wrapped = titles.stream().collect(Collectors.joining(", ", "[", "]"));

System.out.println(wrapped);   // [Java loops, SQL joins]

To a map

Map<String, Integer> viewsByTitle = notes.stream()
        .collect(Collectors.toMap(Note::title, Note::views));

// Duplicate keys throw unless a merge function is supplied
Map<String, Integer> viewsByCategory = notes.stream()
        .collect(Collectors.toMap(Note::category, Note::views, Integer::sum));

// Choosing the map implementation
Map<String, Integer> ordered = notes.stream()
        .collect(Collectors.toMap(Note::title, Note::views,
                (a, b) -> a, LinkedHashMap::new));
The two argument toMap throws IllegalStateException on a duplicate key. This is one of the most common surprises with collectors, and the fix is always to supply a merge function.

Grouping

Map<String, List<Note>> byCategory = notes.stream()
        .collect(Collectors.groupingBy(Note::category));

System.out.println(byCategory.get("java").size());

The classifier function decides the key, and the values default to a List of the matching elements.

Grouping with a downstream collector

Map<String, Long> countPerCategory = notes.stream()
        .collect(Collectors.groupingBy(Note::category, Collectors.counting()));

Map<String, Integer> viewsPerCategory = notes.stream()
        .collect(Collectors.groupingBy(Note::category,
                Collectors.summingInt(Note::views)));

Map<String, List<String>> titlesPerCategory = notes.stream()
        .collect(Collectors.groupingBy(Note::category,
                Collectors.mapping(Note::title, Collectors.toList())));

Map<String, Optional<Note>> topPerCategory = notes.stream()
        .collect(Collectors.groupingBy(Note::category,
                Collectors.maxBy(Comparator.comparingInt(Note::views))));

The second argument transforms each group. This is what makes grouping expressive: the same classifier serves counting, summing, mapping or any other reduction.

Choosing the map type

TreeMap<String, Long> sortedGroups = notes.stream()
        .collect(Collectors.groupingBy(Note::category, TreeMap::new,
                Collectors.counting()));

Multi level grouping

Map<String, Map<Boolean, List<Note>>> byCategoryThenStatus = notes.stream()
        .collect(Collectors.groupingBy(Note::category,
                Collectors.groupingBy(Note::published)));

Nesting works to any depth, although two levels is usually the point at which a small record reads better than the nested map type.

Partitioning

Map<Boolean, List<Note>> split = notes.stream()
        .collect(Collectors.partitioningBy(Note::published));

List<Note> live = split.get(true);
List<Note> drafts = split.get(false);

Map<Boolean, Long> counts = notes.stream()
        .collect(Collectors.partitioningBy(Note::published, Collectors.counting()));
groupingBypartitioningBy
Key typeAnythingBoolean only
Number of groupsAs many as there are distinct keysExactly two
Empty groupsAbsent from the mapBoth keys always present

Both true and false are always present with partitioningBy, even when one side is empty. That guarantee is the practical reason to prefer it over grouping by a boolean.

Summarising

long count = notes.stream().collect(Collectors.counting());
int total = notes.stream().collect(Collectors.summingInt(Note::views));
double mean = notes.stream().collect(Collectors.averagingInt(Note::views));

IntSummaryStatistics stats = notes.stream()
        .collect(Collectors.summarizingInt(Note::views));

Transforming the finished result

String topTitle = notes.stream()
        .collect(Collectors.collectingAndThen(
                Collectors.maxBy(Comparator.comparingInt(Note::views)),
                best -> best.map(Note::title).orElse("none")));

List<String> frozen = titles.stream()
        .collect(Collectors.collectingAndThen(Collectors.toList(), List::copyOf));

Filtering and flattening inside a group

// Keeps every category key, even those with no popular note
Map<String, List<Note>> popularPerCategory = notes.stream()
        .collect(Collectors.groupingBy(Note::category,
                Collectors.filtering(note -> note.views() > 300,
                        Collectors.toList())));

Map<String, List<String>> tagsPerCategory = notes.stream()
        .collect(Collectors.groupingBy(Note::category,
                Collectors.flatMapping(note -> note.tags().stream(),
                        Collectors.toList())));

Filtering before groupingBy removes empty groups entirely; Collectors.filtering keeps them with an empty list. Choose according to whether the key should still appear.

A worked report

Map<String, String> report = notes.stream()
        .filter(Note::published)
        .collect(Collectors.groupingBy(
                Note::category,
                TreeMap::new,
                Collectors.collectingAndThen(
                        Collectors.summingInt(Note::views),
                        views -> views + " total views")));

report.forEach((category, summary) -> System.out.println(category + ": " + summary));

Common mistakes

  • Using toMap without a merge function and hitting a duplicate key.
  • Assuming groupingBy produces a sorted map. It returns a HashMap by default.
  • Expecting a group for a key with no matching elements.
  • Using groupingBy with a boolean classifier instead of partitioningBy.
  • Nesting so many collectors that the type becomes unreadable.
  • Calling toMap where a value may be null, which throws.

Best practices

  • Always supply a merge function to toMap unless keys are provably unique.
  • Use partitioningBy for a two way split.
  • Pass TreeMap::new or LinkedHashMap::new when order matters.
  • Extract a deeply nested collector into a named variable.
  • Introduce a record once the result type needs more than two levels of nesting.

Practice

  1. Group notes by category and count them, then sort the result by key.
  2. Why does toMap throw on duplicate keys, and how do you fix it two ways?
  3. Partition a list of marks into pass and fail and count each side.
  4. Build a map of category to a comma separated list of titles.
  5. Explain the difference between filtering before groupingBy and using Collectors.filtering.

Conclusion

Collectors turn a stream into whatever shape you need. Learn toList, joining, toMap with a merge function, groupingBy with a downstream collector, and partitioningBy, and most reporting code becomes a few readable lines.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.