Java8 新功能筆記 (3) - Stream

Stream

Java 8 新增了一個新的 Stream package 專門用來處理集合(collection),搭配 lambda expression,在處理集合方面變得更加方便。
Stream 可以執行一系列的操作,可以使用循序運算(sequential)也可以使用並行運算(parallel)。Stream 用起來很像 Builder 模式,操作是以一個接一個的方式串起來,因為在設計上有考量到效率,所以每個 stream 方法回傳的值有兩種,並且行為也不同。

當 stream 的方法回傳的值是 Stream 型別時,並不會實際去執行運算,而是當該方法回傳除了 Stream 型別以外的值時(包含void),才真正執行運算。所以要記住 stream 方法的串接規則,中間串接的永遠是回傳 Stream 型別的方法,只有最後一個才是串接傳回某個非 Stream 型別的值的方法。在 API 文件中,屬於終止操作的方法,會有「 This is a terminal operation. 」這樣的標示,表示這個方法只能串接在最後一個。

Stream 提供 filter、sort、map 等功能。Stream 和 Collection 的區別是,Collection 是一種靜態的資料結構,使用的是記憶體空間,而 Stream 則是運算,使用的是 CPU。

範例:印出1~10的數字

使用 for 迴圈:
for (int i = 0; i < 10; i++) {
    System.out.println(i);
}
使用 IntStream:
IntStream.range(0, 10).forEach(i -> System.out.println(i));

//Use Method Reference
IntStream.range(0, 10).forEach(System.out::println);
原有的寫法要用到 3 行,雖然你可以故意把它寫成 1 行,可是在閱讀上其實不容易。使用 Stream 來執行,不僅簡短,對於這段程式碼的意圖也很清楚。

Java 8 新加入的方法參考(Method References),是 lambda 表達式的一種,當你的 lambda 表達式呼叫一個既有的方法但又什麼事都沒做時,就可以使用 Method References,使用方式是
類別名稱::方法名稱
當我們查 IntStream API 的 range()方法,你可以看到它是回傳 IntStream:
static IntStream range(int startInclusive, int endExclusive)
所以 range 並不會直正執行迴圈計算,接下來看看 forEach() 方法:
void forEach(IntConsumer action)
它的回傳值是 void,也就是不回傳值,因為並非 Stream 型別,所以這個方法會真正去執行迴圈計算。在使用 Stream 方法時,記得都要這樣使用。

collect

collect 方法也很常用,可以回傳集合,例如:
List<String> names = Stream.of("Tony", "Tom", "Jonn").collect(Collectors.toList());
List<String> names2 = Arrays.asList("Tony", "Tom", "Jonn");
System.out.println(names.toString());
System.out.println(names2.toString());
//[Tony, Tom, Jonn]
//[Tony, Tom, Jonn]
使用 Stream 的 of 方法指定集合的資料,然後用 collect 方法來收集,必須指定收集的型式,這裡指定為 toList。這段程式碼產生的結果和 Arrays.asList 的結果相同。

你可以以相同的方式取得 Set 集合:
Set<String> names = Stream.of("Tony", "Tony", "Tony", "Tom", "Jonn").collect(Collectors.toSet());
System.out.println(names.toString());
//[Tony, Tom, Jonn]
註:Set 集合會去除重覆。

Collectors.joining

在 collect() 方法中可以傳入 Collector 介面的參數,而 Collectors 類別已經實作了許多方法可以用,這裡介紹一個好用的 joining() 方法。

有時候我們會需要使用 StringBuilder 搭配 for 迴圈來組合一些資料成一段字串,寫法如下:
private void stringJoinUseStringBuilder() {
    StringBuilder sb = new StringBuilder("[");
    int i = 0;
    for (Product p : productList) {
        String name = p.name;
        sb.append(name);
        if (++i < productList.size()){
            sb.append(",");
        }
    }
    sb.append("]");
    System.out.println(sb.toString());
    //[Apple,Banana,Cheery,Orange,WaterMelone]
}
如果我沒有貼上輸出結果,你可能要讀一小段時間才能知道這段程式碼在幹嘛,如果把它改為使用 stream 來寫的話,會是這樣:
private void stringJoinUseStream() {
    String result = productList.stream()
            .map(p -> p.name)
            .collect(Collectors.joining(",", "[", "]"));
    System.out.println(result);
    //[Apple,Banana,Cheery,Orange,WaterMelone]
}
joining 的參數分別是定位符號、前置字元、後置字元,這樣讀起來是不是好讀多了。Collectors 還有很多好用的方法,可以自行研究一下。

