Command Palette

Search for a command to run...

[Advanced Java] Optional trong Java: một return type, không phải thuốc chữa NullPointerException

Optional thường được giới thiệu như cách để tránh NullPointerException. Cách giới thiệu đó sai, và sai theo kiểu tạo ra code còn tệ hơn là không dùng nó. Optional không xoá null khỏi Java, không thể xoá, và cũng chưa bao giờ nhắm tới việc đó. Mọi reference type trong ngôn ngữ vẫn nhận null, một biến Optional vẫn có thể là null, và mỗi Optional bạn tạo ra là thêm một object nằm giữa bạn và giá trị thật.

Thứ nó thực sự sinh ra để giải quyết, từ Java 8, hẹp hơn nhiều nhưng rất hữu ích: một method có return type là Optional tự nói ngay trong signature rằng nó có thể không có gì để trả về, nên người gọi không thể quên trường hợp không tìm thấy, và compiler cũng không cho phép họ đối xử với "không có gì" như một giá trị. Dùng đúng vị trí đó thì nó tuyệt vời. Dùng như một thứ thay thế null nói chung — trong field, trong parameter, trong collection — thì nó chỉ thêm allocation, thêm một lớp gián tiếp và thêm một trạng thái phải suy nghĩ, mà không đổi lại được gì.

Ba chip dưới chữ Optional lớn: present, empty, và null vẫn còn trong ngôn ngữ

Mọi output, exception message và error của javac được trích dưới đây đều sinh ra từ việc compile và chạy code trên OpenJDK 21.0.6 (arm64). Với những method thuộc release muộn hơn Java 8, release được nêu tên và đã được xác nhận bằng cách compile cùng một lời gọi với javac --release 8, 9, 1011.

Optional là một return type, không phải thứ thay thế cho null

Hãy bắt đầu từ chuyện một signature nói được gì và không nói được gì. Một method khai báo User findById(long id) hứa trả về một User. Không có gì trong signature đó cho người gọi biết rằng một bản ghi không tồn tại sẽ quay về dưới dạng null, nên hai lớp phòng thủ duy nhất là tài liệu không ai đọc và một lần kiểm tra null không ai viết.

User findRaw(long id) { ... }        // may return null — you have to know
Optional<User> find(long id) { ... } // may be empty — the type says so

Đó là toàn bộ đóng góp của nó. Optional chuyển một sự thật vốn nằm trong comment Javadoc vào trong type system, nơi compiler bắt buộc thi hành: bạn không gọi được getEmail() trên một Optional<User>, nên bạn phải quyết định điều gì xảy ra khi không có user trước khi code compile được.

Ngoài ra không có gì thay đổi. null vẫn là giá trị hợp lệ của mọi reference type, kể cả của chính Optional. Type này không phải Serializable, không phải Comparable, và thực ra không implement interface nào cả — reflection trên java.util.Optional trong Java 21 báo về không có interface nào và superclass là java.lang.Object. Nó là final, và nó mang annotation nội bộ @ValueBased, đó là lý do javac -Xlint:all cảnh báo "attempt to synchronize on an instance of a value-based class" nếu bạn dùng một Optional làm lock.

⚠️ Nếu chỉ lấy được một ý từ bài này: Optional thuộc về đường đi ra khỏi một method. null bên trong một object thì vẫn hoàn toàn ổn. Rắc Optional lên field và parameter để "dẹp null" chỉ làm code dài hơn, chậm hơn và khó đọc hơn, và các phần dưới đây chỉ ra chính xác là tệ ở chỗ nào.

Ba trạng thái, và trạng thái thứ tư phá hỏng tất cả

Một Optional là một container chứa đúng một ô. Hoặc là present — có giá trị bên trong — hoặc là empty. Đó là hai trạng thái, và JDK mô hình hoá chúng bằng một instance dùng chung cho empty và một object mới cho mỗi giá trị present.

Bốn card: present, empty, of(null) ném exception, và một reference Optional null ném exception

Optional<String> present = Optional.of("hoang");
Optional<String> empty = Optional.empty();

System.out.println("present  = " + present);
System.out.println("empty    = " + empty);
System.out.println("present.isPresent() = " + present.isPresent());
System.out.println("empty.isEmpty()     = " + empty.isEmpty());

Optional<String> fromNull = Optional.ofNullable(null);
System.out.println("ofNullable(null) = " + fromNull);
System.out.println("same object as Optional.empty()? " + (fromNull == Optional.<String>empty()));
present  = Optional[hoang]
empty    = Optional.empty
present.isPresent() = true
empty.isEmpty()     = true
ofNullable(null) = Optional.empty
same object as Optional.empty()? true

toString đáng để nhớ, vì đó là thứ bạn sẽ thấy trong log: một Optional present in ra Optional[value], một cái empty in ra Optional.empty. Optional.empty() luôn trả về đúng một instance được cache lại, nên phép so sánh reference ở trên cho true; còn Optional.of("hi") == Optional.of("hi") cho false, vì mỗi lần gọi là một lần allocate.

Optional.of(null) hỏng ngay tại lời gọi, không phải sau đó

Hai factory này khác nhau đúng một điểm, và sự khác biệt đó là cố ý. of gọi Objects.requireNonNull trên argument của nó; ofNullable thì không.

try {
    Optional<String> bad = Optional.of(null);
    System.out.println("never reached " + bad);
} catch (NullPointerException e) {
    System.out.println("Optional.of(null) threw " + e);
}
Optional.of(null) threw java.lang.NullPointerException

Nếu không catch, stack trace chỉ đích danh frame gây ra:

Exception in thread "main" java.lang.NullPointerException
	at java.base/java.util.Objects.requireNonNull(Objects.java:233)
	at java.base/java.util.Optional.of(Optional.java:113)
	at OfNullThrows.main(OfNullThrows.java:6)

