Collectors có sẵn hơn bốn mươi hàm, và chúng ghép được vào nhau như bài hôm qua đã thấy. Nên tự viết một Collector là chuyện hiếm.
Nhưng hiểu cấu tạo của nó thì đáng, vì ba lý do: bạn sẽ đọc được mã người khác, biết chọn giữa collect và reduce, và — quan trọng nhất — hiểu vì sao có một hàm mà 99% thời gian không bao giờ chạy, rồi bỗng nhiên chạy khi ai đó đổi một chữ.
Bốn hàm
Collector.of(
() -> new Gom(), // 1. supplier — tạo thùng chứa rỗng
Gom::them, // 2. accumulator — bỏ một phần tử vào thùng
Gom::gop, // 3. combiner — gộp hai thùng làm một
Gom::ketQua); // 4. finisher — biến thùng thành kết quả cuối
Đọc theo vòng đời: tạo thùng, đổ dần từng phần tử vào, (có thể) gộp nhiều thùng, rồi chốt kết quả.
Cho một collector thống kê chuỗi, chạy tuần tự:
supplier : tạo thùng chứa
accumulator: thêm 'java'
accumulator: thêm 'go'
accumulator: thêm 'kotlin'
accumulator: thêm 'c'
accumulator: thêm 'python'
5 chuỗi, tổng dài 19, ngắn nhất 'c', dài nhất 'kotlin'
Một thùng, năm lần đổ vào, không có combiner.
Combiner chỉ xuất hiện khi song song
Đổi stream() thành parallelStream(), không sửa gì khác:
supplier : tạo thùng chứa
accumulator: thêm 'kotlin'
supplier : tạo thùng chứa
accumulator: thêm 'java'
supplier : tạo thùng chứa
accumulator: thêm 'c'
supplier : tạo thùng chứa
accumulator: thêm 'python'
supplier : tạo thùng chứa
accumulator: thêm 'go'
combiner : gộp hai nhóm (1 + 1)
combiner : gộp hai nhóm (1 + 1)
combiner : gộp hai nhóm (1 + 2)
combiner : gộp hai nhóm (2 + 3)
Năm thùng riêng cho năm phần tử, rồi gộp dần theo cây: (1+1), (1+1), (1+2), (2+3).
Đây là điều đáng nhớ nhất của bài:
stream() thành parallelStream() — có thể là người khác, nhiều tháng sau, trong một lần tối ưu. Và lúc đó kết quả sai sẽ rất khó truy ngược về nguyên nhân.
Nên nếu tự viết collector, hãy luôn viết một test chạy song song, kể cả khi bạn không định dùng song song.
Combiner phải kết hợp được
Ràng buộc toán học: phép gộp phải kết hợp — (a+b)+c bằng a+(b+c) — vì thứ tự gộp cây không đoán trước được.
Cộng, nhân, lấy min, lấy max, hợp tập hợp: đều kết hợp. Trừ và chia thì không.
Với thống kê ở trên, mọi phép đều kết hợp: cộng số lượng, cộng tổng độ dài, lấy chuỗi ngắn nhất giữa hai bên. Nên nó an toàn.
Một cái bẫy tinh vi: nếu bạn giữ "phần tử đầu tiên thoả điều kiện" thì phép gộp không còn kết hợp theo nghĩa thông thường — kết quả phụ thuộc thứ tự. Với stream có thứ tự thì Java vẫn gộp đúng chiều, nhưng đây là chỗ dễ viết sai.
Cách viết ngắn hơn
Không cần khai một lớp riêng nếu đã có sẵn thứ đóng vai thùng chứa:
Collector<String, ?, String> noiHoa = Collector.of(
StringBuilder::new,
(sb, s) -> sb.append(s.toUpperCase()).append(" "),
StringBuilder::append,
sb -> sb.toString().trim());
JAVA GO KOTLIN C PYTHON
StringBuilder::append vừa vặn làm combiner vì sb1.append(sb2) trả về sb1.
Chữ ? ở kiểu giữa nghĩa là "kiểu thùng chứa, người dùng không cần biết" — và đó là cách khai đúng khi trả collector ra ngoài.
IDENTITY_FINISH: bỏ được hàm thứ tư
Khi thùng chứa chính là kết quả cuối, finisher chỉ là hàm đồng nhất, và bạn khai luôn đặc tính đó:
Collector<String, ?, List<String>> vaoList = Collector.of(
ArrayList::new,
List::add,
(a, b) -> { a.addAll(b); return a; },
Collector.Characteristics.IDENTITY_FINISH);
[java, go, kotlin, c, python]
Ba đặc tính có thể khai:
IDENTITY_FINISH — thùng chứa là kết quả, Stream bỏ qua bước finisher.
UNORDERED — kết quả không phụ thuộc thứ tự phần tử. Cho phép Stream tối ưu mạnh hơn khi song song. Đúng với toSet, sai với toList.
CONCURRENT — nhiều luồng ghi vào cùng một thùng chứa được, nên không cần combiner. Chỉ khai khi thùng chứa thật sự an toàn luồng — ConcurrentHashMap chẳng hạn. Khai sai thì dữ liệu hỏng lặng lẽ.
collect khác reduce chỗ nào
Cả hai đều gom stream thành một giá trị, nhưng:
reduce làm việc với giá trị bất biến — mỗi bước tạo ra một giá trị mới.
collect làm việc với thùng chứa thay đổi được — mỗi bước đổ thêm vào cùng một đối tượng.
Nên nối chuỗi bằng reduce((a,b) -> a+b) tạo ra n chuỗi trung gian và chậm bậc hai, đúng bài học ở bài String. Còn collect(joining()) chỉ dùng một StringBuilder.
Quy tắc: kết quả là số hoặc giá trị nhỏ thì reduce; kết quả là collection hoặc chuỗi thì collect.
Khi nào thật sự nên tự viết
Thành thật thì hiếm, vì ghép sẵn phủ gần hết:
bằng teeing: 5 chuỗi, tổng dài 19
Cùng bài toán thống kê ở đầu bài, teeing giải quyết trong ba dòng mà không cần lớp nào.
Bốn trường hợp tôi thấy đáng tự viết:
Khi ghép sẵn phải duyệt nhiều lần mà nguồn chỉ đọc được một lần.
Khi cần một cấu trúc kết quả riêng — cây, đồ thị, chỉ mục nghịch đảo.
Khi cần tối ưu bộ nhớ — gom trực tiếp vào cấu trúc đích thay vì qua List trung gian.
Khi muốn đóng gói một phép gom phức tạp dùng lại ở nhiều nơi, để chỗ gọi chỉ còn một tên có nghĩa.
Trường hợp cuối là lý do chính đáng nhất: collect(thongKeDonHang()) đọc rõ hơn một chuỗi groupingBy lồng ba tầng, dù bên trong nó chính là chuỗi đó.
Thử ba mươi giây
Viết một collector đơn giản với Collector.of, cho combiner ném UnsupportedOperationException.
Chạy với stream() — không sao. Chạy với parallelStream() — nổ ngay.
Ba mươi giây đó cho thấy vì sao một collector viết sai có thể sống yên trong dự án nhiều năm, rồi hỏng vào đúng ngày ai đó thêm một chữ parallel.
Ngày mai: stream song song — khi nào nhanh, khi nào chậm hơn tuần tự, và đo bằng JMH thay vì đoán.