Challenge

Replace five loops with collectors

25 minintermediate210 yrs

Edge cases. You have to reason, and two valid fixes differ.

What this teaches

  • The downstream collector is what makes groupingBy useful
  • collectingAndThen unwraps the Optional that maxBy leaves behind
  • groupingBy nests, so a two-level index is one pass
  • partitioningBy always returns both keys; groupingBy does not
  • A collector version is not automatically better — one of these is worse

Starter

Starter.java
import java.util.*;
import java.util.function.*;
import java.util.stream.*;

/**
 * Challenge: five loops. Four of them are collectors waiting to be written.
 *
 * The fifth is not, and identifying it is worth as much as converting the
 * other four. "Could this be a stream?" and "should this be a stream?" are
 * different questions, and only one of them has an obvious answer.
 *
 * Each loop prints its result. Your collector version must print exactly the
 * same thing.
 */
public class Starter {

    record Employee(String name, String dept, String city, int salary) { }

    static final List<Employee> STAFF = List.of(
        new Employee("Ana", "eng", "pune", 120),
        new Employee("Bo", "eng", "pune", 95),
        new Employee("Cy", "eng", "kochi", 130),
        new Employee("Di", "sales", "pune", 85),
        new Employee("Ed", "sales", "kochi", 70),
        new Employee("Fi", "ops", "kochi", 60));

    public static void main(String[] args) {

        // ── 1: headcount per department ──────────────────────────────────
        Map<String, Integer> headcount = new TreeMap<>();
        for (Employee e : STAFF) {
            headcount.merge(e.dept(), 1, Integer::sum);
        }
        System.out.println("1 headcount    : " + headcount);
        // TODO 1: one groupingBy with a downstream collector.

        // ── 2: names per department ──────────────────────────────────────
        Map<String, List<String>> names = new TreeMap<>();
        for (Employee e : STAFF) {
            names.computeIfAbsent(e.dept(), k -> new ArrayList<>()).add(e.name());
        }
        System.out.println("2 names        : " + names);
        // TODO 2: groupingBy plus mapping. Note that computeIfAbsent in a loop
        // IS groupingBy, written out by hand.

        // ── 3: highest paid per department ───────────────────────────────
        Map<String, String> topEarner = new TreeMap<>();
        for (Employee e : STAFF) {
            String current = topEarner.get(e.dept());
            if (current == null || e.salary() > salaryOf(current)) {
                topEarner.put(e.dept(), e.name());
            }
        }
        System.out.println("3 top earner   : " + topEarner);
        // TODO 3: maxBy leaves an Optional inside the map. Which collector
        // removes it before the group is finished?

        // ── 4: department then city ──────────────────────────────────────
        Map<String, Map<String, List<String>>> byDeptThenCity = new TreeMap<>();
        for (Employee e : STAFF) {
            byDeptThenCity
                .computeIfAbsent(e.dept(), k -> new TreeMap<>())
                .computeIfAbsent(e.city(), k -> new ArrayList<>())
                .add(e.name());
        }
        System.out.println("4 dept/city    : " + byDeptThenCity);
        // TODO 4: the downstream collector can be another groupingBy. Use the
        // three-argument form so the inner maps stay sorted.

        // ── 5: the first employee earning under 80, or a default ─────────
        String firstCheap = "none";
        for (Employee e : STAFF) {
            if (e.salary() < 80) {
                firstCheap = e.name();
                break;
            }
        }
        System.out.println("5 first cheap  : " + firstCheap);
        // TODO 5: this one CAN be a stream. Write it, then decide whether it
        // should be, and say what the loop communicates that the stream does
        // not.

        // TODO 6: every map printed above is a TreeMap on purpose. Say what
        // would be wrong with printing the result of a plain groupingBy, and
        // what the two ways to fix it are.
    }

    static int salaryOf(String name) {
        return STAFF.stream().filter(e -> e.name().equals(name)).findFirst()
            .map(Employee::salary).orElseThrow();
    }
}

Run it locally:

cd exercises/java/java8/collectors-and-grouping/02-challenge
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    Every loop that builds a map with computeIfAbsent is a groupingBy.

  2. Hint 2

    maxBy returns an Optional. Nesting one inside a map is almost never what you want — which collector removes it as the group is finished?

  3. Hint 3

    For the two-level index, the downstream collector can be another groupingBy.

  4. Hint 4

    One of the five reads clearly as a loop and badly as a collector. Say which, and leave it alone.

Done when

  • Four of the five are collector versions producing identical output
  • No Optional appears in any resulting map type
  • The one that should stay a loop is identified, with the reason
  • Every printed map is sorted or uses a map factory, never HashMap order

← Back to How do Collectors.groupingBy and toMap differ in failure modes?