Đây là tính năng chứ không phải khiếm khuyết. Optional.of(x) là một lời khẳng định rằng x không null, và nó hỏng ngay tại dòng code đưa sai giá trị vào chứ không phải ba frame sau đó. Dùng of khi bạn biết chắc giá trị tồn tại, dùng ofNullable khi bạn đang bọc một thứ có thể là null — thường là kết quả của một API cũ hoặc một lần tra Map.

Một biến Optional mà bản thân nó là null

Đây là trạng thái mà nhiều người tưởng là bất khả thi, và nó chính là lý do khiến cách nói "hết NullPointerException" sụp đổ.

Optional<String> broken = null;
System.out.println(broken.isPresent());
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Optional.isPresent()" because "broken" is null
	at NullVar.main(NullVar.java:5)

Cái container cũng chỉ là một reference như mọi reference khác, nên một null nằm ở ô đó sẽ ném exception ngay lời gọi đầu tiên và kéo theo toàn bộ lợi ích của type. Message nêu được tên biến vì class được compile kèm thông tin debug — javac -g, thứ mà IDE và build tool bật mặc định. Compile chính file đó mà bỏ tuỳ chọn này thì JVM in ra because "<local4>" is null. Định dạng message đó là phần helpful NullPointerException đã nói ở khoá cơ bản; nó là công cụ chẩn đoán, không phải cách sửa.

Quy tắc rút ra là tuyệt đối: một biến, một field hay một giá trị trả về kiểu Optional không bao giờ được là null. Khi một method đã trả về được Optional.empty() thì nó không còn lý do nào để trả về null, và bất kỳ đoạn code nào vừa trả Optional vừa trả null đều đã gánh chi phí của type mà vẫn giữ nguyên cái bug type đó sinh ra để dẹp.

Toàn bộ API, nhóm theo mục đích của từng method

Optional khai báo hai mươi public method, trong đó ba cái là equals, hashCodetoString. Mười bảy cái còn lại rơi vào bốn nhóm, và nhóm chúng theo mục đích giúp nhớ API dễ hơn hẳn so với một danh sách xếp theo alphabet.

Tạo Optional

Lời gọiNó làm gìKhi argument là null
Optional.of(value)bọc một giá trị bắt buộc không nullném NullPointerException ngay lập tức
Optional.ofNullable(value)bọc một giá trị có thể nulltrả về instance empty
Optional.empty()instance empty dùng chungkhông áp dụng

Hỏi trạng thái

Lời gọiTrả về
isPresent()true khi bên trong có giá trị
isEmpty()true khi không có gì — thêm ở Java 11

Cả hai tồn tại để bạn hỏi được; nhưng thường thì không cái nào là thứ đúng đắn để gọi. Một chain kết thúc bằng orElse hoặc map nói đúng điều đó mà không cần rẽ nhánh, và hai phần tiếp theo giải thích vì sao bản có rẽ nhánh lại tệ hơn.

Lấy giá trị ra

Lời gọiKhi emptyCó evaluate argument không
get()ném NoSuchElementExceptionkhông áp dụng
orElseThrow()ném NoSuchElementException — thêm ở Java 10không áp dụng
orElse(other)trả về otherluôn luôn, present hay không
orElseGet(supplier)gọi supplierchỉ khi empty
orElseThrow(supplier)ném thứ supplier dựng rachỉ khi empty

Biến đổi

Lời gọiMapper trả vềKết quả
map(f)một giá trị bình thườngOptional của giá trị đó, empty nếu f trả về null
flatMap(f)một Optionalđúng Optional đó, không lồng nhau
filter(p)một booleangiữ giá trị nếu predicate đúng, ngược lại thành empty
or(supplier)một Optionalcái hiện tại nếu present, không thì lấy của supplier — Java 9
ifPresent(action)không gì cảchạy action chỉ khi present
ifPresentOrElse(action, empty)không gì cảmột trong hai nhánh — Java 9
stream()không gì cảmột stream không hoặc một phần tử — Java 9

Đây là nhóm biến đổi chạy trên một giá trị cần trim:

Optional<String> name = Optional.of("  Hoang  ");
Optional<String> blank = Optional.of("   ");
Optional<String> none = Optional.empty();

System.out.println("map        : " + name.map(String::trim).map(String::toUpperCase));
System.out.println("filter hit : " + name.map(String::trim).filter(s -> !s.isEmpty()));
System.out.println("filter miss: " + blank.map(String::trim).filter(s -> !s.isEmpty()));
System.out.println("or         : " + none.or(() -> Optional.of("anonymous")));
System.out.println("map on empty: " + none.map(String::toUpperCase));

name.ifPresent(v -> System.out.println("ifPresent  : got [" + v.trim() + "]"));
none.ifPresentOrElse(
        v -> System.out.println("ifPresentOrElse: value " + v),
        () -> System.out.println("ifPresentOrElse: nothing there"));

System.out.println("stream present: " + name.map(String::trim).stream().toList());
System.out.println("stream empty  : " + none.stream().toList());
map        : Optional[HOANG]
filter hit : Optional[Hoang]
filter miss: Optional.empty
or         : Optional[anonymous]
map on empty: Optional.empty
ifPresent  : got [Hoang]
ifPresentOrElse: nothing there
stream present: [Hoang]
stream empty  : []

Hai chi tiết trong output đó rất đáng nhớ. filter biến một giá trị present nhưng không qua được predicate thành Optional.empty, đó là cách một chuỗi rỗng trở thành "không có tên" chỉ trong một lời gọi. Và map trên một Optional empty trả về empty mà không hề gọi mapper — hành vi ngắn mạch sẽ được chứng minh bằng bộ đếm ở phần sau.

Release nào thêm method nào

ReleaseThêm vào
Java 8of, ofNullable, empty, get, isPresent, ifPresent, filter, map, flatMap, orElse, orElseGet, orElseThrow(Supplier)
Java 9or, ifPresentOrElse, stream
Java 10orElseThrow() không tham số
Java 11isEmpty

