collect là phép kết linh hoạt nhất của Stream, và Collectors là lớp tiện ích chứa hơn bốn mươi hàm dựng sẵn.
Nhưng thứ khiến nó thật sự mạnh không phải số lượng, mà là chuyện collector lồng được vào nhau.
Ba cái cơ bản
DS.stream().map(NhanVien::ten).collect(toList());
DS.stream().map(NhanVien::phong).collect(toSet());
DS.stream().map(NhanVien::ten).collect(joining(", ", "[", "]"));
toList : [Minh, Lan, Hải, Vy, An]
toSet : [Kinh doanh, Kỹ thuật]
joining : [Minh, Lan, Hải, Vy, An]
joining nhận ba tham số: dấu ngăn, tiền tố, hậu tố. Nó dùng StringBuilder bên trong nên nhanh hơn hẳn việc reduce bằng phép cộng chuỗi — đúng bài học ở bài String.
Với toList, từ Java 16 thì stream().toList() ngắn hơn và trả về danh sách bất biến, khác collect(toList()) trả về ArrayList sửa được — như bài về collection bất biến đã đo.
groupingBy: gom theo khoá
DS.stream().collect(groupingBy(NhanVien::phong, mapping(NhanVien::ten, toList())))
{Kinh doanh=[Lan, Vy], Kỹ thuật=[Minh, Hải, An]}
Tham số thứ hai là collector con — nó quyết định mỗi nhóm được gom thành cái gì. Không truyền thì mặc định là toList() của chính các phần tử.
Đây là chỗ groupingBy trở nên đáng học, vì collector con thay được bằng bất cứ gì:
đếm : {Kinh doanh=2, Kỹ thuật=3}
tổng lương : {Kinh doanh=60000000, Kỹ thuật=87000000}
tuổi trung bình: {Kinh doanh=31.0, Kỹ thuật=32.666666666666664}
lương cao nhất : {Kinh doanh=Lan, Kỹ thuật=An}
Lần lượt là counting(), summingLong(...), averagingInt(...), và một cái phức tạp hơn:
collectingAndThen(
maxBy(Comparator.comparingLong(NhanVien::luong)),
o -> o.map(NhanVien::ten).orElse("-"))
collectingAndThen chạy một collector rồi biến đổi kết quả. Ở đây maxBy trả về Optional<NhanVien>, và ta muốn một String — nên bọc thêm một bước.
Mẫu này rất hay dùng, vì nhiều collector trả về Optional mà bạn thì muốn giá trị trần.
Gom nhóm hai tầng
Collector con của groupingBy có thể lại là một groupingBy:
DS.stream().collect(groupingBy(NhanVien::phong, TreeMap::new,
groupingBy(n -> n.tuoi() < 30 ? "trẻ" : "lớn",
mapping(NhanVien::ten, toList()))))
Kinh doanh : {lớn=[Vy], trẻ=[Lan]}
Kỹ thuật : {lớn=[Minh, An], trẻ=[Hải]}
Một câu lệnh cho một bảng hai chiều. Viết bằng vòng lặp thì cần hai Map lồng nhau và mấy dòng computeIfAbsent.
Tham số giữa — TreeMap::new — chỉ định kiểu map kết quả. Mặc định là HashMap, nên kết quả không có thứ tự; muốn khoá sắp xếp thì truyền TreeMap::new, muốn giữ thứ tự gặp thì LinkedHashMap::new.
Lồng ba tầng cũng được, nhưng lúc đó tôi khuyên dừng lại: một record khoá tổng hợp thường dễ đọc hơn.
partitioningBy luôn trả đủ hai khoá
DS.stream().collect(partitioningBy(n -> n.luong() >= 30_000_000L, mapping(NhanVien::ten, toList())))
{false=[Minh, Hải], true=[Lan, Vy, An]}
stream rỗng vẫn có 2 khoá: {false=[], true=[]}
Đây là khác biệt quan trọng với groupingBy: partitioningBy bảo đảm map có đúng hai khoá true và false, kể cả khi một bên rỗng.
Với groupingBy(n -> dieuKien) thì nhóm rỗng đơn giản là không xuất hiện, và bạn phải kiểm null mỗi lần truy cập.
Nên khi chia hai theo một điều kiện boolean, partitioningBy vừa nhanh hơn (nó dùng một cấu trúc riêng, không băm) vừa an toàn hơn.
toMap và cái bẫy khoá trùng
DS.stream().collect(toMap(NhanVien::phong, NhanVien::ten))
IllegalStateException: Duplicate key Kỹ thuật (attempted merging...
toMap ném ngoại lệ khi hai phần tử cho ra cùng khoá.
Nhiều người thấy khó chịu, nhưng đây là lựa chọn đúng: nếu bạn nghĩ khoá là duy nhất mà thực tế không phải, im lặng ghi đè sẽ làm mất dữ liệu mà không ai biết. Ném lỗi buộc bạn quyết định.
Quyết định đó là tham số thứ ba:
toMap(NhanVien::phong, NhanVien::ten, (a, b) -> a + "|" + b)
{Kinh doanh=Lan|Vy, Kỹ thuật=Minh|Hải|An}
Vài hàm gộp hay dùng: (a, b) -> a giữ cái đầu, (a, b) -> b giữ cái cuối, Integer::sum cộng dồn.
Tham số thứ tư chỉ định loại map, giống groupingBy.
Một cái bẫy nữa ít người biết: toMap không nhận giá trị null. Nếu hàm lấy giá trị có thể trả null thì nó ném NPE — và thông báo không nói rõ nguyên nhân. Dùng groupingBy hoặc lọc trước.
Thống kê một lượt
var tk = DS.stream().collect(summarizingLong(NhanVien::luong));
n=5 min=22,000,000 max=40,000,000 tb=29,400,000
summarizingInt/Long/Double cho năm số trong một lần duyệt: count, sum, min, max, average. Thay cho việc chạy stream năm lần.
Bản tương ứng trên IntStream là summaryStatistics().
teeing: hai collector cùng lúc
DS.stream().collect(teeing(
counting(),
averagingLong(NhanVien::luong),
(n, tb) -> n + " người, lương TB " + tb))
5 người, lương TB 29,400,000
Có từ Java 12, và nó giải quyết đúng bài toán "cần hai kết quả khác nhau từ một stream mà không muốn duyệt hai lần".
Ít người biết nhưng rất hữu ích khi dữ liệu đến từ nguồn chỉ đọc được một lần — file, kết quả truy vấn, API phân trang.
Bảng tra nhanh
| Cần | Collector |
|---|---|
| Danh sách / tập hợp | toList(), toSet(), toCollection(TreeSet::new) |
| Nối chuỗi | joining(", ", "[", "]") |
| Gom theo khoá | groupingBy(hamKhoa, collectorCon) |
| Chia hai theo điều kiện | partitioningBy(dieuKien) |
| Thành map | toMap(khoa, giaTri, hamGop) |
| Đếm / cộng / trung bình | counting(), summingInt(), averagingDouble() |
| Lớn nhất / nhỏ nhất | maxBy(cmp), minBy(cmp) — trả Optional |
| Biến đổi trước khi gom | mapping(ham, collectorCon) |
| Lọc trước khi gom | filtering(dieuKien, collectorCon) |
| Trải phẳng trước khi gom | flatMapping(ham, collectorCon) |
| Biến đổi sau khi gom | collectingAndThen(collector, ham) |
| Hai kết quả một lượt | teeing(c1, c2, ghep) |
Ba dòng mapping, filtering, flatMapping là những cái đáng nhớ nhất — chúng chính là map, filter, flatMap nhưng đặt được vào bên trong một groupingBy.
Nhập tĩnh cho dễ đọc
import static java.util.stream.Collectors.*;
Không có nó thì mỗi collector phải viết Collectors.groupingBy(...), và một biểu thức lồng ba tầng trở nên rất khó đọc. Đây là một trong số ít chỗ tôi thấy nhập tĩnh bằng dấu sao là hợp lý — tên các collector đủ đặc trưng để không đụng với gì khác.
Thử ba mươi giây
Lấy một danh sách đối tượng trong dự án của bạn, viết collect(groupingBy(X::truong, counting())) rồi in ra.
Đó là câu trả lời cho "mỗi loại có bao nhiêu cái" — thứ mà trước Java 8 cần một vòng lặp với computeIfAbsent. Rồi thử đổi counting() thành summingInt(...) để thấy collector con thay được tự do.
Ngày mai: tự viết một Collector — bốn hàm supplier, accumulator, combiner, finisher, và khi nào cái thứ ba thực sự được gọi.