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 mapflatMap:

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ềnorElse("(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, Optionalcô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@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.

Thử ba mươi giây

Viết Optional.of("x").orElse(inRoiTraVe()) với inRoiTraVe() là một hàm có System.out.println bên trong.

Chạy. Dòng in ra 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) và 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.

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ệ.