05 April 2014

本文介紹 Java 8 新增的 API,以及使用的技巧,內容大多環繞在 Stream/Lambda API 上,不過這一篇並不是 Stream/Lambda 的教學。建議先練習運用 Stream 一下,再來看本文比較好吸收。

For loop with index

//Java 8 之前原本的寫法
for (int i = 0 ; i < 10000 ; i++) {
  System.out.print(i);
}

//Java 8 新的寫法
IntStream.range(0, 10_000).forEach(i -> System.out.print(i));
IntStream.rangeClosed(0, 9_999).forEach(i -> System.out.print(i));

第一題是最常見的 for loop,改成用 IntStream.range()。新的寫法肯定是耗較多的資源的,但除非是很特別的運算,不然是沒什麼差。採用 IntStream 的寫法很容易就是一行搞定,有別於原來的三行,也不用管 i 的那些 加加減減和大於小於。而且等到 loop 裡面開始加上邏輯,使用 Stream 的寫法就會越來越有優勢。

IntStream 上還有 average(), boxed(), 等等有趣的 method,建議可以查查。

眼尖的你發現了嗎? 10_0009_999 含底線的數字。其實這是 Java 7 就有的新功能,因為很少人知道,就再介紹給大家認識一下。

For each loop on collection

List<String> names = asList("1", "2", "3");

//原本
for (String name : names) {
  System.out.println(name);
}

//新的寫法
names.forEach(name -> System.out.println(name));

//差的範例:寫太快會不小心多加個 stream(),但這個簡單例子不需要中間操作,
//         所以 Stream 白白建立了。
names.stream().forEach(name -> System.out.println(name));

跟上一則一樣。使用 Iterable.forEach() 就可以變成只寫一行,如果用 method reference 的話會更簡潔,所以我現在比較傾向先用新的寫法。要注意的是有時候 stream() 寫太習慣的話,會忘了 Iterable 上有新的 default method forEach 可以直接呼叫。用 Iterable 的版本會比較省資源 (雖然沒差太多)

Array to Stream

//原本
List<String> list = Arrays.asList("1", "2", "3");
list.contains("3");

//新的寫法
Stream.of("1", "2", "3").anyMatch("3"::equals);

//差的範例:想改用 stream,但習慣性的用 Arrays.asList 來包
Arrays.asList("1", "2", "3").stream().anyMatch("3"::equals);

//Arrays 上有新的 stream() API 可用,但不是 vararg.
String[] array = new String[] { "1", "2", "3" };
Stream<String> s = Arrays.stream(array);
int[] dimensions = new int[] { 1, 2, 3 };
IntStream is = Arrays.stream(dimensions);

Array.asList() 是常用的方法,尤其是在測試環境下要做短的假資料。現在有了 Stream API 後,就可以直接用 Stream.of(),不需要再多包一層 List。Arrays 上也有新的 stream() 方法,不過都不是 vararg,所以不適合拿來寫測試,那些方法是方便用來轉 primitive stream 用的,像是 IntStream 或 DoubleStream。

Stream 本身是 interface,我們剛才看到的 of() 方法是 interface static method,這是 Java 8 的新功能。這也帶出一個新的 API 設計趨勢 -- 直接在 interface 上提供 factory method,而不是像以前那樣還需要特別做個加 s 的 class,像是 Collections

String join

List<String> items = asList("1", "2", "3");

//純 java 的寫法,我就不列了,超繁瑣

//Guava 的做法
Joiner.on(":").join(items);

//Java 8 的 String 可以直接 join 字串的 collection 
String.join(":", items);

//如果是其他物件的話,可以用 Collectors.joining
Stream.of(item1, item2, null, item3)
      .map(Objects::toString)
      .collect(Collectors.joining(":"));

Java 8 終於提供了 join string 的 method,以往都是要透過 apache commons 或是 guava 來做,不然用 StringBuilder 自己寫很噁心。在 Java 8 裡,如果是資料是字串,那就可以直接呼叫 String.join(),如果是其他型別,就要透過 stream() 使用 Collectors.joining() 的方法。新的寫法背後都是靠一個新的 class StringJoiner 來實做的。但我想直接用到它的機會不會多。

Map for each key value

Map<String, Integer> countOfAlbums = ...;

//原本的寫法
for (Entry<String, Integer> entry : countOfAlbums.entrySet()) {
  System.out.format("%s - %d\n", entry.getKey(), entry.getValue());
}

//新的寫法
countOfAlbums.forEach(
    (album, count) -> System.out.format("%s - %d\n", album, count));

