Nghĩ một chuỗi Stream như tờ công thức dán trên tường bếp thì hai điều khó hiểu nhất của nó sáng ra ngay. Viết ra map, filter, sorted chỉ là ghi các bước lên giấy — chưa nồi niêu nào nóng lên. Bếp chỉ bắt đầu nấu khi có người hô "mang ra" — đó là phép kết. Và món được nấu xong hẳn từng đĩa một chứ không phải xào hết nguyên liệu rồi mới nêm: một phần tử chạy trọn cả công thức rồi mới tới phần tử sau. Hai đặc tính "không hô thì không nấu" và "xong từng đĩa" chính là toàn bộ bí ẩn của Stream, và một dòng peek đủ để lật tẩy.

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.

Muốn tự thấy tính lười tận mắt thì thử ngay: 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 không bao giờ quên thêm phép cuối nữa.

Mẫu số chung

Cái làm Stream khó hiểu lần đầu — "viết ra mà không chạy" — không phải quái tính của Java, mà là đặc điểm chung của phép lặp lười (lazy iteration), và gần như ngôn ngữ hiện đại nào cũng có nó cùng đúng cái bẫy "quên phép kết".

  • C# là bản song sinh rõ nhất: LINQ dùng deferred execution — .Where().Select() không chạy gì cho tới khi bạn foreach hoặc gọi .ToList(). Lỗi "quên ToList" của C# giống hệt lỗi "quên toList" của Java, tới từng triệu chứng.
  • Rust biến cảnh báo thành lời của trình biên dịch: iterator của Rust lười, và nếu bạn viết một chuỗi .map().filter() mà không .collect() hay for, compiler cảnh báo thẳng "iterators are lazy and do nothing unless consumed" — đúng bài học đầu bài, được ngôn ngữ nói hộ.
  • Python có generator và itertools lười y hệt: map/filter của Python 3 trả về iterator chưa chạy, phải list() hoặc vòng for mới kích hoạt. Kotlin thì tách bạch tường minh: thao tác trên List là eager (tạo list trung gian mỗi bước), còn .asSequence() chuyển sang lazy giống Stream — nên Kotlin bắt bạn chọn thứ mà Java mặc định.
  • Ngược lại, JavaScript Array.map/filter là eager — mỗi bước tạo một mảng trung gian mới, đúng cái Stream tránh; muốn lười trong JS phải dùng generator hoặc thư viện.

Sợi chỉ chung đáng mang theo: "lười" nghĩa là tách mô tả phép tính khỏi thực thi phép tính — và một khi tách ra, bạn được ba món quà (không tốn bộ nhớ trung gian, xử lý được nguồn vô hạn, ngắt sớm thật sự) đổi lấy một cái bẫy (quên "tiêu thụ" thì chẳng gì chạy). Java gọi bước tiêu thụ là "phép kết", C# gọi là ToList/foreach, Rust gọi là collect, Python gọi là list(). Khác tên, cùng một luật: đã lười thì phải có ai đó hô "mang ra", không thì bếp cứ để nguội.

Ngày mai: map, filter và flatMap — đặc biệt là flatMap, phép mà ai cũng vấp lần đầu.