《Java8實戰》-第四章讀書筆記(引入流Stream)

雷俠發表於2018-08-25

流(Stream)

流是什麼

流是Java API的新成員,它允許你以宣告性方式處理資料集合(通過查詢語句來表達,而不是臨時編寫一個實現)。就現在來說,你可以把它們看成遍歷資料集的高階迭代器。此外,流還可以透明地並行處理,你無需寫任何多執行緒程式碼了!我會在後面的筆記中詳細記錄和解釋流和並行化是怎麼工作的。我們簡單看看使用流的好處吧。下面兩段程式碼都是用來返回低熱量的菜餚名稱的,並按照卡路里排序,一個是用Java7寫的,另一個是用Java8的流寫的。比較一下。不用太擔心Java 8程式碼怎麼寫,我們在接下來會對它進行詳細的瞭解。

選單篩選

使用Java7:

private static List<String> getLowCaloricDishesNamesInJava7(List<Dish> dishes) {
    List<Dish> lowCaloricDishes = new ArrayList<>();
    // 遍歷篩選出低於400卡路里的菜,新增到另外一個集合中
    for (Dish d : dishes) {
        if (d.getCalories() < 400) {
            lowCaloricDishes.add(d);
        }
    }

    // 對集合按照卡路里大小進行排序
    List<String> lowCaloricDishesName = new ArrayList<>();
    Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
        @Override
        public int compare(Dish d1, Dish d2) {
            return Integer.compare(d1.getCalories(), d2.getCalories());
        }
    });

    // 遍歷將菜名新增到另外一個集合中
    for (Dish d : lowCaloricDishes) {
        lowCaloricDishesName.add(d.getName());
    }
    return lowCaloricDishesName;
}
複製程式碼

在上面的程式碼中,看起來很冗長,我們使用了一個“垃圾變數”lowCaloricDishes。它唯一的作用就是作為一次性的中間容器。 在Java8,實現的細節被放到了它本該歸屬的庫力了。 使用Java8:

private static List<String> getLowCaloricDishesNamesInJava8(List<Dish> dishes) {
    return dishes.stream()
            // 選出400卡路里以下的菜餚
            .filter(d -> d.getCalories() < 400)
            // 按照卡路里排序
            .sorted(comparing(Dish::getCalories))
            // 提取菜名
            .map(Dish::getName)
            // 轉為集合
            .collect(toList());
}
複製程式碼

太酷了!原本十幾行的程式碼,現在只需要一行就可以搞定,這樣的感覺真的是太棒了!還有一個很棒的新特性,為了利用多核架構並行執行程式碼,我們只需要將stream()改為parallelStream()即可:

private static List<String> getLowCaloricDishesNamesInJava8(List<Dish> dishes) {
    return dishes
            .parallelStream()
            // 選出400卡路里以下的菜餚
            .filter(d -> d.getCalories() < 400)
            // 按照卡路里排序
            .sorted(comparing(Dish::getCalories))
            // 提取菜名
            .map(Dish::getName)
            // 轉為集合
            .collect(toList());
}
複製程式碼

你可能會想,在呼叫parallelStream方法時到底發生了什麼。用了多少個執行緒?對效能有多大的提升?不用著急,在後面的讀書筆記中會討論這些問題。現在,你可以看出,從軟體工程師的角度來看,新的方法有幾個顯而易見的好處。

  1. 程式碼是以宣告性的方式寫的:說明想要完成什麼(篩選熱量低的菜餚)而不是說明如何實現一個操作(利用迴圈和if條件等控制流語句)。
  2. 你可以把幾個基礎操作連結起來,來表達複雜的資料處理流水線(在 filter 後面接上 sorted 、 map 和 collect 操作),同時保持程式碼清晰可讀。 filter 的結果被傳給了 sorted 方法,再傳給 map 方法,最後傳給 collect 方法。

filter、sorted、map和collect等操作是與具體執行緒模型無關的高層次構件,所以它們的內部實現可以是單執行緒的,也可能透明地充分利用你的多核架構!在實踐中,這意味著我們用不著為了讓某些資料處理任務並行而去操心執行緒和鎖了,Stream API都替你做好了!

現在就來仔細探討一下怎麼使用Stream API。我們會用流與集合做類比,做點兒鋪墊。下一 章會詳細討論可以用來表達複雜資料處理查詢的流操作。我們會談到很多模式,如篩選、切片、 查詢、匹配、對映和歸約,還會提供很多測驗和練習來加深你的理解。接下來,我們會討論如何建立和操縱數字流,比如生成一個偶數流,或是勾股數流。最後,我們會討論如何從不同的源(比如檔案)建立流。還會討論如何生成一個具有無窮多元素的流,這用集合肯定是搞不定。