Map 是這次改變很大的 API 之一,新的 default method Map.forEach() 解決了過去 for loop key/value 繁瑣的語法。我大力推薦各位改用新的寫法。新的寫法不僅不需要重覆寫一次討厭的 generic,也不會再產生中間物件 EntrySet 和一大堆 Entry,而這就是 internal iteration 的好處 (你可以去看 HashMap.forEach 的 source code,裡面沒有產生過渡物件)

Map lazy initialization

//收集每個人的投票次數
Map<String, Integer> vote = new HashMap<>();

//原來的做法
if (!vote.containsKey("John")) {
  vote.put("John", dao.countVote("John"));
}

//新的寫法
vote.computeIfAbsent("John", name -> dao.countVote(name));

上面的例子裡,資料庫查詢 dao.countVote 是屬於耗資源的操作,很適合使用 lazy 的方式加入 map。透過新的 API Map.computeIfAbsent() 總算可以一行搞定了。這個新的 default method 會常用到,早點熟悉它吧。

Multi-Map lazy initialization

//收集每張專輯的曲目 (key是專輯名,value是曲名的集合)
Map<String, List<String>> albums = new HashMap<>();

// 原來的做法
List<String> tracks = albums.get("Thriller");
if (tracks == null) {
  albums.put("Thriller", tracks = new ArrayList<>());
}
tracks.add("Billie Jean");

// 新的寫法
albums.computeIfAbsent("Thriller", album -> new ArrayList<>())
      .add("Billie Jean");

延續上例,computeIfAbsent() 在 Multi-Map 的應用上更顯俐落,同一系列的方法還有 compute(), computeIfPresent()putIfAbsent(),有更好的寫法就直接轉換吧。

Sort List

List<String> data = ....;

//原來的做法
Collections.sort(data, (a, b) -> a.compareToIgnoreCase(b));

//新的寫法
data.sort((a, b) -> a.compareToIgnoreCase(b));

List 上有新的 sort() default method,所以不用像過去那樣要轉用 Collections。除了程式碼簡短直覺外,各個容器也可以依據內部的特性最佳化排序的演算法。List 常見的實作有 ArrayList, LinkedList, CopyOnWriteArrayList,除了 LinkedList 外,另外兩個都有額外覆寫 sort(),直接對內部的 array 排序,而不是每次都複製一份。

Sort Map by value

我將專案升級到 java 8 時,都遇到這樣的需求 -- 對 map 的值做排序 。而且那些專案的性質還完全不同,我想這是個常見的題目,所以我拿它做範例來介紹新的 API:

// 人名和分數的 map,資料大概是:
//     {'John':88, 'Mary':92, 'Bob':80}
Map<String, Integer> scoreByName = ...;

//需求是轉成一個新的 map,分數由高往低排:
Map<String, Integer> topScoreByName = 
  scoreByName.entrySet()  //先轉成 Entry<String, Integer>
             .stream()
             .sorted(Entry.<String, Integer>comparingByValue().reversed())
             .collect(Collectors.toMap(Entry::getKey,
                                       Entry::getValue,
                                       (oldScore, newScore) -> newScore,
                                       LinkedHashMap::new));
                                       
// 結果應是 {'Mary':92, 'John':88, 'Bob':80}

上面的做法是先將原 map 轉成 entry,再對分數做排序,最後將 entry stream 轉成 LinkedHashMap,以保留分數的順序。這個範例用到了許多新的 API:

  • 第一個是 Stream.sorted() 可以依據參數的 comparator 將 stream 重新排序。sorted 是 stateful 中間操作,所以不能應用在無限的 Stream 上。
  • 第二個是 Entry.comparingByValue() 這是 Map.Entry 上新的 interface static method,它回傳一個依 entry 值排序的 comparator。你大概可以猜到還有另一個 Entry.comparingByKey()。Java 8 Entry 一共新增了四個 method。
  • 第三個是 Comparator.reversed() 這是 Comparator 新的 default method。它回傳一個反過來排序的新 comparator。Comparator 多了非常多的新 API,像是 naturalOrder()nullsFirst(),建議大家去瀏覽一下,有個印象以後就有機會運用。

你應該有注意到 Entry.<String, Integer>comparingByValue() 我加上了 generic,原本 compiler 應該要能推導出 generic 的,但無奈它辦不到,所以只好乖乖加回去,程式難看了些。

