1. Giới thiệu
Trong thực tế, hầu như luôn phải nhóm dữ liệu. Ví dụ: phân sinh viên theo khóa học, phân loại hàng hóa theo danh mục, tách người đã thành niên khỏi trẻ em, v.v.
Không có Stream API, các bài toán như vậy được làm thủ công: duyệt bộ sưu tập, kiểm tra tiêu chí, thêm vào danh sách phù hợp trong Map. Đây là đoạn mã “old-school” điển hình:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
String dept = employee.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}
Chạy được, nhưng trông như bạn đang tự tay phân loại mì ống vào các lọ. Nếu cần nhóm theo nhiều tiêu chí? Hoặc còn phải tính tổng theo nhóm? Mã sẽ phình to và trở nên khó đọc.
Stream API và các collector chuyên dụng (groupingBy, partitioningBy) cho phép làm điều này trong một hai dòng — và trông như một phù thủy Java thực thụ.
2. Collector groupingBy: nhóm theo tiêu chí
Ý tưởng chính
groupingBy là một collector biến stream phần tử thành Map, trong đó khóa là kết quả của hàm tiêu chí, còn giá trị là danh sách các phần tử tương ứng với tiêu chí đó.
Chữ ký:
Collectors.groupingBy(Function<T, K>)
- T — kiểu phần tử trong stream,
- K — kiểu khóa (nhóm) do hàm trả về.
Ví dụ đơn giản: nhóm nhân viên theo phòng ban
Giả sử chúng ta có lớp:
public class Employee {
private final String name;
private final String department;
// ... hàm khởi tạo và getter
public Employee(String name, String department) {
this.name = name;
this.department = department;
}
public String getName() { return name; }
public String getDepartment() { return department; }
}
Và một tập hợp nhân viên:
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")
);
Nhóm theo phòng ban:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Ta nhận được gì?
- Khóa: tên phòng ban (String).
- Giá trị: danh sách nhân viên của phòng ban đó (List<Employee>).
In kết quả:
byDepartment.forEach((dept, emps) -> {
System.out.println(dept + ": " +
emps.stream().map(Employee::getName).toList());
});
Kết quả:
IT: [Alisa, Klara]
HR: [Bob, Eva]
Finance: [Denis]
Nó hoạt động bên trong ra sao?
Trước hết, với mỗi phần tử trong stream, ta tính khóa (ví dụ getDepartment()). Nếu khóa đó đã có trong Map, phần tử được thêm vào danh sách tương ứng. Nếu chưa có — sẽ tạo danh sách mới.
Ẩn dụ
Hãy hình dung bạn đang phân loại thư vào các thư mục: với mỗi bức thư, bạn xem đã có thư mục với tên cần thiết hay chưa; nếu chưa — tạo mới và cho thư vào đó.
3. Collector partitioningBy: tách thành hai nhóm
Đôi khi không phải “nhóm theo giá trị”, mà chỉ cần chia bộ sưu tập thành hai phần theo một điều kiện logic (true/false). Ví dụ: nhân viên có lương cao hơn và thấp hơn một ngưỡng xác định; sinh viên — “đậu/rớt”.
Cho việc này có một collector đặc biệt — partitioningBy.
Chữ ký:
Collectors.partitioningBy(Predicate<T>)
Predicate — hàm trả về giá trị boolean.
Ví dụ: tách nhân viên theo mức lương
Giả sử chúng ta có:
public class Employee {
private final String name;
private final int salary;
// ... hàm khởi tạo và getter
public Employee(String name, int salary) {
this.name = name;
this.salary = salary;
}
public String getName() { return name; }
public int getSalary() { return salary; }
}
Và một danh sách:
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)
);
Chia thành “giàu” và “khiêm tốn”:
Map<Boolean, List<Employee>> partitioned = employees.stream()
.collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
- true — nhân viên có lương lớn hơn 100_000.
- false — phần còn lại.
In ra:
System.out.println("Giàu: " +
partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("Khiêm tốn: " +
partitioned.get(false).stream().map(Employee::getName).toList());
Kết quả:
Giàu: [Alisa, Klara]
Khiêm tốn: [Bob, Denis]
Khi nào dùng partitioningBy, khi nào dùng groupingBy?
- Nếu có nhiều hơn hai nhóm — hãy dùng groupingBy.
- Nếu chỉ có hai nhóm theo điều kiện boolean — hãy dùng partitioningBy: nhanh hơn và dễ hiểu hơn.
4. Nhóm lồng nhau: nhóm theo nhiều tiêu chí
Đôi khi ta muốn nhóm không chỉ theo một tiêu chí, mà còn “lồng” các nhóm với nhau. Ví dụ: nhóm nhân viên trước theo phòng ban, rồi bên trong phòng ban — theo chức danh.
Ví dụ:
Giả sử trong lớp Employee của chúng ta còn có trường position:
public class Employee {
private final String name;
private final String department;
private final String position;
// ... hàm khởi tạo và getter
}
Nhóm lồng nhau:
Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)));
- Khóa bên ngoài — phòng ban.
- Khóa bên trong — chức danh.
- Giá trị — danh sách nhân viên.
Làm sao lấy nhân viên của phòng IT có chức danh “Developer”?
List<Employee> itDevs = byDeptAndPosition
.getOrDefault("IT", Map.of())
.getOrDefault("Developer", List.of());
Sơ đồ trực quan
Map<Department, Map<Position, List<Employee>>>
└─ "IT"
├─ "Developer" -> [Alisa, Klara]
└─ "QA" -> [Boris]
└─ "HR"
└─ "Recruiter" -> [Denis]
5. Ví dụ thực hành về nhóm
Ví dụ 1: Nhóm chuỗi theo độ dài
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));
Kết quả:
3: [cat, dog, bee, ant]
8: [elephant, dolphin]
Ví dụ 2: Nhóm số theo tính chẵn lẻ
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]}
Ví dụ 3: Dùng partitioningBy cho chuỗi
Chia các chuỗi thành bắt đầu bằng "A" và phần còn lại:
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. Những điểm hữu ích
Cách sử dụng kết quả nhóm
Thường sau khi nhóm, ta không chỉ muốn có Map, mà còn xử lý nó:
- Duyệt tất cả các nhóm và in thông tin.
- Tìm nhóm có số lượng phần tử lớn nhất.
- Với mỗi nhóm, tính tổng hoặc trung bình chẳng hạn — chi tiết hơn ở bài sau.
Ví dụ: in số nhân viên ở mỗi phòng ban
byDepartment.forEach((dept, emps) ->
System.out.println(dept + ": " + emps.size() + " nhân viên"));
So sánh với cách tự triển khai thủ công
Để củng cố: nhóm theo phòng ban “kiểu xưa” sẽ như sau:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
String dept = e.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}
Còn với Stream API:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Kết luận: ít mã hơn, ít lỗi hơn, dễ đọc hơn.
Mẹo
Nếu cần nhóm không vào List, mà chẳng hạn vào Set — hãy dùng tham số thứ hai Collectors.toSet():
Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())
Có thể tổng hợp ngay: ví dụ lấy số nhân viên trong mỗi phòng ban:
Collectors.groupingBy(Employee::getDepartment, Collectors.counting())
Nhưng điều này — để ở bài tiếp theo!
Sau partitioningBy luôn luôn có hai khóa: true và false. Dù một trong hai nhóm có thể rỗng.
Sau các nhóm lồng nhau, cấu trúc trở thành “cây”: Map lồng trong Map, v.v.
6. Các lỗi thường gặp khi grouping và partitioning
Lỗi 1: Sai kiểu kết quả. Người mới thường kỳ vọng kết quả của groupingBy chỉ là List<T>, chứ không phải Map<K, List<T>>. Hệ quả là họ cố gọi các phương thức của danh sách và gặp lỗi biên dịch. Hãy nhớ: nhóm luôn trả về Map!
Lỗi 2: NullPointerException khi truy cập nhóm không tồn tại. Nếu bạn cố lấy danh sách theo một khóa không tồn tại — sẽ nhận null. Hãy dùng getOrDefault(key, List.of()) hoặc kiểm tra sự tồn tại của khóa qua containsKey.
Lỗi 3: Dùng partitioningBy cho bài toán có nhiều hơn hai nhóm. partitioningBy — chỉ dành cho hai nhóm (true/false). Nếu có nhiều nhóm hơn — hãy dùng groupingBy.
Lỗi 4: Sửa đổi các collection bên trong stream. Đừng cố thay đổi các collection nguồn hoặc Map bên trong stream — điều này dẫn đến lỗi khó lường. Hãy thực hiện toàn bộ xử lý qua Stream API và các collector.
Lỗi 5: Cấu trúc của nhóm lồng nhau không rõ ràng. Sau groupingBy lồng nhau, kết quả là Map bên trong Map. Đừng quên trích xuất dữ liệu cho đúng (ví dụ qua getOrDefault), nếu không sẽ gặp ClassCastException.
GO TO FULL VERSION