Bảng đó được dựng bằng cách compile một lời gọi cho mỗi method với javac --release 8, --release 9, --release 10--release 11 rồi ghi lại release đầu tiên chấp nhận nó, chứ không phải viết theo trí nhớ. Nó có ý nghĩa thực tế: một codebase còn ghim ở Java 8 thì không có or, không có ifPresentOrElse, không có orElseThrow() không tham số, còn isEmpty — method thiếu vắng rõ ràng nhất trong thiết kế ban đầu — mãi tới Java 11 mới xuất hiện.

orElse so với orElseGet: một trong hai luôn luôn chạy

Đây là cặp method bị hiểu nhầm nhiều nhất trong cả API, và cái sai này gần như vô hình khi review code vì hai dòng đọc lên giống hệt nhau. orElse nhận vào một giá trị, nên Java evaluate argument của nó trước khi lời gọi diễn ra — present hay không cũng vậy. orElseGet nhận vào một Supplier, nên phần thân chỉ chạy nếu Optional hoá ra là empty.

static String expensiveDefault() {
    System.out.println("  >> expensiveDefault() ran");
    return "DEFAULT";
}

public static void main(String[] args) {
    Optional<String> present = Optional.of("cached");
    Optional<String> empty = Optional.empty();

    System.out.println("present.orElse(expensiveDefault())");
    System.out.println("  result = " + present.orElse(expensiveDefault()));

    System.out.println("present.orElseGet(EagerDefault::expensiveDefault)");
    System.out.println("  result = " + present.orElseGet(EagerDefault::expensiveDefault));

    System.out.println("empty.orElse(expensiveDefault())");
    System.out.println("  result = " + empty.orElse(expensiveDefault()));

    System.out.println("empty.orElseGet(EagerDefault::expensiveDefault)");
    System.out.println("  result = " + empty.orElseGet(EagerDefault::expensiveDefault));
}
present.orElse(expensiveDefault())
  >> expensiveDefault() ran
  result = cached
present.orElseGet(EagerDefault::expensiveDefault)
  result = cached
empty.orElse(expensiveDefault())
  >> expensiveDefault() ran
  result = DEFAULT
empty.orElseGet(EagerDefault::expensiveDefault)
  >> expensiveDefault() ran
  result = DEFAULT

Hãy đọc lại khối đầu tiên. Optional đang present, kết quả là cached, vậy mà expensiveDefault() vẫn chạy — chạy trước cả lời gọi, vì evaluate một argument nghĩa là như vậy. Bản orElseGet trên đúng cái Optional present đó không in ra gì cả.

Vẫn cái bẫy đó, nhưng với chi phí thật

Một println giúp nhìn thấy cơ chế; một default thật mới cho thấy giá phải trả. Ở đây default là một lần tra cứu tự đếm số lần bị gọi, còn vòng lặp hỏi ba user tồn tại và một user không tồn tại.

static int dbHits = 0;

static String loadDefaultFromDb() {
    dbHits++;
    return "en-US";
}

static Optional<String> lookupUserLocale(String user) {
    return "hoang".equals(user) ? Optional.of("vi-VN") : Optional.empty();
}
dbHits = 0;
for (String u : new String[] { "hoang", "hoang", "hoang", "ghost" }) {
    lookupUserLocale(u).orElse(loadDefaultFromDb());
}
System.out.println("db hits with orElse    = " + dbHits);

dbHits = 0;
for (String u : new String[] { "hoang", "hoang", "hoang", "ghost" }) {
    lookupUserLocale(u).orElseGet(RealOrElse::loadDefaultFromDb);
}
System.out.println("db hits with orElseGet = " + dbHits);
db hits with orElse    = 4
db hits with orElseGet = 1

Bốn lần tra thay vì một, cho bốn vòng lặp mà chỉ một vòng thực sự cần default. Nhân chuyện đó lên một request handler thì orElse đã làm tải lên nguồn dữ liệu của default tăng gấp bốn. Tệ hơn nữa, nếu default có side effect — insert một bản ghi, tăng một bộ đếm, gửi một message — thì orElse thực hiện side effect đó mọi lần, kể cả khi giá trị vốn đã có sẵn.

Quy tắc rất máy móc và đáng áp dụng mà không cần suy nghĩ:

  • orElse chỉ khi default là một hằng số hoặc một giá trị đã tính sẵn: orElse(""), orElse(0), orElse(Collections.emptyList()).
  • orElseGet mỗi khi việc tạo ra default có gọi bất cứ thứ gì.

orElse(null) xứng đáng có một ghi chú riêng: nó hợp lệ, nó compile được, và nó trả về null.

System.out.println("empty.orElse(null) = " + Optional.empty().orElse(null));
empty.orElse(null) = null

Thỉnh thoảng đó là cây cầu trung thực để quay lại một API chấp nhận null, nhưng thường xuyên hơn thì nó là dấu hiệu rằng lẽ ra không nên tạo ra Optional đó ngay từ đầu.

get() là method bạn không nên gọi

get() trả về giá trị nếu có và ném exception nếu không. Exception đó rất đặc trưng và đáng nhận ra ngay từ cái nhìn đầu tiên.

Optional<String> empty = Optional.empty();

try {
    empty.get();
} catch (NoSuchElementException e) {
    System.out.println("get()          -> " + e);
}

try {
    empty.orElseThrow();
} catch (NoSuchElementException e) {
    System.out.println("orElseThrow()  -> " + e);
}

try {
    empty.orElseThrow(() -> new IllegalStateException("user 42 not found"));
} catch (IllegalStateException e) {
    System.out.println("orElseThrow(s) -> " + e);
}
get()          -> java.util.NoSuchElementException: No value present
orElseThrow()  -> java.util.NoSuchElementException: No value present
orElseThrow(s) -> java.lang.IllegalStateException: user 42 not found

