1. 前言
在實務中幾乎總是需要對資料進行分組。例如:把學生依年級分組,把商品依類別歸類,把已成年與未成年分開,等等。
沒有 Stream API 時,這類任務通常得手動完成:遍歷集合、檢查條件,並把元素加入 Map 中對應的清單。下面是典型的「老派」程式碼:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
String dept = employee.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}
能運作,但就像你在手動把義大利麵分裝到不同罐子裡。若需要依多個條件分組呢?或者還要對各組求和?程式碼會急速膨脹且難以閱讀。
Stream API 與專用收集器(groupingBy、partitioningBy)能讓你用一兩行就搞定——看起來就像真正的 Java 魔法師。
2. 收集器 groupingBy:依鍵值分組
核心概念
groupingBy 是一個收集器,會把元素串流轉換為 Map:鍵是分組鍵函式的回傳值,值是符合該鍵的元素清單。
方法簽章:
Collectors.groupingBy(Function<T, K>)
- T — 串流中元素的型別,
- K — 分組鍵(群組)的型別,即函式的回傳型別。
簡單範例:依部門分組員工
假設有以下類別:
public class Employee {
private final String name;
private final String department;
// ... 建構子與 getter
public Employee(String name, String department) {
this.name = name;
this.department = department;
}
public String getName() { return name; }
public String getDepartment() { return department; }
}
以及一個員工集合:
List<Employee> employees = List.of(
new Employee("Alisa", "IT"),
new Employee("Bob", "HR"),
new Employee("Klara", "IT"),
new Employee("Denis", "Finance"),
new Employee("Eva", "HR")
);
依部門分組:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
得到了什麼?
- 鍵:部門名稱(String)。
- 值:該部門的員工清單(List<Employee>)。
列印結果:
byDepartment.forEach((dept, emps) -> {
System.out.println(dept + ": " +
emps.stream().map(Employee::getName).toList());
});
輸出:
IT: [Alisa, Klara]
HR: [Bob, Eva]
Finance: [Denis]
底層如何運作?
首先對串流中的每個元素計算鍵(例如 getDepartment())。如果該鍵已存在於 Map 中,就將元素加入對應清單;若不存在,則建立新的清單。
類比
想像你在把信件歸類到資料夾:對於每封信,先看是否已有對應名稱的資料夾;若沒有,就建立一個新的,然後把信放進去。
3. 收集器 partitioningBy:分成兩組
有時我們不是要「按值分組」,而只是想依布林條件把集合分成兩部分(true/false)。例如:薪資高於或低於某門檻的員工;學生分為「及格/不及格」。
這時可以使用特殊的收集器——partitioningBy。
方法簽章:
Collectors.partitioningBy(Predicate<T>)
Predicate —— 會回傳布林值的函式。
範例:依薪資高低分組員工
假設有:
public class Employee {
private final String name;
private final int salary;
// ... 建構子與 getter
public Employee(String name, int salary) {
this.name = name;
this.salary = salary;
}
public String getName() { return name; }
public int getSalary() { return salary; }
}
以及清單:
List<Employee> employees = List.of(
new Employee("Alisa", 120_000),
new Employee("Bob", 80_000),
new Employee("Klara", 150_000),
new Employee("Denis", 95_000)
);
把員工分成「高薪」與「低薪」:
Map<Boolean, List<Employee>> partitioned = employees.stream()
.collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
- true —— 薪資大於 100_000 的員工。
- false —— 其餘員工。
列印:
System.out.println("高薪: " +
partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("低薪: " +
partitioned.get(false).stream().map(Employee::getName).toList());
結果:
高薪: [Alisa, Klara]
低薪: [Bob, Denis]
何時使用 partitioningBy,何時使用 groupingBy?
- 如果群組多於兩個——使用 groupingBy。
- 如果只有依布林條件分成兩組——使用 partitioningBy:更快也更清楚。
4. 巢狀分組:依多個條件分組
有時不僅想依單一條件分組,還想「把分組巢狀起來」。例如:先依部門分組,再在部門內依職稱分組。
範例:
假設在我們的 Employee 類別中,還有一個欄位 position:
public class Employee {
private final String name;
private final String department;
private final String position;
// ... 建構子與 getter
}
巢狀分組:
Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)));
- 外層鍵——部門。
- 內層鍵——職稱。
- 值——員工清單。
如何取得 IT 部門且職稱為 "Developer" 的員工?
List<Employee> itDevs = byDeptAndPosition
.getOrDefault("IT", Map.of())
.getOrDefault("Developer", List.of());
視覺化示意
Map<Department, Map<Position, List<Employee>>>
└─ "IT"
├─ "Developer" -> [Alisa, Klara]
└─ "QA" -> [Boris]
└─ "HR"
└─ "Recruiter" -> [Denis]
5. 實用分組範例
範例 1:依字串長度分組
List<String> words = List.of("cat", "dog", "elephant", "bee", "ant", "dolphin");
Map<Integer, List<String>> byLength = words.stream()
.collect(Collectors.groupingBy(String::length));
byLength.forEach((len, ws) -> System.out.println(len + ": " + ws));
輸出:
3: [cat, dog, bee, ant]
8: [elephant, dolphin]
範例 2:依奇偶性分組數字
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
Map<String, List<Integer>> byParity = numbers.stream()
.collect(Collectors.groupingBy(n -> n % 2 == 0 ? "even" : "odd"));
System.out.println(byParity);
// {odd=[1, 3, 5], even=[2, 4, 6]}
範例 3:對字串使用 partitioningBy
把字串分成以 "A" 開頭與其他:
List<String> names = List.of("Alice", "Bob", "Anna", "Charlie");
Map<Boolean, List<String>> byA = names.stream()
.collect(Collectors.partitioningBy(s -> s.startsWith("A")));
System.out.println("A-names: " + byA.get(true)); // [Alice, Anna]
System.out.println("Other: " + byA.get(false)); // [Bob, Charlie]
5. 實用細節
如何使用分組結果
很多時候,我們不只想得到 Map,還想進一步處理它:
- 遍歷所有群組並輸出資訊。
- 找出元素數量最多的群組。
- 為每個群組計算加總或平均——詳細內容在下一講。
範例:輸出每個部門的員工數量
byDepartment.forEach((dept, emps) ->
System.out.println(dept + ": " + emps.size() + " 位員工"));
與手動實作的比較
複習一下:傳統作法下,依部門分組大概是這樣:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
String dept = e.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}
使用 Stream API 則是:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
結論:程式碼更少、錯誤更少、可讀性更高。
小技巧
如果不想收集成 List,而是 Set——可把第二個參數設為 Collectors.toSet():
Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())
也可直接做彙總:例如,取得各部門的員工數:
Collectors.groupingBy(Employee::getDepartment, Collectors.counting())
但這部分將在下一講詳細說明!
使用 partitioningBy 時,結果一定會有兩個鍵:true 與 false。即使其中一組為空。
做了巢狀分組後,結構會變成「樹」:Map 裡面包著 Map,依此類推。
6. 分組與 partitioning 的常見錯誤
錯誤 № 1:結果型別誤解。 新手常以為 groupingBy 的結果只是 List<T>,而不是 Map<K, List<T>>。於是嘗試呼叫清單的方法而導致編譯錯誤。記住:分組的結果永遠是 Map!
錯誤 № 2:存取不存在的群組時發生 NullPointerException。 如果你嘗試用不存在的鍵取得清單——會得到 null。請使用 getOrDefault(key, List.of()),或先用 containsKey 檢查鍵是否存在。
錯誤 № 3:多於兩組卻使用 partitioningBy。 partitioningBy 適用於只有兩組(true/false)的情況。若群組超過兩個——請使用 groupingBy。
錯誤 № 4:在串流內修改集合。 不要嘗試在串流內修改原始集合或 Map——這會導致難以預期的錯誤。請把處理邏輯交給 Stream API 與收集器完成。
錯誤 № 5:忽略巢狀分組的結構。 巢狀的 groupingBy 會得到 Map 內嵌 Map 的結果。取值時別忘了正確存取(例如使用 getOrDefault),否則會得到 ClassCastException。
GO TO FULL VERSION