Hình dung một băng chuyền trong nhà máy: gói hàng chạy qua từng trạm, mỗi trạm chỉ làm việc nếu trong gói có đồ, còn gói rỗng thì cứ thế trôi qua tới cuối. Optional trong một chuỗi Stream hoạt động đúng như vậy — bạn để cái hộp chạy trên băng chuyền và xếp các thao tác thành trạm. Cái phản xạ isPresent() rồi get() là nhấc gói ra khỏi băng chuyền giữa chừng, mở xem, rồi loay hoay đặt lại — làm nhiều hơn mà chẳng được gì hơn. Hôm nay là về việc cứ để cái hộp chạy.
Bài về Optional ở chặng trước nói nó là kiểu trả về cho phương thức có thể không tìm thấy gì. Bài này về chỗ bạn gặp nó nhiều nhất mà không chọn: cuối một chuỗi Stream.
Bảy phép kết trả về Optional
findFirst : Optional[Effective Java]
max : Optional[Java Concurrency]
min : Optional[Java Concurrency]
reduce : Optional[Clean Code | Effective Java | Java Concurrency]
trên stream rỗng: Optional.empty
findFirst, findAny, max, min, reduce một tham số, và average của IntStream — tất cả đều trả về Optional.
Lý do rõ ràng: stream có thể rỗng. Không có Optional thì chúng phải trả null (và bạn quên kiểm) hoặc ném ngoại lệ (và bạn phải bọc try-catch cho một trường hợp bình thường).
Đừng mở hộp ngay, hãy nối tiếp
Phản xạ đầu tiên của nhiều người là lấy giá trị ra càng sớm càng tốt:
Optional<Sach> o = timTheoTen(ten);
if (o.isPresent()) {
String tacGia = o.get().tacGia().toUpperCase();
...
}
isPresent + get : Clean Code (chỉ là if viết dài)
Đây là dùng Optional như một cái if — bạn viết dài hơn và không được gì.
Cách đúng là giữ nguyên trong hộp và nối tiếp:
timTheoTen(ten)
.map(Sach::tacGia)
.map(String::toUpperCase)
.orElse("(không tìm thấy)");
Clean Code -> MARTIN
Không Có -> (không tìm thấy)
Cả hai trường hợp — tìm thấy và không — đi qua cùng một luồng mã, không có nhánh nào. Nếu Optional rỗng thì mọi map phía sau đơn giản là không chạy.
Cách hình dung: Optional là một stream có 0 hoặc 1 phần tử, và map với filter trên nó hoạt động đúng như trên Stream.
filter ngay trên Optional
sách rẻ hơn 50 : Optional.empty
sách rẻ hơn 60 : Optional[Effective Java]
timTheoTen("Effective Java").filter(s -> s.gia() < 50).map(Sach::ten)
Tìm thấy sách nhưng không thoả điều kiện thì Optional thành rỗng. Rất gọn cho những câu kiểu "lấy X nếu nó còn hợp lệ".
or: thử nguồn dự phòng
timTheoTen("Không Có")
.or(() -> timTheoTen("Clean Code"))
.map(Sach::ten);
-> tra nguồn thứ hai
Optional[Clean Code]
or (Java 9) nhận một Supplier<Optional> và chỉ gọi khi cái đầu rỗng — nên nguồn dự phòng chỉ được truy vấn khi cần, đúng nguyên tắc của orElseGet ở bài Optional.
Đây là mẫu rất hợp cho tra cứu nhiều tầng: bộ nhớ đệm trước, rồi CSDL, rồi API ngoài.
tuCache(id).or(() -> tuCsdl(id)).or(() -> tuApi(id)).orElseThrow(...)
Bốn dòng thay cho một chuỗi if lồng ba tầng.
ifPresentOrElse: hai nhánh, không cần if
timTheoTen("Không Có").ifPresentOrElse(
s -> System.out.println("thấy " + s.ten()),
() -> System.out.println("không thấy, ghi log"));
không thấy, ghi log
Có từ Java 9. Dùng khi cả hai nhánh đều là hành động, không phải giá trị — ghi log, gửi thông báo, cập nhật số liệu.
Nếu cả hai nhánh trả về giá trị thì map(...).orElseGet(...) đọc gọn hơn.
Từ nhiều Optional về một Stream
Stream.of("Clean Code", "Không Có", "Effective Java")
.map(OptStream::timTheoTen) // Stream<Optional<Sach>>
.flatMap(Optional::stream) // Stream<Sach>, bỏ hết cái rỗng
.map(Sach::ten).toList();
[Clean Code, Effective Java]
Optional.stream() (Java 9) là cây cầu giữa hai thế giới. Ta đã gặp ở bài flatMap; đây là chỗ nó dùng nhiều nhất trong thực tế — tra cứu hàng loạt rồi bỏ những cái không tìm thấy.
Chiều ngược lại cũng có: stream().findFirst() biến một Stream thành Optional.
Đừng dùng orElseThrow() trần
orElseThrow() -> No value present
Thông điệp "No value present" chẳng nói gì cho người đọc log lúc hai giờ sáng. Luôn truyền hàm tạo ngoại lệ có ngữ cảnh:
.orElseThrow(() -> new KhongTimThay("không có sách nào trong kho " + maKho))
Đúng nguyên tắc ở bài thiết kế ngoại lệ: thông điệp phải trả lời được cái gì sai, ở đâu.
Vài chỗ hay gặp trong chuỗi Stream
average trả OptionalDouble, không phải Optional<Double>. Cả họ OptionalInt, OptionalLong, OptionalDouble tồn tại để tránh đóng hộp — chúng có getAsInt, getAsDouble thay vì get, và không có map. Cần biến đổi thì .stream().boxed() hoặc dùng isPresent.
reduce hai tham số không trả Optional, vì đã có giá trị khởi tạo:
.reduce(0, Integer::sum) // int
.reduce(Integer::sum) // Optional<Integer>
max trên Comparator null-unfriendly sẽ ném NPE nếu có phần tử null trong stream — lọc trước bằng filter(Objects::nonNull).
findAny không phải findFirst. Với stream tuần tự chúng thường cho cùng kết quả, nhưng với song song thì findAny trả về phần tử bất kỳ tìm được sớm nhất — nhanh hơn, và không đoán trước được.
Đừng đưa Optional đi quá xa
Một cảnh báo nhắc lại từ bài trước, vì trong chuỗi Stream rất dễ vi phạm:
Map<String, Optional<Sach>> m = ...; // đừng
List<Optional<Sach>> ds = ...; // cũng đừng
Optional sinh ra để trả về từ một phương thức, không phải để lưu trữ. Trong collection, hãy giải quyết nó ngay bằng flatMap(Optional::stream) rồi lưu giá trị trần.
Muốn luyện phản xạ "đừng mở hộp sớm" thì làm thử trên dự án của bạn: tìm một đoạn có .isPresent() rồi .get() ở dòng sau, viết lại bằng .map(...).orElse(...) hoặc .ifPresent(...). Ngắn hơn, và không còn chỗ nào có thể gọi get() nhầm lúc rỗng.
Mẫu số chung
Cái ý "giữ giá trị trong hộp rồi nối thao tác, thay vì mở hộp kiểm tra ở mỗi bước" không phải phát minh của Java — nó là cách dùng chuẩn mực của kiểu optional ở mọi ngôn ngữ có nó, và Java thực ra học lại từ thế giới hàm.
- Rust có bộ combinator trên
Optiongần như trùng tên một-một:map,and_then(chính làflatMap),filter,or_else,unwrap_or/unwrap_or_else(chính làorElse/orElseGet), vàok_or(chính làorElseThrow). Ai quen chuỗiOptionalcủa Java sang Rust đọc được gần như ngay. - Swift và JavaScript/TypeScript biến nó thành cú pháp: optional chaining
a?.b?.cchạy tiếp nếu có, dừng thành "rỗng" nếu gặp nil/undefined — đúng hành vi chuỗimapcủa ta, nhưng ngắn tới mức thành toán tử. Cặp?.và??của JS (ES2020) làmap(...).orElse(...)gói lại. - Kotlin đi cùng đường:
?.cho bước nối,?:(Elvis) cho giá trị mặc định,letcho nhánh hành động — ba thứ thay cho cả họmap/orElse/ifPresent, và vì null đã nằm trong kiểu nên không cần lớp bọc. Scala thìOptionvớimap/flatMap/getOrElselà nơi Java chép ý tưởng về.
Sợi chỉ chung đáng mang theo: xử lý sự vắng mặt là một phép biến đổi, không phải một câu điều kiện. Mỗi lần bạn thấy mình viết "kiểm tra có không, nếu có thì lấy ra, rồi làm tiếp", gần như luôn có một cách viết nó thành một chuỗi phép biến đổi — và chuỗi đó vừa ngắn hơn vừa không để lọt cái bẫy "quên kiểm rồi gọi get()". Java gọi các bước đó là map/filter/or; Rust gọi là map/and_then; Kotlin và Swift rút thành ?.. Khác cú pháp, cùng một lối nghĩ.
Ngày mai: Stream với dữ liệu lớn — đọc file khổng lồ mà không nổ bộ nhớ, và vì sao tính lười lại là thứ làm được điều đó.