09 August 2014

到目前為止,我已經使用 Java 8 數個月了,幾個月下來新功能從完全不懂到漸漸熟悉。大致上學習與運用分幾個階段:

  1. 先開始運用 Stream 和最簡單的 lambda
  2. 強迫自己不寫任何 for/while,全部用 Stream 解決
  3. 導入 java.time
  4. 開始大量使用 method reference
  5. 嘗試使用 default method
  6. 開始廣泛運用 Optional

當然實際上沒有完全分這麼細,這麼地按步就班。不過大致上是先熟悉 stream 再熟悉 method reference ,最後是 Optional。

題外話 Method Reference

在研究 lambda 的時候,我一開始認為 method reference 太過魔術,省掉太多的資訊,會造成程式可讀性變低。不過實際用一陣子後,發現它沒那麼複雜,它與 Stream API 搭配使用時,不是接零個參數 (通常是 constructor),就是接單一個參數,而這個參數多半是 stream 中的資料而已。

// 用 method reference 可以少掉了 a, n 這種中間沒意義的變數,
// 也可以減少 ->, () 等等防礙閱讀的符號
// 另外,可使用 method reference 的地方也表示它沒有 capture 任何變數

accounts.stream()
        .map(a -> a.getNickname())
        .forEach(n -> System.out.println(n));

accounts.stream()
        .map(Account::getNickname)
        .forEach(System.out::println);

當然要一眼看懂 method reference 在做什麼還是要一點訓練的,不過這門檻並不高。所以我個人的建議是能用就用吧,它現在已經變成我 Java 8 最愛的新功能了!

重新檢視 Optional

好了,回今天的主題,Optional,它在我的學習歷程中是最後才開始上路,一方面是 Java 本身的 API 多半沒對應,另一方面它也不適合漸進式導入,因為很容易出現新舊風格不一的情況。現在實際用了一陣子,整理了一些心得:

Optional.of() vs. Optional.ofNullable()

使用 Optional 第一個會撞牆的就是這兩個 factory method。在 Java 8 中,of(value) 中的 value 不能接 null,只有 ofNullable(value) 才行。我一開始只用 of() 來建立 Optional,所以執行期噴了一堆錯誤,所以現在就都改採用 ofNullable()

我不知道為什麼要設計成這樣,也許是學理上有些原因吧。但我覺得這實在是很差的設計,我查了一下用到 Optional 的程式碼,大概九成的案例都是用 ofNullable()。為什麼?會用到 Optional 的地方一定是會有 null 啊,不然用它幹嘛?現在 Java8 設計了一個繞舌又難打的 ofNullable method,程式碼難看了許多,改用 from() 或是 resolve() 等名稱都還好些。不管怎樣現在也只能用下去了,這個錯誤的設計值得大家借鏡。

Elvis Operator 替代品

Elvis Operator 在一些語言上有,不過 Java 還沒有:

// Java 只有 ternary if operator
String displayName = getNickname() != null ? getNickname() : "Unknown";

// Groovy 則有 Elvis operator
var displayName = getNickname() ?: "Unknown" ;

Elvis operator 不只是讓程式碼簡潔而已,有時候像是 getNickname() 這樣的 method 是個重度操作 (例如來自資料庫),如果沒 Elvis 就會變成這樣:

// ternary operator 會變 query 兩次,相信大家不會這樣寫:
String displayName = accountDao.findNickname(1223) != null 
                   ? accountDao.findNickname(1223) 
                   : "Unknown" ;
 
// 通常會改寫成這樣:
String displayName = accountDao.findNickname(1223);
if (displayName == null) {
  displayName = "Unknown";
}

本來 Elvis operator 一行可以搞定的,變成要四行,而且 displayName 也不能設成 final,這很痛苦。這時 Optional 可以派上用場:

final String displayName = Optional.ofNullable(accountDao.findNickname(1223))
                                   .orElse("Unknown");

有了 Java8 的 Optional,終於可以把這難看的四行程式碼換成一行簡潔的程式碼。這當然跟專屬的 operator 是不能比的,但是好非常多了。

當然,這是最基本的 Optional 的 API 運用,本來是沒什麼好提的。不過,因為運用的時機非常多,所以會成為新的成語 (Programming idiom)。不論你們團隊會導入 Optional 到什麼程度,那些 map()filter()、method reference 你都不太熟,但這個最基本的 Elvis by Optional 成語你都應該熟悉。隨著越來越多的人升級到 Java8,這個成語相信會隨處可見。

Optional Argument

我們有了 Optional,那麼,它適合用在任何可能出現 null 的地方嗎?我們先來看看運用在參數上的例子,試想一下這樣的 API:

//用 email 建立帳號,生日和性別不一定要填
public Account create(String email, 
                      Optional<LocalDate> birthday,
                      Optional<Gender> gender);

嗯,看起來挺合理的,那麼用的人會…

// (1) 認真讀你的 API 乖乖牌
accountDao.create("foo@gmail.com", 
                  Optional.empty(), 
                  Optional.of(Gender.MALE));
 
// (2) 一般 Java 開發者
accountDao.create("foo@gmail.com", null, null)

現實中,很少人會認真的像 (1) 的例子一樣,還乖乖的傳 empty() 給你,我想大部份的人都會用 (2) 的方法呼叫。Optional 用在參數上有幾個缺點:

  1. API 真的不好用啊,每次呼叫,不管有沒有值都要用 Optional 包一次參數。
  2. 調用 API 的人,不小心會傳 null 給你,然後 compiler 也檢查不出來。他不知道呼叫錯了。
  3. 由於用的人還是會傳 null 進來,變成實作 API 的人不管怎麼樣也要處理傳入 null 的情況,多一層 Optional 反而變多餘。

Java 8 in Action 這本書裡,作者是建議可以這樣設計 API,希望盡可能享有 Optional 帶來的優點。但我個人實際用過後,覺得沒有什麼優點,缺點還一堆,所以我不會這樣設計 public API。參數上採用 Optional,我只會考慮在 private method 上使用。

參數如果可以接受 null,建議按下列的準則:

  1. 可以 null 的參數盡可能放在後面
  2. 可考慮標示 @Nullable 的 annotation
  3. 如果大多數的使用例都是傳 null 居多,可考慮 overloading 新的 method。
  4. 參數過多的 method,建議改用 Builder pattern

OptionalReturn ?

OptionalReturn 這啥?原本 Java 8 設計團隊可能會接受用這個名字取代我們現在看到的 Optional。是的,設計團隊希望 Optional 只該用在回傳值上,它根本就不該出現在參數上,也不該出現在 instance field 。因為這樣的決策,所以 Optional 有很多限制,它不俱備 Serializable,所以不能用在 instance field,也不適用在 RPC 上 (RMI)。即然設計成這樣了,現階段我們使用者也只能在這樣的限制使用。

Optional vs. Exception

我在前幾篇文章提到 Optional 的適用範圍,再複述如下:

// Optional 適合用在有查詢、檢驗、解析... 等等會有未知
// 結果涵意的 action method 上,看看下面的例子:

// 例1: 可能查無此人
interface AccountDao {
  Optional<Account> findByName(String name);
}

// 例2: 解析地址可能失敗
interface Address {
  static Optional<Address> tryParse(String address) {...}
}

// 例3: 檢查名字是否唯一,重覆就不建立
interface AccountService {
  Optional<Account> tryCreate(String uniqueName); 
}

經過這幾個月的實際操作演練,這些規則仍然適用。把原本會丟 exception 的地方,改成 Optional 後 API 就變好用了。不過,Optional 雖然可以某方面取代 Exception,但不能完全取代:

//當 例3 需要揭露多種錯誤的情況時,Optional 就不夠用了
Account create(String uniqueName) 
    throws DuplicateNameException, NameTooShortException; 

很多時候你需要揭露多種錯誤狀況,你還是需要用 exception。另外,如果邏輯上 無法建立 Account 是不合理的,你也不能用 Optional 取代 exception。
Optional 適用在本來只有單一 exception ,而且沒有值是合理的回傳值上:

interface Address {

  // 這個範例只有單純的錯誤,所以適合轉成 Optional,不過 exception 
  // 的版本仍可以提供有用的錯誤資訊
  static Address parse(String rawAddress) 
      throws AddressFormatException {...}


  // 你可以提供丟 exception 的 API,也可以同時提供 Optional 的版本,沒
  // 規定只能用一種。當然 Optional 的版本可優先考慮,因為還是比較好用。
  static Optional<Address> tryParse(String rawAddress) {...}

 
  // 但不要設計 API 是回傳 Optional ,但也會同時丟 exception,這完全沒意義。
  static Optional<Address> bad_example_parse(String rawAddress) 
      throws AddressFormatException {...}
      
}

最後,提一下命名規則,如果 method 呼叫後,結果是失敗時,但不會丟 exception,我通常會冠上 try 這個字。這個慣例來自於 concurrent Lock 以及 Spliterator 等 API。我想在 Java API 中已出現不少次使用 try 當做前綴字的例子,大部份的開發者應該可以接受這樣的慣例。我建議大家採用。

使用 Optional 後,不會再遇到 NPE ?

NPE是指... 喔,我不能說出它的全名,那只有鄧不利多能說。經過幾個月的 Optional 實驗,結論是我還是遇到了 NPE。我遇到的案例是這樣的:

Account tryValidate(String accessToken) {
  if (someLogicCheck(accessToken)) {
    if (anontherLogicCheck(accessToken, httpSession)) {
      return null;
    }
  }
  //...
  //很長的運算... 十幾行...
  //...
  return new Account(...);
}
 
//test case
@Test
public void testInvalidAccessToken() {
  assertNull(validator.tryValidate("a bad access token"));
}

這個 method 解析 accessToken ,成功的話回傳 Account 物件。後來我把這個 method 改成 Optional,過程是:

// 第一步先改 signature
Optional<Account> tryValidate(String accessToken) {
  //...
    
  // 第二步,IDE 會抱怨回傳 Account 是錯的,所以就包起來吧    
  return Optional.of(new Account(...));
}

程式改好,測試也過了,接著就是上線。然後 validator.tryValidate(someInput).map(...) 這地方就爆 NPE 了。你應該已經看出我犯了什麼錯誤,還看不出來的話那表示 Optional 真的很危險。

也許打一開始撰寫 method 時就回傳 Optional,我就不會犯錯了,不過改程式和重構是日常作業啊!即使我寫了單元測試還是沒測到 (assertNull 那行實在太不起眼了)。經過這次的教訓,我認為即使全部 method 換用 Optional 也不能完全解決 NPE。只能說能降低機會吧。會這樣根本的原因還是 Java compiler 不會檢查 Optional type 不能吃 null。

如果只針對這種直接 return null 造成的錯誤,現在是有解的。FindBugs 上個月出爐最新版 3.0 了,它可以抓到這種錯誤

當 Optional 遇見 Object

除了 NPE 外,Optional 實際使用還有很多地雷,每個我都踩到了,請大家跟著我也來踩一遍:

//承上例,tryValidate 回傳的是 Optional<Account>

//例1. validate 結果放到 HttpSession
public void session(String token) {
  httpSession.setAttribute("account", validator.tryValidate(token));
  
  //其他地方拿出 Account... 砰!
  Account exist = (Account) httpSession.getAttribute("account");
}

//例2. validate 結果放到 Spring web 的 ModelAndView
public ModelAndView renderView(String token) {
  modelAndView.addObject("account", validator.tryValidate(token));
  return modelAndView;
}

//然後在 html view 中顯示... 砰!
<span >${account.nickname}</span>

//例3. validate 結果從 List 中移除
private List<Account> loginedAccounts = ...;
public void logout(String token) {
   //logout 後從登入的列表中移除... 砰!
  loginedAccounts.remove(validator.tryValidate(token));
}

砰砰砰,連三爆。我現在講的輕鬆,但這些地雷有時候花了幾個小時才找到,真的很想砸電腦。它們都有共同的特徵,ModelAndViewHttpSessionList.remove() 的參數都是接 Object 型別,所以你傳進 Optional 時,都無消無息的石沉大海,直到執行期才發現問題。你可以想像我將 tryValidate() 修改成 Optional 後,這些吃 Object 參數的地方全都變成未爆彈。

這三個地雷,List.remove() FindBugs 抓得到型別錯誤。而 HttpSession FindBugs 則會警告 Optional 不是 Serializable,不該放入 session 中。不過 ModelAndView 它就幫不上忙了,越冷門的 API FindBugs 越使不上力。

話說回來,我真的認為 Java 8 的 Optional 根本不該繼承 Object 啊。而且這樣設計的話,它自然也不能吃 null 了。不過這樣的改變太大了,不可能在 Java 8 發生,也許 Java 9, 10 之後引進 value type 之後,會有改進的實作。

總結

小小的 Optional,看看 Java 8 的原始碼,它還不到 100 行,值得這麼大書特書嗎?我也希望它很簡單,不過實務上使用它時會有很多需要衡量的地方:

  1. Optional 可擔任 Java Elvis Operator 的角色
  2. Optional 不適合用在參數上
  3. Optional 不支援 Serializable,不適合放在 instance field
  4. Optional 的本職是用在回傳值,適用有未知結果涵意的 action method 上
  5. Optional 不可完全取代 Exception,它擔任互補的角色
  6. Optional 不能完全解決 NPE,你仍然需要小心,以及搭配 FindBugs 輔助
  7. Optional 搭配萬用型別的容器使用容易出錯。
  8. OptionalInt 等變型應避免使用,因為他們缺乏 map/flatMap 這類 method。

本文完全沒討論 Optional 它的 map/flatMap 那些 Monad 的實例應用,因為相對上面總結那幾條,Monad 真是簡單多了啊~ (被毆


回響

可以用 Tag <I>、<B>,程式碼請用 <PRE>