Tưởng tượng hai cách người ta trao cho bạn một gói hàng. Cách thứ nhất: đưa thẳng cái hộp, trông như có đồ, bạn cứ thế mở dùng — hôm nào nó rỗng thì bạn ngã ngửa giữa chừng. Đó là null: một giá trị trông đầy đặn cho tới lúc nó không. Cách thứ hai: đưa bạn cái hộp dán nhãn to đùng "CÓ THỂ RỖNG", buộc bạn phải mở ra nhìn rồi mới quyết định làm gì. Đó là Optional. Nó không làm cái hộp đầy hơn — nó chỉ dán cái nhãn, và cái nhãn ấy mới là toàn bộ giá trị của nó. Hiểu sai cái nhãn làm được gì và không làm được gì chính là lý do Optional hay bị dùng hỏng.

Optional vào Java 8 với lời hứa lớn: chấm dứt NullPointerException.

Mười hai năm sau, phần lớn mã Java vẫn đầy NPE, và không ít dự án còn có thêm một loại lỗi mới — NoSuchElementException từ chính Optional.

Vấn đề không nằm ở công cụ. Nó nằm ở chỗ Optional được thiết kế cho một mục đích rất hẹp, còn người ta đem dùng cho mọi thứ.

Optional sinh ra để làm gì

Nó có đúng một công dụng chính: làm kiểu trả về của phương thức có thể không tìm thấy gì.

static Optional<NguoiDung> tim(String id) {
    return Optional.ofNullable(kho.get(id));
}

Chữ ký này nói với người gọi một điều mà NguoiDung tim(String id) không nói được: có thể không có kết quả, và bạn phải xử lý chuyện đó. Trình biên dịch không ép, nhưng kiểu trả về đã nói rõ.

So với trả về null: người gọi phải đọc tài liệu — hoặc đọc mã nguồn — mới biết có cần kiểm tra null không. Và họ sẽ quên.

Ba cách tạo

  of(x)        : Optional[x]
  ofNullable() : Optional.empty
  empty()      : Optional.empty
  of(null) -> NPE ngay lập tức

Optional.of(x) — dùng khi bạn chắc chắn x khác null. Null thì nó ném NPE ngay, và đó là tính năng: nó báo lỗi lập trình tại chỗ thay vì để bạn cầm một Optional rỗng đi khắp nơi.

Optional.ofNullable(x) — dùng khi x có thể null. Đây là cầu nối từ API cũ trả về null.

Optional.empty() — rỗng tường minh.

Chuỗi map/flatMap thay cho if lồng nhau

Đây là chỗ Optional thật sự có giá trị. Cách viết truyền thống:

NguoiDung n = tim(id);
if (n != null) {
    DiaChi d = n.diaChi();
    if (d != null) {
        return d.thanhPho();
    }
}
return "(không rõ)";

Với Optional:

String tp = tim(id)
        .flatMap(NguoiDung::diaChiOpt)
        .map(DiaChi::thanhPho)
        .orElse("(không rõ)");
  u1 -> Hà Nội
  u2 -> (không rõ)      <- có người dùng, nhưng địa chỉ null
  u3 -> (không rõ)      <- không có người dùng

Hai trường hợp "không có" hoàn toàn khác nhau được xử lý bằng cùng một luồng, không có if nào.

Khác biệt map và flatMap:

map khi hàm trả về giá trị thường. Optional<DiaChi> qua map(DiaChi::thanhPho) thành Optional<String>.

flatMap khi hàm đã trả về Optional. Không có nó, bạn nhận Optional<Optional<DiaChi>> — đúng lý do flatMap tồn tại, và ta sẽ gặp lại khái niệm này ở chặng Stream.

orElse chạy cả khi không cần

Đây là cái bẫy hay gặp nhất, và cũng dễ chứng minh nhất:

Optional<String> co = Optional.of("có sẵn");
co.orElse(tinhTonKem());        // tinhTonKem() in ra một dòng khi chạy
co.orElseGet(Opt::tinhTonKem);
  orElse    : (hàm tốn kém ĐÃ CHẠY)
  orElseGet :

Optional đã có giá trị, vậy mà orElse vẫn chạy hàm tính giá trị mặc định. orElseGet thì không.

Lý do đơn giản: orElse(x) nhận một giá trị, nên x phải được tính trước khi vào phương thức — đúng nguyên nhân với dòng log.debug nối chuỗi hôm qua. orElseGet(supplier) nhận một hàm, chỉ gọi khi cần.

Quy tắc:

Giá trị mặc định là hằng số rẻ tiền — orElse("(không rõ)"), orElse(0) — thì dùng orElse, đọc gọn hơn.

Giá trị mặc định phải tính toán, truy vấn CSDL, hay gọi mạng — bắt buộc orElseGet.

Cùng bẫy đó với ngoại lệ: orElseThrow(() -> new ...) nhận hàm, nên ngoại lệ chỉ được tạo khi rỗng. orElseThrow() không tham số thì ném NoSuchElementException với thông điệp "No value present" — đúng, nhưng vô ích cho người đọc log. Hãy luôn truyền hàm với thông điệp có ngữ cảnh:

tim(id).orElseThrow(() -> new NoSuchElementException("không có người dùng " + id));
  không có người dùng u3

Bốn cách dùng phản tác dụng

Một: get() không kiểm tra.

  get() -> java.util.NoSuchElementException: No value present

opt.get() sau khi opt.isPresent() chỉ là if (x != null) viết dài dòng hơn — bạn không được gì cả. Còn get() mà không kiểm tra thì chỉ đổi NullPointerException thành NoSuchElementException.

Từ Java 10 có orElseThrow() không tham số làm đúng việc của get() nhưng tên nói rõ hậu quả. Hãy dùng nó, và coi get() như đã lỗi thời.

Hai: Optional làm trường của lớp.

class NguoiDung {
    private Optional<DiaChi> diaChi;   // đừng
}

Optional không Serializable, tốn thêm một đối tượng cho mỗi thể hiện, và làm mọi framework dùng phản chiếu — JPA, Jackson — khó chịu. Trường cứ để null, rồi bọc lại ở getter:

private DiaChi diaChi;
public Optional<DiaChi> diaChi() { return Optional.ofNullable(diaChi); }

Ba: Optional làm tham số.

void capNhat(String ten, Optional<String> ghiChu)   // đừng

Người gọi giờ phải viết Optional.of("x") hoặc Optional.empty() — dài dòng hơn hẳn truyền null. Và họ vẫn có thể truyền null vào chính tham số Optional đó, tạo ra tình huống buồn cười nhất: Optional bị null.

Dùng nạp chồng, hoặc nhận null và xử lý bên trong.

Bốn: Optional<List<T>>.

Danh sách rỗng đã diễn đạt được "không có gì" rồi. Optional<List<T>> bắt người gọi xử lý hai trạng thái rỗng khác nhau mà chẳng để làm gì. Trả List.of().

Optional trong Stream

Từ Java 9, Optional.stream() biến một Optional thành Stream có 0 hoặc 1 phần tử — rất tiện để lọc bỏ những cái rỗng:

Stream.of("u1", "u2", "u3")
      .map(Opt::tim)             // Stream<Optional<NguoiDung>>
      .flatMap(Optional::stream) // Stream<NguoiDung>, bỏ hết cái rỗng
      .map(NguoiDung::ten)
      .toList();
  [Minh]

Cách viết cũ là .filter(Optional::isPresent).map(Optional::get) — dài hơn và có get().

Cũng nên nhớ: findFirst(), findAny(), max(), min(), reduce() của Stream đều trả về Optional. Đó là lý do bạn gặp nó thường xuyên ở chặng sau.