Hai dòng đầu giống hệt nhau vì từ Java 10 chúng là cùng một phần thân method mang hai cái tên. orElseThrow() là cách viết được khuyến nghị: get() đọc lên như một accessor an toàn nhưng hành xử như một lời khẳng định, còn orElseThrow() nói thẳng ra rằng nó có thể ném exception. get() không bị deprecated trong Java 21 — Optional.class.getMethod("get").isAnnotationPresent(Deprecated.class) cho false, và javac -Xlint:all không nói gì về một lời gọi tới nó — nhưng tài liệu của chính JDK giờ hướng bạn sang các lựa chọn khác.

Dạng nhận supplier mới là thứ đáng dùng trong code ứng dụng, vì NoSuchElementException: No value present không nói cho người đọc log biết bất cứ điều gì về lần tra cứu nào đã hỏng.

isPresent() rồi get() chỉ là kiểm tra null với nhiều cú pháp hơn

Đoạn dưới đây là thứ người ta viết nhiều nhất sau khi bắt đầu dùng Optional, và cũng chính là thứ khiến nhiều team kết luận rằng type này không đáng — kết luận đúng, vì ở dạng này thì đúng là không đáng thật.

// a null check
User u = findRaw(id);
String a = (u != null) ? u.email() : "unknown";

// the same null check, with more syntax
Optional<User> o = find(id);
String b = o.isPresent() ? o.get().email() : "unknown";

// what Optional is actually for
String c = find(id).map(User::email).orElse("unknown");

Cả ba đều cho unknown với một id không tồn tại. Bản ở giữa có đúng cái rẽ nhánh của bản đầu, đúng khả năng viết sai như bản đầu, cộng thêm một allocation và một type xa lạ — nó tệ hơn hẳn cái kiểm tra null mà nó thay thế. Bản thứ ba thì không có nhánh nào để viết sai.

Trường hợp còn lại của isPresent() là khi hai nhánh thực sự làm hai việc khác nhau và không nhánh nào tạo ra giá trị, mà ngay cả khi đó ifPresentOrElse thường vẫn đọc dễ hơn. Nếu code của bạn 80% là isPresent()get() thì bạn đã lấy cú pháp của Optional mà không lấy được chút lợi ích nào.

Chaining: map, flatMap, filter và or

Chaining là chỗ type này thôi làm nghi thức và bắt đầu thực sự bớt code. Mọi method biến đổi đều trả về một Optional khác, và mọi method trong số đó đều không làm gì khi receiver là empty.

Một pipeline Optional năm stage được trace hai lần, bên dưới là map so với flatMap

map khi mapper trả về giá trị, flatMap khi mapper trả về Optional

Một câu đó là toàn bộ quy tắc, và chọn sai thì được compile error chứ không phải bug lúc runtime — tức là kết cục tốt.

map bọc lại bất cứ thứ gì mapper tạo ra: đưa cho nó một mapper trả về Customer thì bạn nhận Optional<Customer>. flatMap giả định mapper đã tự bọc kết quả rồi, nên nó trả thẳng cái Optional bên trong ra thay vì lồng thêm một lớp. Vậy nên khi một getter tự nó đã trả về Optional — đúng thứ bài này khuyên bạn viết — thì map sinh ra Optional<Optional<Customer>>:

Optional<Optional<Address>> nested =
        Optional.of(full).map(Order::rawCustomer).map(Customer::address);

System.out.println("map twice     -> " + nested);
System.out.println("flatMap twice -> " +
        Optional.of(full).flatMap(Order::customer).flatMap(Customer::address));
map twice     -> Optional[Optional[Address[city=Da Nang, zip=550000]]]
flatMap twice -> Optional[Address[city=Da Nang, zip=550000]]

Đi tiếp chain sau một map lẽ ra phải là flatMap thì javac chặn bạn lại:

return Optional.ofNullable(order)
        .map(Order::customer)      // Optional<Optional<Customer>>
        .map(Customer::address)    // does not compile
        .map(Address::zip)
        .orElse("UNKNOWN");
MapMisuse.java:18: error: incompatible types: invalid method reference
                .map(Customer::address)    // does not compile
                     ^
    method address in class Customer cannot be applied to given types
      required: no arguments
      found:    Optional<Customer>
      reason: actual and formal argument lists differ in length
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

Cái sai ngược lại — flatMap với một mapper trả về giá trị bình thường — cũng bị bắt ồn ào không kém, và message ghi rõ type mà nó cần:

FlatMisuse.java:10: error: method flatMap in class Optional<T> cannot be applied to given types;
        Optional<Customer> c = opt.flatMap(Order::rawCustomer);
                                  ^
  required: Function<? super Order,? extends Optional<? extends U>>
  found:    Order::rawCustomer
  reason: cannot infer type-variable(s) U
    (argument mismatch; bad return type in method reference
      Customer cannot be converted to Optional<? extends U>)

Bốn lần kiểm tra null gộp thành một biểu thức

Lấy một model trong đó một order có thể không có customer, một customer có thể không có address, và một address có thể không có mã bưu chính — hình dạng mà hệ thống thật nào rồi cũng lớn lên thành.

static String zipNullChecks(Order order) {
    if (order == null) return "UNKNOWN";
    Customer c = order.rawCustomer();
    if (c == null) return "UNKNOWN";
    Address a = c.rawAddress();
    if (a == null) return "UNKNOWN";
    String zip = a.zip();
    if (zip == null) return "UNKNOWN";
    return zip;
}

static String zipChained(Order order) {
    return Optional.ofNullable(order)
            .flatMap(Order::customer)
            .flatMap(Customer::address)
            .map(Address::zip)
            .orElse("UNKNOWN");
}

Cả hai trả về 550000 với một order đầy đủ và UNKNOWN với mọi order thiếu dữ liệu, kể cả một order null. Bản chained có một lối ra thay vì năm và không có cách nào bỏ sót một tầng, và thêm một tầng vào model thì nó dài thêm một dòng chứ không phải ba.

Trace từng stage cho thấy vì sao: một khi một stage sinh ra empty thì mọi stage sau đó cũng sinh ra empty.

