Khép lại chặng lập trình hàm bằng câu hỏi khó nhất: viết thế nào cho người sau đọc được?
Vì Stream có một tính chất nguy hiểm — nó khiến mã trông gọn gàng ngay cả khi nó đang rất khó hiểu. Một chuỗi mười phép nối nhau nhìn qua thì "hiện đại", đọc kỹ mới thấy phải giữ năm tầng ngữ cảnh trong đầu cùng lúc.
Ba cách viết, cùng kết quả
Bài toán: tính tổng tiền theo khách hàng, chỉ tính đơn đã hoàn tất.
Cách một — một chuỗi liền:
return DS.stream()
.filter(d -> d.trangThai().equals("HOAN_TAT"))
.collect(groupingBy(Don::khach,
summingLong(d -> d.mon().stream()
.mapToLong(m -> m.soLuong() * m.donGia()).sum())));
Cách hai — tách những gì có tên:
private static final Predicate<Don> DA_HOAN_TAT = d -> d.trangThai().equals("HOAN_TAT");
private static long tongTien(Don d) {
return d.mon().stream().mapToLong(m -> m.soLuong() * m.donGia()).sum();
}
return DS.stream()
.filter(DA_HOAN_TAT)
.collect(groupingBy(Don::khach, summingLong(DocDuoc::tongTien)));
Cách ba — tách thành các bước:
List<Don> donHoanTat = DS.stream().filter(DA_HOAN_TAT).toList();
return donHoanTat.stream().collect(groupingBy(Don::khach, summingLong(DocDuoc::tongTien)));
cách 1 (chuỗi dài) : {Minh=96000, Vy=12000}
cách 2 (tách tên) : {Minh=96000, Vy=12000}
cách 3 (từng bước) : {Minh=96000, Vy=12000}
Cùng kết quả. Nhưng cách hai đọc được thành câu: lấy đơn đã hoàn tất, gom theo khách, cộng tổng tiền. Cách một buộc bạn giải mã một biểu thức lồng ba tầng để hiểu điều đó.
Khác biệt không nằm ở độ dài — cách hai còn nhiều dòng hơn. Nó nằm ở chỗ mỗi mảnh đều có tên.
Tách chuỗi không tốn gì
Phản đối thường gặp với cách ba: "tách ra thì phải duyệt hai lần chứ?"
một chuỗi liền : duyệt nguồn 4 lần
tách hai bước : duyệt nguồn 4 lần
Nguồn vẫn chỉ được duyệt đúng một lượt. Bước thứ hai duyệt lại danh sách kết quả — vốn đã nhỏ hơn vì đã lọc.
Nên chi phí thật của việc tách là một List trung gian. Với dữ liệu vừa phải thì không đáng kể; với dữ liệu rất lớn hoặc nguồn lười thì mới cần cân nhắc — và khi đó cách hai (tách tên, giữ một chuỗi) là lựa chọn tốt nhất.
Đặt tên cho điều kiện thì ghép được
Predicate<Don> cuaMinh = d -> d.khach().equals("Minh");
DS.stream().filter(DA_HOAN_TAT.and(cuaMinh))
[D1, D3]
Đây là lợi ích thứ hai của việc đặt tên, ngoài chuyện dễ đọc: các mảnh trở thành thứ dùng lại và ghép được. Một Predicate đặt tên có thể tái sử dụng ở năm chỗ, kết hợp bằng and/or, và dựng động từ tham số tìm kiếm như bài về giao diện hàm đã nói.
Một lambda viết thẳng trong filter thì không.
Bốn dấu hiệu chuỗi đã quá dài
Một: bạn phải cuộn ngang để đọc. Nghe tầm thường nhưng rất hiệu quả.
Hai: có lambda nhiều hơn một dòng.
.map(d -> {
long tien = tongTien(d);
String muc = tien > 50_000 ? "lớn" : "nhỏ";
return d.ma() + "(" + muc + ")";
})
Đây là một phương thức đang giả trang. Đặt cho nó cái tên:
.map(DocDuoc::moTa)
[D1(nhỏ), D2(nhỏ), D3(nhỏ), D4(nhỏ)]
Ba: có chuỗi Stream lồng trong chuỗi Stream. Một stream() bên trong một map là dấu hiệu rõ nhất cần tách hàm — đúng chỗ tongTien ở ví dụ trên.
Bốn: quá năm phép nối. Không phải luật cứng, nhưng khi vượt quá đó tôi luôn thấy có ít nhất một mảnh đáng đặt tên.
Ba thứ Stream không nên làm
Tác dụng phụ trong map hay filter. Chúng có thể được gọi số lần khác bạn nghĩ — đúng chuyện peek không chạy khi count() biết trước số phần tử ở bài trước. Cần tác dụng phụ thì đặt ở forEach cuối chuỗi, hoặc dùng vòng lặp.
Sửa collection đang duyệt. Ném ConcurrentModificationException giống hệt vòng lặp, và trong chuỗi Stream thì càng khó nhìn ra.
Dùng forEach thay cho for. ds.forEach(x -> {...}) không hơn gì for (var x : ds) mà mất break, mất chỉ số, stack trace khó đọc hơn.
Giới hạn của phong cách hàm trong Java
Java không phải ngôn ngữ hàm, và có vài chỗ nó cho thấy điều đó rõ:
Không có checked exception trong lambda. Mọi thao tác I/O trong chuỗi Stream đều phải bọc try-catch hoặc UncheckedIOException — và mã trở nên xấu ngay.
Không có bất biến bắt buộc. Lambda vẫn sửa được trạng thái bên ngoài qua trường hoặc đối tượng, nên "thuần tuý" là kỷ luật của bạn chứ không phải bảo đảm của ngôn ngữ.
Không có so khớp mẫu trên hàm, không có currying tiện lợi. Function.andThen là tất cả những gì có, và nó đủ cho việc thông thường nhưng không hơn.
Không có đệ quy đuôi được tối ưu. Đệ quy sâu vẫn tràn ngăn xếp, nên các thuật toán hàm kinh điển phải viết lại bằng vòng lặp.
Nên lời khuyên của tôi là: dùng phong cách hàm ở chỗ nó hợp, đừng cố biến Java thành Haskell. Một for rõ ràng luôn tốt hơn một chuỗi Stream phải giải mã.
Hết chặng lập trình hàm
Mười hai bài vừa qua: lambda, giao diện hàm, method reference, cấu tạo Stream, map/filter/flatMap, Collectors, tự viết collector, song song, Optional trong chuỗi, dữ liệu lớn, vòng lặp và Stream, và hôm nay là chuyện đọc được.
Ba điều tôi mong đọng lại:
Stream lười, và điều đó giải thích gần hết. Không có phép kết thì không chạy, từng phần tử đi dọc chuỗi, ngắt sớm hoạt động thật, file lớn hơn bộ nhớ vẫn xử lý được.
Đo trước khi tin. parallelStream chậm hơn hai nghìn lần trên dữ liệu nhỏ; IntStream trên mảng ngang vòng lặp. Cả hai đều trái trực giác.
Mục tiêu là mã đọc được, không phải mã ngắn. Nếu chuỗi Stream không nói ra ý định rõ hơn vòng lặp tương đương, nó không đáng dùng.
Từ ngày mai ta sang chặng I/O và dữ liệu — mười bài, bắt đầu từ java.nio.file, đi qua JSON, thời gian, regex, HTTP, tới JDBC và connection pool.
Thử ba mươi giây
Mở một file trong dự án của bạn, tìm chuỗi Stream dài nhất.
Đếm số phép nối, và tìm xem có lambda nào nhiều hơn một dòng không. Nếu có, đặt tên cho nó và xem chuỗi còn lại đọc thế nào — thường là bạn sẽ thấy toàn bộ ý định hiện ra trong một dòng.