Vậy nó có xoá được NPE không

Không, và nó không hứa điều đó. Cái nó cho là làm cho sự vắng mặt trở nên hiển hiện trong kiểu.

NguoiDung tim(String id) không nói gì về khả năng không tìm thấy. Optional<NguoiDung> tim(String id) thì nói, và người gọi buộc phải viết một câu gì đó để lấy giá trị ra — chính lúc viết câu đó, họ nghĩ tới trường hợp rỗng.

Nói cách khác, Optional là công cụ giao tiếp giữa người viết API và người dùng API, không phải một lớp bảo vệ lúc chạy. Dùng đúng chỗ đó thì nó rất tốt. Rải khắp nơi thì nó chỉ thêm một tầng bọc.

Với dự án dùng nhiều thư viện, chú thích @Nullable và @NonNull — của JSpecify hoặc JetBrains — bổ sung cho Optional rất tốt: chúng đánh dấu được cả tham số và trường, nơi Optional không nên dùng, và IDE cảnh báo ngay khi bạn quên kiểm tra.

Muốn tự thấy cái bẫy orElse thì thử một dòng: Optional.of("x").orElse(inRoiTraVe()) với inRoiTraVe() là hàm có System.out.println bên trong. Chạy — dòng in ra vẫn xuất hiện, dù Optional đã có giá trị và giá trị mặc định không hề được dùng. Đổi sang orElseGet(Vd::inRoiTraVe) rồi chạy lại, im lặng. Nếu hàm đó là một truy vấn CSDL, bạn vừa tìm ra một truy vấn thừa chạy trên mọi request.

Mẫu số chung

"Làm cho sự vắng mặt hiển hiện trong kiểu" không phải ý tưởng riêng của Java — nó là một trong những bài học thiết kế lan rộng nhất của cả ngành, và nhìn cách mỗi ngôn ngữ làm điều đó cho thấy ngay Optional của Java còn dang dở tới đâu.

  • Haskell là ông tổ: kiểu Maybe a có từ thập niên 1990, và vì Haskell không có null, người viết buộc phải xử lý nhánh Nothing — trình biên dịch không cho quên. Đây là thứ Optional mơ tới mà không với được, vì Java vẫn còn null song song.
  • Kotlin giải đúng vấn đề gốc: nó nướng khả năng null vào chính hệ thống kiểu. String không bao giờ null, String? thì có thể, và trình biên dịch chặn mọi truy cập chưa kiểm tra. Không cần lớp bọc, không có chi phí đối tượng — đó là lý do Kotlin gần như không dùng Optional cho trường và tham số.
  • Swift (Optional<T> với cú pháp T? và if let) và Rust (Option<T> với match và toán tử ?) đi cùng hướng Kotlin: buộc xử lý ở mức biên dịch, và ? lan "không có" lên trên gọn một ký tự.
  • Go cố ý không có Option: nó dùng con trỏ nil hoặc bộ đôi val, ok := m[k] — ít an toàn hơn, nhưng cùng một ý "hãy nói rõ thứ này có thể vắng".

Sợi chỉ chung: null không phải một giá trị, nó là một lời nói dối — một thứ trông như kiểu T nhưng không phải. Cách chữa tốt nhất là đưa "có thể vắng" vào kiểu để người đọc và trình biên dịch đều thấy. Java làm điều đó bằng một lớp bọc (nên chỉ nên dùng ở kiểu trả về); Kotlin, Swift, Rust làm bằng hệ thống kiểu (nên dùng được khắp nơi). Biết Optional thuộc nhóm đầu là biết vì sao nó tuyệt ở chỗ nó sinh ra và lạc lõng ở mọi chỗ khác.

Ngày mai ta khép lại chặng ngoại lệ bằng lập trình phòng thủ: kiểm tra đầu vào ở đâu, sao chép phòng vệ, và cách giữ cho đối tượng luôn hợp lệ.