== full
  1 ofNullable(order)        Optional[order]
  2 flatMap(Order::customer) Optional[customer]
  3 flatMap(Cust::address)   Optional[address]
  4 map(Address::zip)        Optional[550000]
  5 orElse("UNKNOWN")        "550000"
== customer is null
  1 ofNullable(order)        Optional[order]
  2 flatMap(Order::customer) Optional.empty
  3 flatMap(Cust::address)   Optional.empty
  4 map(Address::zip)        Optional.empty
  5 orElse("UNKNOWN")        "UNKNOWN"

"Sinh ra empty" không đồng nghĩa với "chạy rồi sinh ra empty". Mapper của các stage sau không hề được gọi, một bộ đếm chứng minh điều đó:

AtomicInteger calls = new AtomicInteger();

calls.set(0);
present.map(s -> { calls.incrementAndGet(); return s.toUpperCase(); })
       .filter(s -> { calls.incrementAndGet(); return true; })
       .map(s -> { calls.incrementAndGet(); return s + "!"; });
System.out.println("present: mapper calls = " + calls.get());

calls.set(0);
empty.map(s -> { calls.incrementAndGet(); return s.toUpperCase(); })
     .filter(s -> { calls.incrementAndGet(); return true; })
     .map(s -> { calls.incrementAndGet(); return s + "!"; });
System.out.println("empty:   mapper calls = " + calls.get());
present: mapper calls = 3
empty:   mapper calls = 0

Đó là thứ làm cho một chain dài trở nên an toàn: không lambda nào trong đó nhìn thấy null, và không cái nào chạy trên nhánh không tìm thấy.

filter, or và các method gây side effect

filter thu hẹp một giá trị present thành empty khi predicate không đúng, đó là cách validation tham gia vào chain mà không cần if. or cung cấp hẳn một Optional thay thế và, giống orElseGet, nó lazy — các supplier đứng sau lần trúng đầu tiên không bao giờ được gọi:

Optional<String> resolved = Optional.<String>empty()
        .or(() -> Optional.empty())
        .or(() -> Optional.of("from config"))
        .or(() -> Optional.of("never reached"));

System.out.println(resolved);
Optional[from config]

Một lưu ý về map: vì nó bọc kết quả bằng ofNullable, một mapper trả về null sẽ cho bạn empty chứ không phải Optional[null].

System.out.println("map(s -> null) = " + Optional.of("x").map(s -> null));
map(s -> null) = Optional.empty

Tiện lợi khi bạn đang map qua một getter cũ, nhưng lại là một sự vắng mặt im lặng khi cái null kia thực ra là bug — nên rất đáng biết bạn đang dựa vào cái nào trong hai.

Những chỗ Optional làm code tệ đi

Mọi thứ ở trên là lập luận ủng hộ Optional. Phần này là lập luận phản đối việc dùng nó ở bất cứ đâu ngoài return type, và mỗi mục là một chi phí thật chứ không phải chuyện gu code.

Một class với năm marker đỏ ở các vị trí dùng sai Optional và một marker xanh ở return type

Optional làm field

Điều người ta hay khẳng định là Optional không phải Serializable. Chuyện đó đáng kiểm tra chứ không đáng nhắc lại, nên đây là phép kiểm tra và kết quả của việc thực sự thử serialize:

static class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private final String name;
    private final Optional<String> nickname;   // Optional as a field

    User(String name, String nickname) {
        this.name = name;
        this.nickname = Optional.ofNullable(nickname);
    }
}

System.out.println("Optional implements Serializable? "
        + Serializable.class.isAssignableFrom(Optional.class));

User u = new User("hoang", "hoangth");
try (ObjectOutputStream out = new ObjectOutputStream(new ByteArrayOutputStream())) {
    out.writeObject(u);
    System.out.println("serialized fine");
} catch (NotSerializableException e) {
    System.out.println("writeObject threw " + e);
}
Optional implements Serializable? false
writeObject threw java.io.NotSerializableException: java.util.Optional

Class compile được, object dựng được, và lỗi chỉ đến lúc runtime ngay lần đầu có thứ gì đó muốn ghi nó ra — một session store, một cache, một lời gọi từ xa, một phép deep copy kiểu cũ. Serialization còn là nửa nhỏ hơn của lập luận. Một Optional field tốn thêm một object cho mỗi instance chỉ để mang một sự thật mà bản thân field đã mang được, nó không thể là primitive, và những framework gán field bằng reflection sẽ vui vẻ để nó null, khôi phục đúng cái kiểu hỏng mà bạn dùng type này để tránh.

Hãy để field nhận null và đặt Optional lên accessor, đúng cái biên mà type này sinh ra để phục vụ:

static class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private final String name;
    private final String nickname;          // nullable field

    User(String name, String nickname) { this.name = name; this.nickname = nickname; }

    Optional<String> nickname() { return Optional.ofNullable(nickname); }
}

Ghi một User không có nickname qua một ObjectOutputStream rồi đọc lại:

serialized 90 bytes
nickname() = Optional.empty

Object serialize được, mà mọi người gọi vẫn nhận được một signature thừa nhận rằng nickname có thể vắng mặt.

Optional làm parameter của method

Một parameter kiểu reference có hai trường hợp người gọi phải xử lý: có giá trị, hoặc null. Khai báo nó là Optional làm thành ba, vì bản thân argument cũng có thể là null.

static String greetBad(String name, Optional<String> title) {
    return title.map(t -> t + " " + name).orElse(name);
}

System.out.println(greetBad("Hoang", Optional.of("Dr.")));
System.out.println(greetBad("Hoang", Optional.empty()));
System.out.println(greetBad("Hoang", null));   // the third case
Dr. Hoang
Hoang
greetBad(name, null) -> Cannot invoke "java.util.Optional.map(java.util.function.Function)" because "title" is null