map

使用時機

資料的轉換。當你有一個方法(method)它的用途會將某個輸入資料轉換成另一個資料輸出時,map 可以讓你使用這個方法。

輸入輸出:T -> R

範例:使用 foreach 及 Stream.map 的比較

//foreach
List<String> names = new ArrayList<>();
for (String name : asList("tony", "tom", "john")) {
    String upperName = name.toUpperCase();
    names.add(upperName);
}
System.out.println(names.toString());
//[TONY, TOM, JOHN]

//Stream
List<String> names2 = Stream.of("tony", "tom", "john")
                            .map(name -> name.toUpperCase())
                            .collect(toList());
System.out.println(names2.toString());
//[TONY, TOM, JOHN]
map 方法中的 lambda 表達式 name -> name.toUpperCase() 也可以換成 Method Reference 的方式:String::toUpperCase,意圖會更明確,而且 name 其實是多餘的。

Type Inferred (型別推斷)

在上面的範例中,你會看到 foreach 的例子裡,new ArrayList<>(); 並沒有寫成 new ArrayList<String>();,這也是 Java 8 的新功能:型別推斷。因為我們宣告的 names 指定為 List<String> 型別,編譯器即可推斷出 ArrayList 也是 <String>,因此可以省略不寫。

只要可以明確得知物件的型別資訊,都可以省略不寫。

filter

使用時機

資料的檢查。當你要用迴圈來檢查集合中的每個元素是否符合你的期望時。

輸入輸出:T -> boolean

範例:使用 foreach 和 Stream.filter 的比較

//foreach
List<String> names = new ArrayList<>();
for (String name : asList("Tony", "Tom", "John", "Andy")) {
    if (name.startsWith("T")) {
        names.add(name);
    }
}
System.out.println(names.toString());
//[Tony, Tom]

//stream
List<String> names2 = Stream.of("Tony", "Tom", "John", "Andy")
                            .filter(name -> name.startsWith("T"))
                            .collect(Collectors.toList());
System.out.println(names2.toString());
//[Tony, Tom]
兩個例子一比較,應該就能清楚知道 filter 的用途了。

flatMap

使用時機

平面化集合資料。將不同集合中的資料串接在一起,成為在一起的一組集合。

範例:使用 Stream.flatMap

List<String> allNames = Stream.of(asList("Tony", "Tom", "John"),
                                    asList("Amy", "Emma", "Iris"))
                                .flatMap(names -> names.stream())
                                .collect(Collectors.toList());
System.out.println(allNames.toString());
//[Tony, Tom, John, Amy, Emma, Iris]

count

使用時機

計算集合的元素數量。

範例:使用 Stream.count

long count = Stream.of("Tony", "Tom", "John", "Amy", "Emma", "Iris").count();
System.out.println("count: " + count);
//count: 6

max, min

使用時機

取得集合中的最大值或最小值。

範例:使用 Stream.max 及 Stream.min

int max = Stream.of(120,24,59,63,11,74)
                .max(Comparator.comparing(n -> n))
                .get();
System.out.println("max: " + max);
//max: 120

int min = Stream.of(120,24,59,63,11,74)
                .min(Comparator.comparing(n -> n))
                .get();
System.out.println("min: " + min);
//min: 11
這裡要注意一下,max 和 min 方法的回傳值是 Optional<t>,這是 Java 8 的新功能。它不是一般的值,它表示的是這個方法回傳的值,可能有值,也可能無值,也就是兩種可能性,因此我們必須使用 Optional 的 get 方法來取得真正的值。

max 及 min 是屬於終止操作。

sorted

使用時機

要對集合內容進行排序時。

範例:使用 Stream.sorted

//ASC
List<Integer> sortedAsc = Stream.of(120,24,59,63,11,74)
                                .sorted()
                                .collect(toList());
System.out.println("sorted asc: " + sortedAsc);
//sorted asc: [11, 24, 59, 63, 74, 120]

//DESC
List<Integer> sortedDesc = Stream.of(120,24,59,63,11,74)
                                .sorted((n1,n2) -> n2.compareTo(n1))
                                .collect(toList());
System.out.println("sorted desc: " + sortedDesc);
//sorted desc: [120, 74, 63, 59, 24, 11]

循序運算(sequential)及並行運算(parallel)