到了第三步,現在的 stream 裡面流的是依分數由高而低來排序的 Entry<String, Integer>,剩最後一步是將這些 entry 依序加入 LinkedHashMap。這裡使用了最複雜的 Collectors.toMap() method,需要四個參數,前兩個參數很簡單,就是拿原本的 entry 當作新 map 的 key value。

第三個參數就是會卡關的地方了,它是 BinaryOperator<U> mergeFunction,它要你提供一個將 value 合併的函數,也就是如果 stream 裡遇到相同的人名 (key) 時,該怎麼處理它們對應的分數 (value)。在這個範例裡其實不會有同人名的例子發生,但這個參數不能傳 null,所以還是寫個標準的實作給它,就是後來的分數贏。

第四個參數則是我們要提供的新 map 的 supplier,範例程式直接拿 LinkedHashMap constructor 當作 supplier。

雖然這題的解法有點長,但遠比 java 8 之前的解法短很多。我好奇還有更短的解法嗎?希望有人可以提供更簡潔的答案。

File operations

Java 7 新增了 NIO.2,多了相當多的 API,尤其是 java.nio.file.Files

//copy 檔案
Files.copy(Paths.get("a.txt"), Paths.get("b.txt"));

//寫檔
Files.write(Paths.get("a.png"), dataByteArray);

//讀文字檔成行
Files.readAllLines(Paths.get("pom.xml"));

我只舉了三個例子,不過 Files 還有一大堆方便的 method 可用,幾乎可以取代 apache commons IO 的 IOUtilsFileUtils 了。我在這一篇重新提 java 7 Files 是因為很多人不會用,也不知道它的存在。雖然 Files 跟 commons IO 的功能重疊,我個人建議能用 Files 就用它。commons 雖然好用但畢竟是多一個依賴的 jar,依賴多了就會有版本相衝的問題。

好,回到 java 8。 Files 因應 Stream API,也加了幾個新的 method:

//讀文字檔成行
try (Stream<String> lines = Files.lines(Paths.get("/tmp/foo.txt"))) {
  lines.forEach(System.out::println);
}

//列出當下目錄的所有檔案
try (Stream<Path> paths = Files.list(Paths.get("/tmp/"))) {
  paths.forEach(System.out::println);
}

//列出目錄下的所有檔案,包含子目錄
try (Stream<Path> paths = Files.walk(Paths.get("/tmp/"))) {
  paths.forEach(System.out::println);
}

Files.lines(), Files.list(), Files.walk() 這三個新 method 都能直接回傳 Stream。以此為起點,接下來的操作就可以運用 lambda 的優勢,讓程式更簡潔。

Files.linesFiles.readAllLines 兩者看似相同,但前者需要的資源較少,因為它是 Stream,是 lazy 的。後者則是全部都讀到記憶體,遇到大檔就麻煩了。因此大多數的情況用 Files.lines 比較好。

注意到了嗎?上面的範例通通用 try-with-resources 包起來了。這個說來話長:

  • 這三個 API 內部都有開 file handle,它們都需要額外呼叫 close(),釋放資源
  • Stream 有 implements AutoCloseable,所以可以放在 try-with-resources 裡,讓它替你回收資源
  • 在實作上,Stream 是透過 BaseStream.onClose() 這個 callback,回收內部資源的
  • 當 IOException 發生在 Stream 的操作時,它會轉成 UncheckedIOException 再丟出來,這是 Java 8 新增的 RuntimeException,在 lambda 的環境下會常用到它。

我翻遍了 Java 8 新的 API,除了這三個 Files method 的 Stream 之外,其他的 Stream 都不用主動去 close() 它。換句話說,沒人會有習慣去關閉 Stream,因此很少人會記得用這三個 method 時要特別包 try-with-resources,我認為這是 Anti-pattern。

避免設計 API 回傳的 Stream 需要呼叫 close()

現在這三個 method 已經設計成這樣了,我們只能硬背要關閉它的 Stream。當然如果你是寫個小工具,跑個幾分鐘 JVM 就 shutdown 了,那不關也許沒什麼差。

BTW,我覺得這些 NIO.2 的 API 已經讓 Java 檔案操作變得相當簡單了,不會差 scripts 語言太多。以往 Java 界最容易被垢病的就是複製一個檔案要寫一堆 BufferedReader, InputStream, 等等一堆瑣碎的 class。現在這些已經成為歷史,Java 7/8 內建常用的檔案操作 API,建議大家及早轉換。

Optional