Bạn không dẹp được kiểu hỏng nào cả, bạn thêm vào một kiểu hỏng mới — và làm mọi call site ồn ào hơn, vì người gọi đang có sẵn một giá trị bình thường giờ phải viết Optional.of(title) ngay tại chỗ gọi. Hai overload nói đúng điều đó mà không cần wrapper và không sinh ra trường hợp thứ ba:

static String greet(String name) { return name; }
static String greet(String name, String title) { return title + " " + name; }
Hoang
Dr. Hoang

Mỗi overload đều toàn phần: không có giá trị argument nào làm cho một trong hai chạy sai.

Optional nằm trong collection

Một Map vốn đã có câu trả lời đầy đủ cho một key không tồn tại, và bọc value type lại chỉ cho bạn hai kiểu "không có gì" khác nhau mà cùng mang một nghĩa.

Map<String, Optional<String>> bad = new HashMap<>();
bad.put("hoang", Optional.of("hth"));
bad.put("linh", Optional.empty());

System.out.println("bad.get(\"linh\")   = " + bad.get("linh"));
System.out.println("bad.get(\"absent\") = " + bad.get("absent"));

Map<String, String> good = new HashMap<>();
good.put("hoang", "hth");
System.out.println("Optional.ofNullable(good.get(\"absent\")) = "
        + Optional.ofNullable(good.get("absent")));
bad.get("linh")   = Optional.empty
bad.get("absent") = null
Optional.ofNullable(good.get("absent")) = Optional.empty

Cái map có value là Optional bây giờ phân biệt "có key nhưng không có giá trị" với "không có key", và mọi người đọc code đều phải tự suy ra xem sự phân biệt đó có ý nghĩa gì trong đúng cái map này không — thường là không. Nó cũng allocate thêm một Optional cho mỗi entry. Map thường cộng một lần ofNullable lúc tra cho ra cùng kết quả với chỉ một kiểu vắng mặt. Lập luận đó cũng loại luôn List<Optional<T>>: một list các giá trị có-thể-không-có gần như luôn là một list mà lẽ ra phải lọc bỏ các phần tử không có ngay từ đầu.

Trả về null từ một method có return type là Optional

Đây là tổ hợp tệ nhất có thể có, vì nó là trường hợp duy nhất tệ hơn hẳn so với việc không làm gì cả.

static Optional<String> findNickname(String user) {
    return null;
}

try {
    System.out.println(findNickname("hoang").orElse("none"));
} catch (NullPointerException e) {
    System.out.println("caller NPE: " + e.getMessage());
}
caller NPE: Cannot invoke "java.util.Optional.orElse(Object)" because the return value of "Misuse.findNickname(String)" is null

Người gọi đọc signature, tin nó, chain lên kết quả, và nhận đúng cái exception mà type này sinh ra để ngăn — trong khi vẫn trả tiền cho wrapper. Message của JVM ít nhất cũng chính xác về thủ phạm: nó nêu tên method có giá trị trả về là null. Một method có return type là Optional chỉ có đúng hai lệnh return hợp lệ, Optional.of(...)/Optional.ofNullable(...)Optional.empty(), còn return null; trong thân method như vậy nên bị coi là lỗi ngay khi review dù javac chấp nhận nó.

Optional cho một giá trị không bao giờ vắng mặt

Mục cuối cùng không có exception nào để trưng ra, và đó chính là lý do nó sống dai trong một codebase. Bọc một thứ luôn luôn có mặt thì tốn một allocation mỗi lần gọi và bắt mọi người gọi phải mở ra một giá trị chưa từng thiếu:

Optional<Long> version() {
    return Optional.of(this.version);   // never absent
}

Optional.of(x) có allocate: hai lời gọi với cùng nội dung là hai object khác nhau, đúng như việc Optional.of("hi") == Optional.of("hi") cho false. Chỉ Optional.empty() là dùng chung. Một object nhỏ, sống ngắn cho mỗi lời gọi có đáng lo hay không thì hoàn toàn phụ thuộc vào tần suất gọi và không đáng đo ở đây — nhưng cũng chẳng có mặt lợi nào để đem ra cân, vì người gọi không học được gì từ một type nói "có thể không có" về một thứ luôn luôn có. Nếu giá trị là bắt buộc thì cứ trả về nó. Optional trên một field hay một return type không thể empty chỉ là nhiễu, và tệ hơn, nó dạy người đọc thôi tin vào type ngay cả khi type đó thực sự có ý nghĩa.

Những chỗ Optional thực sự thuộc về

Ba hình dạng bao trọn gần như mọi cách dùng tốt, và cả ba thực chất là cùng một hình dạng: một method trả lời một câu hỏi mà "không có gì" là câu trả lời bình thường chứ không phải một lỗi.

/* 1. A repository lookup: "not found" is a normal answer. */
Optional<User> findById(long id) {
    return Optional.ofNullable(rows.get(id));
}

/* 2. A parse that can fail, without using an exception for control flow. */
static Optional<Integer> parseInt(String s) {
    try {
        return Optional.of(Integer.valueOf(s));
    } catch (NumberFormatException e) {
        return Optional.empty();
    }
}

/* 3. A search with no match. */
static Optional<String> firstLongerThan(List<String> words, int n) {
    return words.stream().filter(w -> w.length() > n).findFirst();
}
hoang@example.com
no such user
parse 42   : Optional[42]
parse abc  : Optional.empty
parse abc with default: -1
first > 2  : Optional[ccc]
first > 9  : Optional.empty

Tại call site, signature làm hết phần việc. Người gọi muốn một default thì viết orElse, người gọi mà với họ việc không tìm thấy đúng là lỗi thì viết orElseThrow kèm một message chỉ rõ lần tra cứu nào, và không ai vô tình dùng được một giá trị không tồn tại như thể nó tồn tại:

String email = repo.findById(id)
        .map(User::email)
        .orElseThrow(() -> new NoSuchElementException("user " + id));