流簡介

要討論流,我們首先來談談集合,這是最容易上手的方式了。Java8中的集合支援一個新的stream方法,它會返回一個流(介面定義在 java.util.stream.Stream 裡)。你在後面會看到,還有很多其他的方法可以得到流,比如利用數值範圍或從I/O資源生成流元素。

那麼,流到底是什麼呢?簡短的定義就是“從支援資料處理操作的源生成的元素序列”。讓我們一步步剖析這個定義。

  1. 元素序列:就像集合一樣,流也提供了一個介面,可以訪問特定元素型別的一組有序值。因為集合是資料結構,所以它的主要目的是以特定的時間/空間複雜度儲存和訪問元素(如ArrayList 與 LinkedList )。但流的目的在於表達計算,比如你前面見到的filter 、 sorted 和 map 。集合講的是資料,流講的是計算。
  2. 源:流會使用一個提供資料的源,如集合、陣列或輸入/輸出資源。請注意,從有序集合生成流時會保留原有的順序。由列表生成的流,其元素順序與列表一致。
  3. 資料處理操作:流的資料處理功能支援類似於資料庫的操作,以及函數語言程式設計語言中的常用操作,如filter、map、reduce、find、match、sort等。流操作可以順序執行,也可並行執行。

此外,流操作有兩個重要的特點。

  1. 流水線:很多流操作本身會返回一個流,這樣多個操作就可以連結起來,形成一個大的流水線。
  2. 內部迭代:與使用迭代器顯式迭代的集合不同,流的迭代操作是在背後進行的。

讓我們來看一段能夠體現所有這些概念的程式碼:

List<Dish> menu = Dish.MENU;
// 從menu獲得流
List<String> threeHighCaloricDishNames = menu.stream()
        // 通過鏈式操作,篩選出高熱量的菜餚
        .filter(d -> d.getCalories() > 300)
        // 獲取菜名
        .map(Dish::getName)
        .limit(3)
        .collect(Collectors.toList());
// [pork, beef, chicken]
System.out.println(threeHighCaloricDishNames);
複製程式碼

看起來很簡單,就算不明白也沒關係,我們來了解了解,剛剛使用到的一些方法:

  1. filter: 接受Lambda,從流中排除某些元素。在剛剛的程式碼中,通過傳遞Lambda表示式 d -> d.getCalories() > 300,選擇出熱量高於300卡路里的菜餚。
  2. map:接受一個Lambda,將元素轉換成其他形式或提取資訊。在剛剛的程式碼中,通過傳遞方法引用Dish::getName,提取了每道菜的菜名。
  3. limit:截斷流,使其元素不超過給定的數量。
  4. collect:將流轉換為其他形式。在剛剛的程式碼中,流被轉為一個List集合。

在剛剛解釋的這段程式碼,與遍歷處理選單集合的程式碼有很大的不同。首先,我們使用了宣告性的方式來處理選單資料。我們並沒有去實現篩選(filter)、提取(map)或截斷(limit)功能,Stream庫已經自帶了。因此,StreamAPI在決定如何優化這條流水線時更為靈活。例如,篩選、提取和截斷操作可以一次進行,並在找到這三道菜後立即停止。

流與集合

Java現有的集合概念和新的流概念都提供了介面,來配合代表元素型有序值的資料介面。所謂有序,就是說我們一般是按順序取用值,而不是隨機取用的。那這兩者有什麼區別呢?

打個比方說,我們在看電影的時候,這些視訊就是一個流(位元組流或幀流),流媒體視訊播放器只要提前下載使用者觀看位置的那幾幀就可以了,這樣不用等到流中大部分值計算出來。比如,我們在Youtube上看的視訊進度條隨便拖動到一個位置,你會發現它很快就開始播放了,不需要將整個視訊都載入好,而是載入了一段。如果,不按照這種方式的話,我們可以想象一下,視訊播放器可能沒有將整個流作為集合,儲存所需要的記憶體緩衝區——而且要是非得等到最後一幀出現才能開始看,那等待的時間就太長了,早就沒耐心看了。

初略地說,集合與流之間的差異就在於什麼時候進行計算。集合是一個記憶體中的資料結構,它包含資料結構中目前所有的值,集合中的每個元素都得先算出來才能新增到集合中。