OptionalOptionalInt 等等,該怎麼用,有什麼新 method 我就不說明了,我建議各位花點時間讀一下它的 Javadoc,還有這篇簡介。雖然 Optional 看似獨立,好像可以用來解決 NullPointerException,但其實它目前只是 Stream API 的一環,Java 8 並沒有將這個概念植入現有的 API 裡。而且更重要的是,它沒有 implements Serializable (理由請見 mailing list)。

Optional 如果要發揮最大的效用,必須大部份的 API 都改成用 Optional 作為回傳的容器,這樣才能順利串接。但這樣的改變太大了,不是個選項。因此設計者將它的應用範圍縮小,用來解決特定的問題。

因此,我們在運用 Optional 時,最好也將它侷限在某些應用上,我整理了一些規則給大家參考:

//範例:爬網頁的結果
class FetchResult {

   //方法一:將 instance field 放在 Optional 裡。
   //        但這個做法應盡量避免
   private Optional<String> document;
   
   //方法二:instance field 不變,但額外提供讀取的 method 回傳 Optional
   private String document;
   Optional<String> document() {
     return Optional.ofNullable(document);
   }
}

第一個規則是 instance field 不要用 Optional,因為它不是 Serializable。上面的例子裡的方法二是比較建議的作法:instance field 不變,但提供一個 getter 回傳 Optional,讓用的人知道抓的網頁可能沒有結果。

第二個規則,Optional getter 的命名就不要用 getFoo() 這樣的格式了,因為容易混淆,而且有些 framework 看到 getter 會自動處理 (例如 Jackson),所以用不同的命名法則較好。建議直接用 field 名稱加上括弧即可,如方法二所示。當然也不該同時提供普通的 getter 和 Optional getter。

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

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

interface Address {
  static Optional<Address> parse(String address); //解析可能失敗
}

interface AccountService {
  Optional<Account> tryCreate(String uniqueName); //檢查名字是否唯一
}

//這是承上面的例子,fetchPage() 回傳的 FetchResult 不會是 null,
//但它裡面 document() 是 Optional
interface WebPageCrawler {
  FetchResult fetchPage(String url);              //FetchResult 裡面有 Optonal
}

至於其他的 method 我個人不是很建議用,比方說一個 Account POJO 的 birthday 欄位,即使它是選填的,我大概不會包個 Optional<LocalDate> 的 getter。因為如果可以這樣寫,那不就全部的物件都要改?不然有些 getter 會回傳 null,有些 getter 回傳 Optional,會讓讀程式的人無跡可循,大大降低可讀性。

第四個規則,不要發佈 Collection<Optional<String>> 這樣的雙層容器的 API 給別人使用。雙層容器很難操作,而且 Optional 夾在中間,也會讓 generic bounded wildcard 失效。class 實作的內部自己用用無妨,但回傳值還是將 Optional 展開吧。

第五個規則,不要拿 Optional 當 synchronize lock 物件,也不要放進 IdentityHashMap。Optional 是一種 Value-based class,該文件裡明說了不要將 value class 拿來做任何跟 object identity 有關的操作。

好了,規則很多,不過你也不一定要全部遵守就是,事情總是有例外。又或者你嫌這些規矩囉嗦,那大可完全不要用 Optional,就按以前的做法,例如 parse 失敗就丟個 ParseException。

剩下的小 API

還有很多新的 API,講不完,我把我查到的都寫下來吧:

//按 regex 將字串 split 成 Stream,這個應該會很常用
Pattern.compile(",").splitAsStream("a,b,c")

//建立一個 concurrent hash set
ConcurrentHashMap.newKeySet()

//ThreadLocal 現在不用 subclass 也能提供 lazy 初始值
//這是設計 API 的新趨勢
ThreadLocal.withInitial(Supplier<? extends S> supplier)

//AtomicLong 也有新 API
AtomicLong.getAndUpdate(LongUnaryOperator updateFunction)

//CharSequence 多了各個字的 Stream
IntStream charStream = CharSequence.chars()
IntStream codeStream = CharSequence.codePoints()

//Java 內建的 Logger 是沒有人在用的,不過這一次 Java 8 用 lambda 
//設計新的 Logger API,這設計的理念很好。它將要 log 的內容整個
//用 Supplier 包起來,這樣 log level 不到的,就不會去執行它。
Logger.debug(() -> "account created:" + expansiveTask())

結語

經過這一輪的介紹,希望大家能嘗試運用新的 API,讓程式碼更簡潔。這些新 API 很多都是 interface static method 和 default method,它們是很好的範例,讓我們了解該怎麼設計和運用這個延伸的新功能。Java 8 還有很多未知的領域值得探索,咱們下次繼續前進吧。