Exception in thread "main" java.util.NoSuchElementException: user 99
	at GoodUses.lambda$main$1(GoodUses.java:50)
	at java.base/java.util.Optional.orElseThrow(Optional.java:403)
	at GoodUses.main(GoodUses.java:50)

Optional.stream() là cây cầu khi cả một lô những lần tra cứu như vậy đổ vào một pipeline: nó biến mỗi kết quả thành không hoặc một phần tử, nên các lần không tìm thấy biến mất mà không cần cặp filter cộng get.

List<String> found = ids.stream()
        .map(OptStream::findById)
        .flatMap(Optional::stream)
        .map(User::email)
        .sorted()
        .toList();
[hoang@example.com, linh@example.com]
asked for 4, found 2

Chính JDK dùng Optional ở đâu, và vì sao Map.get vẫn trả về null

Thư viện chuẩn là một chỉ dẫn tốt về phạm vi mà type này nhắm tới, vì nó chỉ dùng Optional ở những chỗ đang thêm hẳn một method mới. Reflection trên các interface trong Java 21 cho thấy java.util.stream.Stream khai báo đúng năm method trả về OptionalfindFirst, findAny, max, minreduce — còn java.util.Map khai báo không cái nào.

Stream (5): [findAny -> Optional, findFirst -> Optional, max -> Optional, min -> Optional, reduce -> Optional]
Map (0): []
IntStream (6): [average -> OptionalDouble, findAny -> OptionalInt, findFirst -> OptionalInt, max -> OptionalInt, min -> OptionalInt, reduce -> OptionalInt]
Map.get on a missing key: null

Quy luật ở đây rất rõ. Stream ra đời ở Java 8 cùng lúc với Optional, nên các terminal operation có thể không tìm thấy gì được tự do dùng nó. Map đã tồn tại từ Java 1.2 và Map.get được đặc tả là trả về null cho một key không tồn tại. Đổi return type đó là bất khả thi theo cả hai hướng: nó phá vỡ source compatibility với mọi lời gọi get từng được viết, phá vỡ binary compatibility vì return type là một phần của method descriptor trong file class, và phá vỡ mọi class implement Map nằm ngoài JDK. Nó cũng sẽ huỷ hoại hành vi được ghi trong tài liệu của HashMap là cho phép value là null, mà đó chính là lý do getOrDefaultcontainsKey tồn tại. Map.get trả về null không phải một sơ suất JDK hối tiếc; đó là một hợp đồng nó không thể thay đổi.

Các biến thể cho primitive trong output đó cũng đáng một dòng. OptionalInt, OptionalLongOptionalDouble tồn tại chỉ để tránh boxing trong stream API, chúng không có map hay flatMap, và chúng không phải type đa dụng để bạn lấy ra dùng trong code của mình.

Quy tắc thực dụng

  • Hãy trả về Optional. Đừng lưu nó trong field, đừng nhận nó làm parameter, đừng đặt nó vào collection.
  • Một giá trị kiểu Optional không bao giờ là null. return null; từ một method trả về Optional là bug chứ không phải lối tắt.
  • Optional.of khi giá trị bắt buộc phải có, Optional.ofNullable khi bọc một thứ có thể không có.
  • orElse chỉ dành cho hằng số. Bất cứ thứ gì có gọi hàm thì đưa vào orElseGet.
  • Ưu tiên orElseThrow() hơn get(), và ưu tiên orElseThrow(supplier) hơn cả hai, để chỗ hỏng nêu được tên lần tra cứu.
  • isPresent() cộng get() chỉ là kiểm tra null khoác áo mới. Hãy nghĩ tới map, filter, orElseifPresentOrElse trước.
  • flatMap đúng khi mapper tự nó trả về một Optional; còn lại thì map. Chọn sai là compile error chứ không phải bất ngờ lúc runtime.
  • Đừng bọc một giá trị luôn có mặt, và đừng bọc một collection — hãy trả về collection rỗng.

FAQ

Optional có xoá bỏ NullPointerException khỏi Java không?

Không. null vẫn là giá trị hợp lệ của mọi reference type, một biến Optional vẫn có thể là null rồi ném exception ngay lời gọi đầu tiên, và Optional.of(null) ném exception ngay lập tức theo đúng thiết kế. Thứ Optional làm được là đưa khả năng vắng mặt vào return type của một method, để compiler ép người gọi xử lý nó. Việc đó dẹp được đúng một nhóm bug — quên rằng một lần tra cứu có thể không tìm thấy — và không dẹp thêm gì khác.

orElse và orElseGet khác nhau ở đâu?

orElse nhận một giá trị, nên argument của nó được evaluate trước khi lời gọi chạy, bất kể Optional có present hay không. orElseGet nhận một Supplier và chỉ gọi supplier khi Optional empty. Trong một vòng lặp bốn lần mà chỉ một lần cần default, bản orElse chạm vào lần tra cứu dự phòng bốn lần còn bản orElseGet chạm một lần. Dùng orElse cho hằng số và orElseGet cho mọi thứ có tính toán, có allocate hoặc có side effect.

Khi nào nên dùng flatMap thay cho map?

Đúng khi mapper tự nó trả về một Optional. map bọc lại bất cứ thứ gì mapper tạo ra, nên một getter trả về Optional mà đưa vào map sẽ cho bạn Optional[Optional[...]]; flatMap trả cái bên trong ra đã được mở. Vì một model thiết kế tốt sẽ phơi ra các getter trả về Optional, một chain đi xuyên các object lồng nhau thường là một loạt flatMap kết thúc bằng đúng một map. Chọn sai thì không compile được, nên compiler đang làm giúp bạn phép kiểm tra này.

Gọi get() trên một Optional có sai không?

get() không bị deprecated trong Java 21 và không sinh ra cảnh báo nào từ compiler, nhưng cũng không có lý do gì để chọn nó. Từ Java 10, orElseThrow() là cùng một method dưới một cái tên rõ nghĩa hơn, và cả hai đều ném java.util.NoSuchElementException: No value present khi empty. Một get() được bọc bởi isPresent() là một lần kiểm tra null với thêm cú pháp và thêm một allocation — cùng cái nhánh, cùng rủi ro, không được lợi gì. Hãy dùng map/orElse khi cần một giá trị, hoặc orElseThrow(supplier) khi việc vắng mặt thực sự là lỗi và bạn muốn message nêu tên lần tra cứu.

