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á truefalse, 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 IntStreamsummaryStatistics().

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.