Hiểu lầm phổ biến nhất về Stream: người ta tưởng nó chạy theo tầng — map xong hết cả danh sách, rồi mới tới filter.
Thực tế ngược lại, và một dòng peek là đủ để thấy.
Không có phép kết thì không gì chạy
Stream.of("a","b","c")
.peek(s -> System.out.println("thấy " + s))
.map(String::toUpperCase);
(không in ra dòng nào)
Chuỗi này khai đầy đủ, peek có in ra màn hình, mà chạy không có gì xảy ra.
Vì các phép trung gian — map, filter, peek, sorted, limit — chỉ ghi lại ý định. Chúng không đụng tới dữ liệu. Toàn bộ công việc chỉ bắt đầu khi có một phép kết: toList, forEach, count, reduce, findFirst.
Đây là lý do một chuỗi Stream thiếu phép cuối là lỗi im lặng — mã biên dịch được, chạy được, và không làm gì cả. IDE thường cảnh báo, và cảnh báo đó đáng nghe.
Từng phần tử đi hết chuỗi rồi mới tới phần tử sau
Thêm phép kết vào và đánh số từng bước:
1. nguồn : a
2. map : a
3. filter : A
1. nguồn : b
2. map : b
3. filter : B
1. nguồn : c
2. map : c
3. filter : C
kết quả: [A, C]
Đọc thứ tự này kỹ. Không phải a b c rồi mới map, mà là a đi hết chuỗi, rồi tới b, rồi c.
Cách hình dung: Stream đi dọc, không đi ngang. Mỗi phần tử được đẩy qua toàn bộ đường ống trước khi phần tử tiếp theo bắt đầu.
Ba hệ quả thực tế:
Không cần bộ nhớ trung gian. Chuỗi map().filter().map() trên một triệu phần tử không tạo ra ba danh sách một triệu phần tử. Với vòng lặp truyền thống mà mỗi bước tạo một List mới thì có.
Xử lý được nguồn vô hạn. Vì không phải đọc hết mới bắt đầu.
Ngắt sớm hoạt động thật sự. Xem mục dưới.
Ngắt sớm
Stream.of(1,2,3,4,5,6,7,8,9,10)
.peek(x -> System.out.print("xét " + x))
.filter(x -> x % 4 == 0)
.findFirst();
xét 1 xét 2 xét 3 xét 4
tìm thấy: 4 (không xét tới 10)
Sáu phần tử còn lại không bao giờ được chạm tới. Với một danh sách nhỏ thì không đáng kể; với một stream đọc từ file mười triệu dòng thì đó là khác biệt giữa vài mili giây và vài phút.
Các phép ngắt sớm: findFirst, findAny, anyMatch, allMatch, noneMatch, limit.
allMatch dừng ngay khi gặp phần tử không thoả; noneMatch dừng khi gặp phần tử thoả. Đây là lý do nên dùng chúng thay vì filter(...).count() > 0.
Nguồn vô hạn
Stream.iterate(1, x -> x * 2).limit(8).toList();
[1, 2, 4, 8, 16, 32, 64, 128]
Stream.iterate sinh vô hạn phần tử, limit cắt lại. Không có tính lười thì đoạn này treo máy.
Từ Java 9 có dạng ba tham số, đọc như vòng for:
Stream.iterate(1, x -> x < 20, x -> x * 3)
[1, 3, 9]
Đọc là: bắt đầu từ 1, tiếp tục khi còn nhỏ hơn 20, mỗi bước nhân 3. So với for (int x = 1; x < 20; x *= 3) thì cùng ý nghĩa.
Thứ tự phép quyết định số lần tính
filter trước rồi map : m2 m4
map trước rồi filter : m1 m2 m3 m4
Cùng kết quả, nhưng bản đầu gọi map hai lần, bản sau gọi bốn lần.
Quy tắc: đặt phép lọc càng sớm càng tốt. Mỗi phần tử bị loại sớm là một phần tử không phải đi qua các bước tốn kém phía sau.
Cùng lý do, limit nên đặt ngay sau chỗ có thể. Nhưng cẩn thận với sorted — nó là phép chặn: phải gom hết dữ liệu mới sắp xếp được, nên mọi thứ sau nó mất tính lười một phần. Đặt filter trước sorted luôn tốt hơn.
Stream dùng một lần
dùng lại -> stream has already been operated upon or closed
Một Stream chỉ tiêu thụ được một lần. Muốn duyệt hai lần thì tạo hai stream, hoặc gom kết quả vào một List rồi dùng lại danh sách đó.
Đây là khác biệt căn bản với Collection: collection lưu trữ dữ liệu, stream mô tả một phép tính trên dữ liệu.
Nếu cần một stream tái sử dụng được, hãy giữ một Supplier<Stream<T>> thay vì giữ chính stream.
Các nguồn thường dùng
Stream.of : [1, 2, 3]
List.stream : [a, b]
Arrays.stream : 6
IntStream.range : [0, 1, 2, 3, 4]
Stream.generate : [x, x, x]
String.chars : [a, b, c]
Vài cái đáng nhớ thêm:
Files.lines(path) đọc file theo dòng, lười — không nạp cả file vào bộ nhớ. Nhưng nó giữ tài nguyên nên phải đóng, và đây là một trong số ít stream cần try-with-resources.
IntStream.range(a, b) loại b, rangeClosed(a, b) bao gồm. Dùng thay cho vòng for khi cần đưa chỉ số vào chuỗi Stream.
Stream.generate(supplier) sinh vô hạn không theo quy luật, hợp với dữ liệu ngẫu nhiên. Luôn phải limit.
Random.ints(), Random.doubles() cũng trả về stream.
peek chỉ để gỡ lỗi
peek nhận một Consumer và trả lại đúng stream đó. Nó rất hợp để nhìn vào giữa chuỗi như bài này đang làm.
Nhưng đừng dùng nó cho logic thật. Hai lý do:
Nó là phép trung gian nên lười — không có phép kết thì peek không chạy, như phần đầu bài đã thấy.
Từ Java 9, JVM được phép bỏ qua một số phép trung gian nếu chứng minh được kết quả không đổi. Ví dụ list.stream().peek(...).count() có thể không gọi peek lần nào, vì count biết trước số phần tử.
Cần tác dụng phụ thì dùng forEach ở cuối chuỗi.
Ba loại phép
Gom lại thành bảng cho dễ tra:
| Loại | Ví dụ | Đặc điểm |
|---|---|---|
| Nguồn | stream(), Stream.of, IntStream.range |
tạo ra stream |
| Trung gian | map, filter, sorted, distinct, limit, skip, peek |
lười, trả về stream mới |
| Kết | toList, forEach, count, reduce, findFirst, anyMatch, collect |
chạy thật, trả về kết quả |
Hai phép trung gian đặc biệt vì chúng chặn: sorted và distinct cần gom dữ liệu trước khi cho ra phần tử đầu tiên. Đặt chúng sau các phép lọc.
Khi nào đừng dùng Stream
Một cân bằng cần thiết: Stream không phải lúc nào cũng tốt hơn vòng lặp.
Vòng lặp đơn giản trên collection nhỏ — for dễ đọc hơn và không có chi phí khởi tạo đường ống.
Khi cần break giữa chừng với logic phức tạp — takeWhile giải quyết được một số trường hợp, còn lại thì vòng lặp rõ hơn.
Khi cần chỉ số — Stream không có khái niệm vị trí. IntStream.range(0, n) là cách vòng, nhưng đọc kém hơn for thường.
Khi thân xử lý có tác dụng phụ — ghi file, gọi API, cập nhật trạng thái. Stream sinh ra cho phép biến đổi thuần tuý.
Ta sẽ đo cụ thể vòng lặp và Stream ở một bài riêng cuối chặng.
Thử ba mươi giây
Lấy chuỗi ba bước ở đầu bài, thêm .peek(System.out::println) vào giữa, rồi chạy hai lần: một lần có .toList() ở cuối, một lần không.
Lần không có phép kết, màn hình trống trơn. Đó là toàn bộ ý nghĩa của "Stream lười" — và một khi thấy tận mắt thì bạn sẽ không bao giờ quên thêm phép cuối nữa.
Ngày mai: map, filter và flatMap — đặc biệt là flatMap, phép mà ai cũng vấp lần đầu.