Một Optional field có serialize được không?

Không. Serializable.class.isAssignableFrom(Optional.class) trả về falseOptional không implement interface nào cả — và ghi một object có Optional field qua ObjectOutputStream sẽ hỏng lúc runtime với java.io.NotSerializableException: java.util.Optional. Cách sửa là để field nhận null và trả về Optional.ofNullable(field) từ accessor; khi đó cùng object đó serialize sạch sẽ mà người gọi vẫn nhìn thấy khả năng vắng mặt trong signature.

Một method có return type là Optional có bao giờ được trả về null không?

Không bao giờ. Đó là cách dùng duy nhất tệ hơn hẳn so với việc không dùng Optional, vì người gọi tin vào signature và chain thẳng lên kết quả. Message của JVM nói rõ chuyện gì đã xảy ra — Cannot invoke "java.util.Optional.orElse(Object)" because the return value of "Misuse.findNickname(String)" is null — nhưng tới lúc đó thì bạn đã trả tiền cho wrapper mà vẫn giữ nguyên bug. Một method trả về Optional chỉ có đúng hai lệnh return hợp lệ: một giá trị đã bọc, hoặc Optional.empty().

Vì sao Map.get vẫn trả về null thay vì Optional?

Vì nó không thể đổi. Map đã nằm trong JDK từ Java 1.2 và Map.get được đặc tả là trả về null cho một key không tồn tại. Đổi return type sẽ phá vỡ mọi lời gọi hiện có ở mức source, phá vỡ binary compatibility vì return type là một phần của method descriptor trong file class, và phá vỡ mọi class bên thứ ba implement Map. Nó cũng đụng độ với việc HashMap cho phép value là null, và đó là lý do getOrDefaultcontainsKey tồn tại. Hãy tự bọc tại call site bằng Optional.ofNullable(map.get(key)) khi bạn muốn chain.

Optional.of(null) có giống Optional.ofNullable(null) không?

Không, và sự khác biệt là cố ý. Optional.of(null) gọi Objects.requireNonNull và ném NullPointerException ngay tại dòng đó, với java.util.Optional.of(Optional.java:113) trong trace. Optional.ofNullable(null) trả về instance empty dùng chung. Dùng of như một lời khẳng định rằng giá trị tồn tại, để một giá trị sai hỏng ngay chỗ nó được tạo ra; dùng ofNullable khi bạn cố ý bọc một thứ có thể vắng mặt một cách hợp lệ, chẳng hạn một lần tra Map hoặc một getter cũ.

Kết luận

Optional là một container một ô với hai trạng thái và một nhiệm vụ: cho phép một return type thừa nhận rằng nó có thể không có gì để trả về. Mọi thứ tốt đẹp ở nó đều xuất phát từ nhiệm vụ đó — compiler ép người gọi xử lý trường hợp không tìm thấy, một chuỗi flatMap gộp bốn lần kiểm tra null thành một biểu thức, những mapper không bao giờ nhìn thấy null vì chúng không hề chạy trên nhánh empty. Mọi thứ tệ hại ở nó đều xuất phát từ việc dùng nó ở chỗ khác — một field không serialize được, một parameter có ba trường hợp thay vì hai, một map với hai kiểu rỗng, và cái return null; giữ nguyên bug mà lại thêm wrapper. Hãy học cho kỹ orElse so với orElseGet, vì đó là cái vô hình khi review và nó đã gọi vào nguồn dữ liệu default bốn lần thay vì một trong vòng lặp ở trên.

Đến đây là hết Phần 3 của khoá này. Phần 4 chuyển sang multithreading, bắt đầu từ đầu: một thread thực sự là gì, JVM cấp cho mỗi thread những gì, và tạo một thread bằng Thread cũng như bằng Runnable ra sao.

Bài viết liên quan

[Advanced Java] Set trong Java: HashSet, LinkedHashSet và TreeSet

HashSet, LinkedHashSet và TreeSet trên OpenJDK 21: Set thực sự đảm bảo điều gì, HashMap ẩn bên trong HashSet, các lookup của NavigableSet, hai quy tắc khác nhau để quyết định duplicate, compareTo mâu thuẫn với equals, ClassCastException và null trong TreeSet, EnumSet, và vì sao removeAll có thể thành quadratic.

[Advanced Java] Stream API trong Java: map, filter, reduce và collect

Stream API của Java trên OpenJDK 21: pipeline gồm source, intermediate và terminal, tính lazy được chứng minh bằng trace println xen kẽ, map, filter, cả ba overload của reduce, collect cùng bộ Collectors, primitive stream và chi phí allocation của boxing, và các bẫy quanh peek, findAny, lambda có state cùng parallelStream.

[Advanced Java] Enum nâng cao trong Java: constructor, constant body, EnumMap và enum singleton

Enum nâng cao trong Java trên OpenJDK 21: javap cho thấy enum thực sự compile ra cái gì, field và constructor private ngầm định, constant-specific class body cùng những file class sinh thêm, abstract method, enum implement interface, EnumMap và EnumSet, switch exhaustive, enum singleton mà reflection không phá được, state machine bằng enum, và các bẫy quanh ordinal với values().

[Advanced Java] Generics trong Java: type parameter, bounded type, wildcard và type erasure

Generics trong Java trên OpenJDK 21: container Object thời chưa có generics và ClassCastException mà nó sinh ra, cách viết generic class và generic method, bounded type parameter và nhiều bound, wildcard cùng PECS với đúng message lỗi của javac, type erasure chứng minh bằng javap, Signature attribute, bridge method, và lời hứa thật sự của SuppressWarnings unchecked.