相比之下,流則是在概念上固定的資料結構,其元素則是按需計(懶載入)算的。需要多少就給多少。這是一種生產者與消費者的關係。從另一個角度來說,流就像是一個延遲建立的集合:只有在消費者要求的時候才會生成值。與之相反,集合則是急切建立的(就像黃牛囤貨一樣)。

流只能遍歷一次

請注意,和迭代器類似,流只能遍歷一次。遍歷完之後,我們就說這個流已經被消費掉了。你可以從原始資料來源那裡再獲得一個新的流來重新遍歷一遍,就像迭代器一樣(這裡假設它是集合之類的可重複的源,如果是I/O通道就沒戲了)。例如以下程式碼會丟擲一個異常,說流已被消費掉了:

List<String> names = Arrays.asList("Java8", "Lambdas", "In", "Action");
Stream<String> s = names.stream();
s.forEach(System.out::println);
// 再繼續執行一次,則會丟擲異常
s.forEach(System.out::println);
複製程式碼

千萬要記住,它只能消費一次!

外部迭代與內部迭代

使用Collection介面需要用使用者去做迭代(比如用for-each),這個稱為外部迭代。反之,Stream庫使用內部迭代,它幫你把迭代做了,還把得到的流值存在了某個地方,你只要給出一個函式說要幹什麼就可以了。下面的程式碼說明了這種區別。

集合:使用for-each迴圈外部迭代:

// 集合:使用for-each迴圈外部迭代
List<Dish> menu = Dish.MENU;
List<String> names = new ArrayList<>();
for (Dish dish : menu) {
    names.add(dish.getName());
}
複製程式碼

請注意, for-each 還隱藏了迭代中的一些複雜性。for-each結構是一個語法糖,它背後的東西用Iterator物件表達出來更要醜陋得多。

集合:用背後的迭代器做外部迭代

List<String> names = new ArrayList<>();
Iterator<String> iterator = menu.iterator();
while(iterator.hasNext()) {
    Dish d = iterator.next();
    names.add(d.getName());
}
複製程式碼

流:內部迭代

List<String> names = menu.stream()
                    .map(Dish::getName)
                    .collect(toList());
複製程式碼

讓我們用一個比喻來解釋一下內部迭代的差異和好處吧!比方說你在和你兩歲的兒子說話,希望他能把玩家收起來。

你:“兒子,我們把玩家收起來吧。地上還有玩具嗎?”
兒子:“有,球。”
你:“好,放進盒子裡。還有嗎?”
兒子:“有,那是我的飛機。”
你:“好,放進盒子裡。還有嗎?”
兒子:“有,我的書。”
你:“好,放進盒子裡。還有嗎?”
兒子:“沒了,沒有了。”
你:“好,我們收好啦!”
複製程式碼

這正是你每天都要對Java集合做的。你外部迭代一個集合,顯式地取出每個專案再加以處理。如果,你對兒子說“把地上的所有玩具都放進盒子裡收起來”就好了。內部迭代比較好的原因有二:第一,兒子可以選擇一隻手拿飛機,另一隻手拿球第二,他可以決定先拿離盒子最近的那個東西,然後再拿別的。同樣的道理,內部迭代時,專案可以透明地並行處理,或者用更優化的順序進行處理。要是用Java過去的那種外部迭代方法,這些優化都是很困難的。這似乎有點兒雞蛋裡挑骨頭,但這差不多就是Java 8引入流的理由了,Stream庫的內部迭代可以自動選擇一種適合你硬體的資料表示和並行實現。與此相反,一旦通過寫 for-each 而選擇了外部迭代,那你基本上就要自己管理所有的並行問題了(自己管理實際上意味著“某個良辰吉日我們會把它並行化”或“開始了關於任務和 synchronized 的漫長而艱苦的鬥爭”)。Java8需要一個類似於Collection 卻沒有迭代器的介面,於是就有了Stream!下面的圖說明了流(內部迭代)與集合(外部迭代)之間的差異。

image

我們已經瞭解過了集合與流在概念上的差異,特別是利用內部迭代:替你把迭代做了。但是,只有你已經預先定義好了能夠隱藏迭代的操作集合。例如filter或map,這個才有用。大多數這類操作都接受Lambda表示式作為引數,因此我們可以用前面所瞭解的知識來引數化其行為。

流操作

java.util.stream.Stream 中的 Stream 介面定義了許多操作。它們可以分為兩大類。我們再來看一下前面的例子:

List<String> names = menu.stream()
                // 中間操作
                .filter(d -> d.getCalories() > 300)
                // 中間操作
                .map(Dish::getName)
                // 中間操作
                .limit(3)
                // 將Stream轉為List
                .collect(toList());
複製程式碼