Stream 提供兩種執行運算的方式,一種是循序運算,只會在單一個執行緒上運算,另一種並行運算則是在多個執行緒上同時運算,並行運算的方式可以充份利用 CPU 多核多執行緒的特點,因此執行速度會比較快。

範例:循序運算及並行運算的執行速度比較

主程式:
List<Integer> randomNumbers = getRandomNumbers();
sequential(randomNumbers);
parallel(randomNumbers);
先產生一個極大數量的亂數集合,之後透過這兩種運算方式來進行排序的動作,看看執行速度有什麼差別。

方法實作:
private List<Integer> getRandomNumbers() {
    List<Integer> randomNumbers = new ArrayList<>();
    IntStream.range(0, 1000000)
            .forEach(n -> {
                int rnd = (int)(Math.random() * 10000);
                randomNumbers.add(rnd);
            });
    return randomNumbers;
}

//循序運算
private void sequential(List<Integer> randomNumbers) {
    long start = System.nanoTime();
    List<Integer> sorted = randomNumbers.stream().sequential().sorted().collect(toList());
    long end = System.nanoTime();
    long duration = TimeUnit.NANOSECONDS.toMillis(end - start);
    System.out.println("sequntial duration: " + duration + "(ms)");
    //sequntial duration: 757(ms)
}

//並行運算
private void parallel(List<Integer> randomNumbers) {
    long start = System.nanoTime();
    List<Integer> sorted = randomNumbers.stream().parallel().sorted().collect(toList());
    long end = System.nanoTime();
    long duration = TimeUnit.NANOSECONDS.toMillis(end - start);
    System.out.println("parallel duration: " + duration + "(ms)");
    //parallel duration: 535(ms)
}
因為這個結果取決於 CPU 的運算,所以每次執行的結果都不會相同,不同電腦上執行的結果也會不同,但結果大致都是並行運算快過循序運算。
本文網址:https://blog.tonycube.com/2015/10/java-java8-3-stream.html
Tony Blog 撰寫,請勿全文複製,轉載時請註明出處及連結,謝謝 😀

8 則留言

  1. 想問關於filter這個功能
    如果當我想過濾的條件是多個的時候應該要怎麼寫
    我自己室友嘗試過這個
    http://stackoverflow.com/questions/36246998/stream-filter-of-1-list-based-on-another-list
    但是不成功 結果並不如預期

    回覆刪除
    回覆
    1. 在 filter 中,你實際上是在寫 if 條件式,所以你可以這樣寫:

      假設有個 Person 的類別,內有 age,gender 等屬性,
      有個 List 中有多個 Person,然後
      ...
      .filter(p -> p.age >= 18 && p.gender.equals("female"))

      或是
      ...
      .filter(p -> p.age >= 18)
      .filter(p -> p.gender.equals("female"))

      你可以串接多個過濾條件,這樣其實看得比較清楚。

      刪除
    2. 感謝妳的回覆 似乎有點頭緒了
      難怪每次都是指過濾了最後那個條件
      因為我的迴圈放置的地方有問題要調整

      刪除
  2. Lamdba 效能未提很多(非parallel)

    易讀性跟維護性也不好,沒有什麼非要用的必要性。

    回覆刪除
  3. 請教 Stream 和 IntStream 分別是甚麼?

    回覆刪除
    回覆
    1. 一樣的東西,只是 IntStream 只能用在整數原始型別 int ,有針對 int 增加額外的功能可用,例如 IntStream 有 range() 可用,Stream 就沒有。

      以 API 文件來看它們的區別,請點選 Method Summary 中的「Default Methods」:
      * https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html

      * https://docs.oracle.com/javase/8/docs/api/java/util/stream/IntStream.html

      刪除
    2. 差在兩個串流處理的資料類型不一樣
      Stream處理的是 Integer類 (裝箱後的int)
      IntStream處理的是int (基本資料類型int)
      會有boxing跟unboxing的overhead問題~~
      可以將IntStream想像成,在使用Stream在處理整數的一種分支(提供處理int的串流)。

      刪除

留言小提醒:
1.回覆時間通常在晚上,如果太忙可能要等幾天。
2.請先瀏覽一下其他人的留言,也許有人問過同樣的問題。
3.程式碼請先將它編碼後再貼上。(線上編碼:http://bit.ly/1DL6yog)
4.文字請加上標點符號及斷行,難以閱讀者恕難回覆。
5.感謝您的留言,您的問題也可能幫助到其他有相同問題的人。