1. Xử lý lười là gì?
Xử lý lười, hay “tính toán lười” (lazy evaluation), là nguyên tắc hoãn thực hiện thao tác trên dữ liệu cho đến khi kết quả thực sự cần thiết. Trong ngữ cảnh Stream API, điều này có nghĩa là: nếu bạn viết một chuỗi biến đổi trên một collection, Java sẽ không thực thi ngay — nó chờ cho đến khi một thao tác terminal được gọi. Chỉ khi đó cả chuỗi mới được tính toán.
Tại sao cần điều này? Thứ nhất, tiết kiệm tài nguyên: các phần tử rốt cuộc không cần đến sẽ không bị xử lý. Thứ hai, tăng hiệu năng — bạn có thể xây những chuỗi dài mà không phải tạo ra nhiều collection trung gian. Cuối cùng, có “short-circuit”: ngay khi tìm thấy phần tử phù hợp đầu tiên, việc xử lý tiếp theo được dừng lại.
So sánh: hãy tưởng tượng một người phục vụ “lười”. Bạn nói: “Mang menu, rồi cà phê, rồi bánh ngọt.” Anh ta gật đầu nhưng chẳng làm gì… cho tới khi bạn thêm: “Giờ hãy mang thật đi.” Lúc đó anh ta mới đi thực hiện — và có thể chỉ mang cà phê nếu đã hết bánh. Stream “lười” cũng hoạt động tương tự như vậy.
2. Thao tác trung gian và thao tác kết thúc
Trung gian (intermediate):
- filter
- map
- sorted
- distinct
- peek (để debug)
- v.v.
Các thao tác trung gian trả về một Stream mới, nhưng không khởi chạy tính toán. Chúng chỉ “lập kế hoạch” xử lý.
Kết thúc (terminal):
- collect
- forEach
- reduce
- count
- findFirst, findAny
- anyMatch, allMatch, noneMatch
- v.v.
Chỉ thao tác terminal mới kích hoạt việc thực thi toàn bộ chuỗi.
Ví dụ: không có gì xảy ra nếu không có thao tác terminal
List<String> names = List.of("Alisa", "Bob", "Vasya");
names.stream()
.filter(name -> {
System.out.println("Lọc " + name);
return name.startsWith("A");
});
// Sẽ không có thông báo nào! Mã bên trên chỉ "xây" chuỗi thao tác.
Bây giờ thêm thao tác terminal:
names.stream()
.filter(name -> {
System.out.println("Lọc " + name);
return name.startsWith("A");
})
.forEach(System.out::println);
// Bây giờ sẽ thấy output trong console!
Kết quả:
Lọc Alisa
Lọc Bob
Lọc Vasya
Alisa
3. Ưu điểm của xử lý lười
Tiết kiệm tài nguyên
Xử lý lười giúp không tốn thời gian và bộ nhớ cho những phần tử không cần thiết. Ví dụ, nếu bạn tìm phần tử phù hợp đầu tiên, việc xử lý sẽ dừng lại ngay ở lần trùng khớp đầu tiên.
List<String> names = List.of("Alisa", "Bob", "Vasya", "Anna");
String firstA = names.stream()
.filter(name -> {
System.out.println("Kiểm tra: " + name);
return name.startsWith("A");
})
.findFirst()
.orElse("Không tìm thấy");
System.out.println("Kết quả: " + firstA);
Output:
Kiểm tra: Alisa
Kết quả: Alisa
Lưu ý: các phần tử còn lại thậm chí không được kiểm tra!
Chuỗi dài mà không cần collection trung gian
Có thể kết hợp nhiều thao tác (filter, map, sorted, v.v.) mà không tạo collection ở mỗi bước.
List<String> names = List.of("Alisa", "Bob", "Vasya", "Anna");
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList(); // Java 16+, trước đây — .collect(Collectors.toList())
Short-circuit
Nếu chỉ cần biết “có phần tử phù hợp hay không”, các phần còn lại sẽ không được kiểm tra:
boolean hasLongName = names.stream()
.anyMatch(name -> {
System.out.println("Kiểm tra: " + name);
return name.length() > 10;
});
// Nếu phần tử đầu tiên đủ dài — các phần còn lại sẽ không được kiểm tra!
4. Ví dụ: xử lý lười hoạt động thế nào
Ví dụ 1: không có gì xảy ra nếu không có thao tác terminal
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
numbers.stream()
.filter(n -> {
System.out.println("Lọc " + n);
return n % 2 == 0;
});
// Không có output!
Ví dụ 2: chuỗi có thao tác terminal
numbers.stream()
.filter(n -> {
System.out.println("Lọc " + n);
return n % 2 == 0;
})
.map(n -> {
System.out.println("Nhân " + n);
return n * 10;
})
.forEach(System.out::println);
Output:
Lọc 1
Lọc 2
Nhân 2
20
Lọc 3
Lọc 4
Nhân 4
40
Lọc 5
Lưu ý quan trọng: các thao tác được thực hiện theo từng phần tử: trước filter, rồi map, sau đó forEach — lần lượt cho từng phần tử. Đây không phải hai lượt duyệt riêng biệt kiểu “lọc hết rồi mới biến đổi hết”.
Ví dụ 3: dùng peek để debug
numbers.stream()
.filter(n -> n % 2 == 0)
.peek(n -> System.out.println("Qua bộ lọc: " + n))
.map(n -> n * 10)
.peek(n -> System.out.println("Sau map: " + n))
.forEach(System.out::println);
5. Những điểm hữu ích
Không dùng stream với tác dụng phụ
Tính “lười” có thể gây rắc rối nếu bạn trông đợi thực thi tức thì. Các tác vụ phụ trong map, filter hoặc peek (ghi file, thay đổi trạng thái bên ngoài) có thể chạy không đúng thứ tự, không cho tất cả phần tử hoặc thậm chí không chạy nếu không có thao tác terminal.
Lọc càng sớm càng tốt
Đặt filter càng gần đầu chuỗi càng tốt để loại bỏ sớm phần tử thừa, giảm khối lượng công việc phía sau.
Chỉ cần kết quả đầu tiên? Hãy dùng thao tác terminal phù hợp
Nếu chỉ cần phần tử phù hợp đầu tiên — hãy gọi findFirst hoặc findAny. Điều này cho phép stream dừng ngay khi có kết quả.
Streams không dùng để thay đổi collection gốc
Streams không dành cho việc thêm/xóa phần tử của collection gốc. Để chỉnh sửa cấu trúc collection, hãy dùng cơ chế khác.
Trực quan hóa cách stream lười hoạt động
List<String> words = List.of("cat", "dog", "elephant", "fox", "giraffe");
words.stream()
.filter(w -> w.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
Cách nó diễn ra:
| Giai đoạn | cat | dog | elephant | fox | giraffe |
|---|---|---|---|---|---|
|
✗ | ✗ | ✓ | ✗ | ✓ |
|
— | — | ELEPHANT | — | GIRAFFE |
|
— | — | in | — | in |
Bảng: so sánh cách tiếp cận eager và lazy
| Cách tiếp cận | Khi nào xử lý diễn ra? | Sử dụng bộ nhớ | Hiệu năng |
|---|---|---|---|
| Eager (tham lam) | Ngay khi được gọi | Có thể nhiều | Đôi khi chậm |
| Lazy (lười) | Chỉ khi cần | Tối thiểu | Thường nhanh hơn |
Cách tiếp cận eager — ví dụ bạn tự tổ chức nhiều lượt duyệt qua collection, tạo ra các danh sách trung gian.
Cách tiếp cận lazy — tức là streams: không làm gì cho đến khi cần kết quả cuối.
6. Các lỗi thường gặp khi làm việc với streams “lười”
Lỗi số 1: kỳ vọng kết quả tức thì. Người mới nghĩ rằng gọi filter hoặc map sẽ chạy ngay. Nhưng nếu không có thao tác terminal (ví dụ, collect, forEach) thì không có gì xảy ra — dẫn đến “không debug được”, “không có gì in ra”.
Lỗi số 2: tác dụng phụ trong thao tác trung gian. Ghi file, thay đổi biến bên ngoài bên trong map/filter/peek là thực hành tệ. Do tính lười và các tối ưu hóa, những hành động này có thể không chạy đầy đủ, không theo thứ tự mong muốn hoặc không chạy nếu thiếu thao tác terminal.
Lỗi số 3: quên gọi thao tác terminal. Bạn viết chuỗi stream nhưng không kết thúc bằng collect, forEach, v.v. Kết quả — “im lặng”.
Lỗi số 4: kỳ vọng mọi phần tử đều được xử lý. Các thao tác như findFirst hoặc anyMatch sẽ dừng pipeline khi có kết quả đầu tiên. Những phần tử còn lại không được xử lý — dẫn tới thắc mắc “tại sao println của tôi không chạy cho tất cả?”.
Lỗi số 5: dùng streams để thay đổi collection gốc. Streams không dành cho việc chỉnh sửa collection gốc (thêm/xóa phần tử). Hãy dùng các phương thức chuyên biệt của collection hoặc iterator.
GO TO FULL VERSION