filter、map和limit可以連成一條線,collect觸發流水線執行並關閉它。可以連起來的稱為中間操作,關閉流的操作可以稱為終端操作。

中間操作

諸如filter和sorted等中間操作會返回一個流。讓多個操作可以連線起來形成一個查詢。重要的是,除非流水線上觸發一個終端操作,否則中間操作不會執行任何處理它們懶得很。這就是因為中間操作一般都可以合併起來,在終端操作時一次性全部處理。

為了搞清楚流水線到底發生了什麼,我們把程式碼改一改,讓每個Lambda都列印出當前處理的菜餚(就像很多演示和除錯技巧一樣,這種程式設計風格要是擱在生產程式碼裡那就嚇死人了,但是學習的時候卻可以直接看清楚求值的順序):

List<String>  names = menu.stream()
        .filter(d -> {
            System.out.println("filtering:" + d.getName());
            return d.getCalories() > 300;
        })
        .map(dish -> {
            System.out.println("mapping:" + dish.getName());
            return dish.getName();
        })
        .limit(3)
        .collect(toList());
System.out.println(names);
複製程式碼

執行結果:

filtering:pork
mapping:pork
filtering:beef
mapping:beef
filtering:chicken
mapping:chicken
[pork, beef, chicken]
複製程式碼

從上面的列印結果,我們可以發現有好幾種優化利用了流的延遲性質。第一,儘管有很多熱量都高於300卡路里,但是隻會選擇前三個!因為limit操作和一種稱為短路的技巧,第二,儘管filter和map是兩個獨立的操作,但是它們合併到同一次便利中了(我們把這種技術叫做迴圈合併)。

終端操作

終端操作會從流的流水線生產結果。其結果是任何不是流的值,比如List、Integer,甚至是void。例如,在下面的流水線中,foreachh返回的是一個void的終端操作,它對源中的每道菜應用一個Lambda。把System.out.println()傳遞給foreach,並要求它列印出由menu生成的流中每一個Dish:

menu.stream().forEach(System.out::println);
複製程式碼

為了檢驗一下對終端操作已經中間操作的理解,下面我們一起來看看一個例子:

下面哪些是中間操作哪些是終端操作?

long count = menu.stream()
            .filter(d -> d.getCalories() > 300)
            .distinct()
            .limit(3)
            .count();
複製程式碼

答案:流水線中最後一個操作是count,它會返回一個long,這是一個非Stream的值。因此,它是終端操作。

使用流

總而言之,流的使用一般包括三件事:

  1. 一個資料來源(比如集合)來執行查詢
  2. 一箇中間操作鏈,形成一條流的流水線
  3. 一個終端操作,執行流水線,並能生成結果。

流的流水線背後的理念類似於構建器模式。 在構建器模式中有一個呼叫鏈用來設定一套配置(對流來說這就是一箇中間操作鏈),接著是呼叫built方法(對流來說就是終端操作)。其實,我們目前所看的Stream的例子用到的方法並不是它的全部,還有一些其他的一些操作。

在本章中,我們所接觸到的一些中間操作與終端操作:

中間:

操作 型別 返回型別 操作引數 函式描述
filter 中間 Stream Predicate T -> boolean
map 中間 Stream Function<T, R> T -> R
limit 中間 Stream
sorted 中間 Stream Comparator (T, T) -> int
distinct 中間 Stream

終端:

操作 型別 目的
foreach 終端 消費流中的每個元素並對其應用 Lambda。這一操作返回 void
count 終端 返回流中元素的個數。這一操作返回 long
collect 終端 把流歸約成一個集合,比如 List 、 Map 甚至是 Integer

Stream是一個非常好用的一個新特性,它能幫助我們簡化很多冗長的程式碼,提高我們程式碼的可讀性。

本章總結

  1. 流是“從支援資料處理操作的源生成的一系列元素”。
  2. 流利用內部迭代:迭代通過filter、map、sorted等操作被抽象掉了。
  3. 流操作有兩類:中間操作和終端操作。
  4. filter和map等中間操作會返回一個流,並可以連結在一起。可以用它們來設定一條流水線,但並不會生成任何結果。
  5. forEach和count等終端操作會返回一個非流的值,並處理流水線以返回結果。 6.流中的元素是按需計算(懶載入)的。

程式碼示例

Github: chap4

Gitee: chap4

公眾號

如果,你對Java8中的新特性很感興趣,你可以關注我的公眾號或者當前的技術社群的賬號,利用空閒的時間看看我的筆記,非常感謝!

《Java8實戰》-第四章讀書筆記(引入流Stream)

相關文章