Coi một chuỗi Stream như một câu văn thì thấy ngay vấn đề. Một câu viết tốt đọc trôi từ đầu tới cuối: lấy đơn đã hoàn tất, gom theo khách, cộng tổng tiền. Một câu viết tệ là câu dài lê thê, mệnh đề lồng mệnh đề, đọc tới cuối quên mất đầu — đúng, nó vẫn đúng ngữ pháp, nhưng phải đọc ba lần mới hiểu. Stream nguy hiểm đúng ở chỗ đó: nó khiến câu dài lê thê trông gọn gàng, "hiện đại", trong khi thực ra bắt người đọc giữ năm tầng ngữ cảnh trong đầu cùng lúc. Cả bài hôm nay là về cách viết câu cho người sau đọc được.

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?

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.

Muốn tự soi mã mình thì mở một file trong dự á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à toàn bộ ý định hiện ra gọn trong một dòng.

Mẫu số chung

Cái căng thẳng "chuỗi hàm gọn mắt nhưng khó đọc" không phải chuyện riêng của Java Stream — nó xuất hiện ở mọi ngôn ngữ có giao diện kiểu fluent hoặc chuỗi phép biến đổi, và cách mỗi nơi xử lý cho thấy một triết lý về khả năng đọc.

  • JavaScript có đúng cái bẫy này với .map().filter().reduce(): một chuỗi dài với lambda viết thẳng trông "sạch" nhưng rất khó gỡ, và cộng đồng JS tranh luận không dứt giữa chuỗi hàm và for...of. Cách chữa giống hệt Java — đặt tên cho từng bước và từng vị từ.
  • Python thì chọn sẵn khả năng đọc vào ngôn ngữ: thay vì chuỗi map(filter(...)) lồng nhau, Python ưu tiên list comprehension ([tongTien(d) for d in ds if da_hoan_tat(d)]) vì nó đọc gần với câu nói thường hơn. Guido cố ý không thêm nhiều công cụ hàm nặng đô chính vì lý do đọc-được.
  • Rust có chuỗi iterator mạnh nhất nhóm này (.iter().filter().map().collect()), và văn hoá Rust cũng khuyên tách closure nhiều dòng thành hàm có tên — đúng "lambda nhiều hơn một dòng là phương thức giả trang" của ta.

Sợi chỉ chung đáng mang theo: phong cách hàm không tự động làm mã dễ đọc hơn — nó chỉ dời độ phức tạp từ vòng lặp sang chuỗi phép biến đổi, và nếu bạn không đặt tên cho các mảnh thì độ phức tạp đó dồn hết vào một biểu thức lồng nhau không ai đọc nổi. Quy tắc vàng giống nhau ở Java, JS, Python, Rust: mỗi mảnh có ý nghĩa nên có một cái tên, và nếu một chuỗi không nói ra ý định rõ hơn vòng lặp tương đương thì đừng dùng nó. Mã ngắn không phải mục tiêu; mã mà người sau đọc một lượt là hiểu mới là mục tiêu — và đôi khi, ở mọi ngôn ngữ, một vòng for rõ ràng thắng một chuỗi hàm khéo léo.

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.