Java 8新特性之旅:使用Stream API處理集合

ImportNew發表於2015-08-28

在這篇“Java 8新特性教程”系列文章中,我們會深入解釋,並通過程式碼來展示,如何通過流來遍歷集合,如何從集合和陣列來建立流,以及怎麼聚合流的值。

在之前的文章“遍歷、過濾、處理集合及使用Lambda表示式增強方法”中,我已經深入解釋並演示了通過lambda表示式和方法引用來遍歷集合,使用predicate介面來過濾集合,實現介面的預設方法,最後還演示了介面靜態方法的實現。

原始碼都在我的Github上:可以從 這裡克隆。

內容列表

  • 使用流來遍歷集合。
  • 從集合或陣列建立流。
  • 聚合流中的值。

1. 使用流來遍歷集合

簡介:

Java的集合框架,如List和Map介面及Arraylist和HashMap類,讓我們很容易地管理有序和無序集合。集合框架自引入的第一天起就在 持續的改進。在Java SE 8中,我們可以通過流的API來管理、遍歷和聚合集合。一個基於流的集合與輸入輸出流是不同的。

如何工作?

它採用一種全新的方式,將資料作為一個整體,而不是單獨的個體來處理。當你使用流時,你不需要關心迴圈或遍歷的細節。你可以直接從一個集合建立一個流。然 後你就能用這個流來許多事件了,如遍歷、過濾及聚和。我將從專案 Java8Features 的 com.tm.java8.features.stream.traversing 包下的例子開始。程式碼在一個SequentialStream 類中,Java SE 8 中有兩種集合流,即序列流和並行流。

List<person> people = new ArrayList<>();

people.add(new Person("Mohamed", 69));
people.add(new Person("Doaa", 25));
people.add(new Person("Malik", 6));

Predicate<person> pred = (p) -> p.getAge() > 65;

displayPeople(people, pred);

...........

private static void displayPeople(List<person> people, Predicate<person> pred) {

     System.out.println("Selected:");
     people.forEach(p -> {
         if (pred.test(p)) {
             System.out.println(p.getName());
         }
     });
}

在這兩種流中,序列流相對比較簡單,它類似一個迭代器,每次處理集合中的一個元素。但是語法與以前不同。在這段程式碼中,我建立了 pepole 的陣列列表,向上轉型為List。它包含三個 Person 類的例項。然後我們使用 Predicate 宣告一個條件,只有滿足這個條件的 people 才會顯示。在 displayPeople() 方法的48到52行迴圈遍歷該集合,挨個測試其中的每一項。執行這段程式碼,你將獲得如下的結果:

Selected:
Mohamed

我將會展示如何使用流來重構這段程式碼。首先,我註釋了這段程式碼。然後,在這段註釋的程式碼下,我開始使用集合物件 people。然後我呼叫一個 stream() 方法。一個stream物件,類似集合,也要宣告泛型。如果你從一個集合獲取流,則該流中每一項的型別與集合本身是一致的。我的集合是 Person 類的例項,所以流中也使用同樣的泛型型別。

System.out.println("Selected:");
 //        people.forEach(p -> {
 //            if (pred.test(p)) {
 //                System.out.println(p.getName());
 //            }
 //        });

  people.stream().forEach(p -> System.out.println(p.getName()));
}

你可以呼叫一個 stream() 方法來獲得了一個流物件,然後可以在該物件上進行一些操作。我簡單地呼叫了 forEach 方法,該方法需要一個Lamda表示式。我在引數中傳遞了一個Lamda表示式。列表中的每一項就是通過迭代器處理的每一項。處理過程是通過Lambda 操作符和方法實現來完成的。我簡單使用system output來輸出每個人的名稱。儲存並執行這段程式碼,輸出結果如下。因為沒有過濾,所以輸出了列表中所有元素。

Selected:
Mohamed
Doaa
Malik

現在,一旦有了一個流物件,就可以很容易使用 predicate 物件了。當使用 for each 方法處理每一項時,我不得不顯示呼叫 predicate 的 test 方法,但是使用流時,你可以呼叫一個名為 filter 的方法。該方法接收一個 predicate 物件,所有的 predicate 物件都有一個 test 方法,所以它已經知道怎樣去呼叫該方法。所以,我對該程式碼做一點改動。我將.forEach()方法下移了兩行,然後在中間的空白行,我呼叫了 filter 方法。

people.stream()
     .filter(pred)
     .forEach(p -> System.out.println(p.getName()));

filter方法接收一個 predicate 介面的例項物件。我將 predicate 物件傳進去。filtr 方法返回一個過濾後的流物件,在這個物件上我就可以去呼叫forEach()方法了。我執行這段程式碼,這次我只顯示集合中滿足預定義條件的項了。你可以在 流物件上做更多的事情。去看看 Java SE 8 API 中流的doc文件吧。

Selected:
Mohamed

你將會看到除了過濾,你還可以做聚合、排序等其他的事情。在我總結這段演示之前,我想向你們展示一下序列流和並行流之前的重要區別。Java SE 8 的一個重要目標就是改善多 CPU 系統的處理能力。Java 可在執行期自動協調多個 CPU 的執行。你需要做的所有事情僅僅是將序列流轉換為並行流。

從語法上講,有兩種方法來實現流的轉換。我複製一份序列流類。在包檢視視窗,我複製並貼上該類,然後對它重新命名,ParallelStream,開啟這個 新的類。在這個版本中,刪除了註釋的程式碼。我不再需要這些註釋了。現在就可以通過兩種方式建立並行流。第一種方式是呼叫集合中的 parallelStream()方法。現在我就擁有一個可以自動分配處理器的流了。

private static void displayPeople(List<person> people, Predicate<person> pred) {
     System.out.println("Selected:");
     people.parallelStream()
             .filter(pred)
             .forEach(p -> System.out.println(p.getName()));
 }

執行這段程式碼,就可以看到完全一致的結果,過濾然後返回資料。

Selected:
Mohamed

第二種建立並行流的方式。再次呼叫 stream() 方法,然後在 stream 方法的基礎上呼叫 parallel() 方法,其本質上做的事情是一樣的。開始是一個序列的流,然後再將其轉換為並行流。但是它仍然是一個流。可以過濾,可以用之前的一樣方式去處理。只是現在的 流可以分解到多個處理起來處理。

people.stream()
      .parallel()
      .filter(pred)
      .forEach(p -> System.out.println(p.getName()));

總結

現在還沒有一個明確的規定來說明在什麼情況下並行流優於序列流。這個依賴於資料的大小和複雜性以及硬體的處理能力。還有你執行的多 CPU 系統。我可以給你的唯一建議是測試你的應用和資料。建立一個基準的、計時的操作。然後分別使用序列流和並行流,看哪一個更適合於你。

2、從集合或陣列建立流

簡介

Java SE 8’s stream API 是為了幫助管理資料集合而設計的,這些物件是指集合框架中的物件,例如陣列列表或雜湊表。但是,你也可以直接從陣列建立流。

如何工作?

在 Java8Features 專案中的 eg.com.tm.java8.features.stream.creating 包下,我建立了一個名為ArrayToStream的類。在這個類的 main 方法中,我建立了一個包含三個元素的陣列。每個元素都是Person類的一個例項物件。

public static void main(String args[]) {

    Person[] people = {
        new Person("Mohamed", 69),
        new Person("Doaa", 25),
        new Person("Malik", 6)};
    for (int i = 0; i < people.length; i++) {
        System.out.println(people[i].getInfo());
    }
}

該類中為私有成員建立了 setters 和 getters 方法,以及 getInfo() 方法,該方法返回一個拼接的字串。

public String getInfo() {
    return name + " (" + age + ")";
}

現在,如果想使用流來處理這個陣列,你可能認為需要先將陣列轉為陣列列表,然後從這個列表建立流。但是,實際上你可以有兩種方式直接從陣列建立流。第一方式,我不需要處理資料的那三行程式碼,所以先註釋掉。然後,在這個下面,我宣告一個流型別的物件。

Stream 是 java.util.stream 下的一個介面。當我按下 Ctrl+Space 並選取它的時候,會提示元素的泛型,這就是流管理的型別。在這裡,元素的型別即為Person,與陣列元素本身的型別是一致的。我將我新的流物件命名為 stream,所有的字母都是小寫的。這就是第一種建立流的方法,使用流的介面,呼叫 of() 方法。注意,該方法存在兩個不同版本。

第一個是需要單個物件,第二個是需要多個物件。我使用一個引數的方法,所以傳遞一個名為 people 的陣列,這就是我需要做的所有事情。Stream.of() 意思就是傳入一個陣列,然後將該陣列包裝在流中。現在,我就可以使用 lambda 表示式、過濾、方法引用等流物件的方法。我將呼叫流的 for each 方法,並傳入一個 lambda 表示式,將當前的 person 物件和 lambda 操作符後傳入後,就能獲取到 person 物件的資訊。該資訊是通過物件的 getInfo() 方法獲取到的。

Person[] people = {
        new Person("Mohamed", 69),
        new Person("Doaa", 25),
        new Person("Malik", 6)};

//        for (int i = 0; i < people.length; i++) {
//            System.out.println(people[i].getInfo());
//        }
        Stream<Person> stream = Stream.of(people);
        stream.forEach(p -> System.out.println(p.getInfo()));

儲存並執行這段程式碼,就可獲取到結果。輸出的元素的順序與我放入的順序是一致的。這就是第一種方式:使用 Stream.of() 方法。

Mohamed (69)
Doaa (25)
Malik (6)

另一種方式與上面的方式實際上是相同的。複製上面的程式碼,並註釋掉第一種方式。這次不使用 Stream.of() 方法,我們使用名為 Arrays 的類,該類位於 java.util 包下。在這個類上,可以呼叫名為 stream 的方法。注意,stream 方法可以包裝各種型別的陣列,包括基本型別和複合型別。

//      Stream<person> stream = Stream.of(people);

        Stream<person> stream = Arrays.stream(people);
        stream.forEach(p -> System.out.println(p.getInfo()));

儲存並執行上面的程式碼,流完成的事情與之前實質上是一致的。

Mohamed (69)
Doaa (25)
Malik (6)

結論

所以,無論是 Stream.of() 還是 Arrays.stream(),所做的事情實質上是一樣的。都是從一個基本型別或者複合物件型別的陣列轉換為流物件,然後就可以使用 lambda 表示式、過濾、方法引用等功能了。

3、聚合流的值

簡介

之前,我已經描述過怎麼使用一個流來迭代一個集合。你也可以使用流來聚合集合中的每一項。如計算總和、平均值、總數等等。當你做這些操作的時候,弄明白並行流特性就非常重要。

如何工作?

我會在 Java8Features 專案的 eg.com.tm.java8.features.stream.aggregating 包下進行演示。首先我們使用 ParallelStreams 類。在這個類的 main 方法中,我建立了一個包含字串元素的陣列列表。我簡單地使用迴圈在列表中新增了10000個元素。然後在35和36行,我建立了一個流物件,並通過 for each 方法挨個輸出流中每一項。

public static void main(String args[]) {

    System.out.println("Creating list");
    List<string> strings = new ArrayList<>();
    for (int i = 0; i < 10000; i++) {
        strings.add("Item " + i);
    }
    strings.stream()
           .forEach(str -> System.out.println(str));
}

執行這段程式碼後,就獲得了一個我所預期的結果。在螢幕上輸出的順序與新增到列表中的順序是一致的。

.........
Item 9982
Item 9983
Item 9984
Item 9985
Item 9986
Item 9987
Item 9988
Item 9989
Item 9990
Item 9991
Item 9992
Item 9993
Item 9994
Item 9995
Item 9996
Item 9997
Item 9998
Item 9999

現在,讓我們看一下當轉換成並行流後會發生什麼。正如我之前所描述的,我即可以呼叫parallelStream方法,也可以在流上呼叫parallel方法。

我將採用第二種方法。現在,我就可以使用並行流了,該流可以根據負載分配到多個處理器來處理。

strings.stream()
       .parallel()
       .forEach(str -> System.out.println(str));

再次執行該段程式碼,然後觀察會發生什麼。注意,現在最後列印的元素不是列表中最後一個元素,最後一個元素應該是9999。如果我滾動輸出結果,就能發現處理過程以某種方式在迴圈跳動。這是因為在執行時將資料劃分成了多個塊。

.........
Item 5292
Item 5293
Item 5294
Item 5295
Item 5296
Item 5297
Item 5298
Item 5299
Item 5300
Item 5301
Item 5302
Item 5303
Item 5304
Item 5305
Item 5306
Item 5307
Item 5308
Item 5309
Item 5310
Item 5311

然後,將資料塊分配給合適的處理器去處理。只有當所有塊都處理完成了,才會執行之後的程式碼。本質上講,這是在呼叫 forEach() 方法時,將整個過程是根據需要來進行劃分了。現在,這麼做可能會提高效能,也可能不會。這依賴於資料集的大小以及你硬體的效能。通過這個例子,也可以看 出,如果需要按照新增的順序挨個處理每一項,那麼並行流可能就不合適了。

序列流能保證每次執行的順序是一致的。但並行流,從定義上講,是一種更有效率的方式。所以並行流在聚合操作的時候非常有效。很適合將集合作為一個整體考慮,然後在該集合上進行一些聚合操作的情況。我將會通過一個例子來演示集合元素的計數、求平均值及求和操作。

我們在這個類的 main 方法中來計數,開始還是用相同的基礎程式碼。建立10,000個字串的列表。然後通過一個 for each 方法迴圈處理每一項。

public static void main(String args[]) {

    System.out.println("Creating list");
    List<string> strings = new ArrayList<>();
    for (int i = 0; i < 10000; i++) {
        strings.add("Item " + i);
    }
    strings.stream()
           .forEach(str -> System.out.println(str));
}

在這個例子中,我想直接對集合元素進行計數,而不是挨個來處理。所以,我註釋掉原來的程式碼,使用下面的程式碼。因為不能準確的知道該集合到底有多少個元素。所以我使用長整型變數來儲存結果。

我將這個變數命名為count,通過呼叫集合strings的.stream(), .count()方法,返回一個長整型的值。然後將這個值與“count:”拼接起來,再通過system的output來列印。

//      strings.stream()
//             .forEach(str -> System.out.println(str));
        long count = strings.stream().count();
        System.out.println("Count: " + count);

儲存並執行該段程式碼,下面是輸出結果。集合中元素數量的統計幾乎是瞬間完成。

Creating list
Count: 10000

現在對上面的程式碼做一點小小的改動,增加兩個0。現在,開始處理1000,000個字串。我再次執行這段程式碼,也很快就返回結果了。

Creating list
Count: 1000000

現在,我使用並行流來處理,看會發生什麼。我在下面增加 parallel 方法:

//      strings.stream()
//             .forEach(str -> System.out.println(str));
        long count = strings.stream().parallel().count();
        System.out.println("Count: " + count);

然後我執行這段程式碼,發現花費的時間更長一點了。現在,我做一個基準測試,通過抓取操作前後的時間戳來觀察發生了什麼。然後做一點數學的事情。不同的系統 上,得到的結果可能不同。但是根據我的經驗來說,這種包含簡單型別的簡單集合,使用並行流並沒有太多的優勢。不過,我還是鼓勵你去自己做基準測試,雖然有 點麻煩。 不過這也要你是如何去做的。

再讓我們看一下求和及求均值。我將使用 SumAndAverage 類。這次,我有一個包含三個 person 物件的列表,每個 person 物件的有不同的年齡值。我的目的是求三個年齡的和及年齡的平均值。我在所有的 person 物件都加入到列表之後加入了一行新的程式碼。然後,我建立了一個名為sum的整型變數。

首先,我通過 pepole.stream() 方法獲取一個流。在這個流基礎上,我可以呼叫 mapToInt() 方法。注意,還有兩個類似的 Map Method:mapToDouble() 和 mapToLong()。這些方法的目的就是,從複合型別中獲取簡單的基本型別資料,建立流物件。你可以用 lambda 表示式來完成這項工作。所以,我選擇 mapToInt() 方法,因為每個人的年齡都是整數。

關於 Lambda 表示式,開始是一個代表當前 person 的變數。然後,通過 Lambda 操作符和 Lambda 表示式(p.getAge())返回一個整數。這種返回值,我們有時也叫做int字串。也可以返回double字串或其它型別。現在,由於已經知道它 是一個數字型別的值,所以我可以呼叫 sum() 方法。現在,我就已經將所有集合中 person 物件的年齡值全部加起來了。通過一條語句,我就可以用 System Output 來輸出結果了。我將求和的結果與“Total of ages”連線在一起輸出。

List<person> people = new ArrayList<>();
        people.add(new Person("Mohamed", 69));
        people.add(new Person("Doaa", 25));
        people.add(new Person("Malik", 6));

        int sum = people.stream()
                  .mapToInt(p -> p.getAge())
                  .sum();
        System.out.println("Total of ages " + sum);

儲存並執行上面的程式碼。三個年齡的總和是100。

Total of ages 100

求這些值的平均值非常類似。但是,求平均值需要做除法操作,所以需要考慮除數為0的問題,因此,當你求平均值的時候,可以返回一個Optional的變數。

你可以使用多種資料型別。在計算平均值的時候,我想獲得一個 doule 型別的值。所以,我建立了一個 OptionalDouble 型別的變數。注意,還存在 Optional Int 和 Optional Long。我將平均值命名為 avg,使用的程式碼與求和的程式碼也是一致的,開始用 people.stream()。在這個基礎上,再次使用 mapToInt()。並且傳遞了相同的 lambda 表示式,最後,呼叫 average 方法。

現在,獲得了一個OptionalDouble型別的變數。在處理這個變數前,你可以通過 isPresent() 來確保它確實是一個double值。所以,我使用了一段 if/else 的模板程式碼來處理。判定的條件是 avg.isPresent()。如果條件為真,就使用 System Output 輸出“Average”標籤和平均值。在 else 子句中,我簡單地列印“average wasn’t calculated”。

OptionalDouble avg = people.stream()
                .mapToInt(p -> p.getAge())
                .average();
if (avg.isPresent()) {
    System.out.println("Average: " + avg);
} else {
    System.out.println("average wasn't calculated");
}

現在,在這個例子中,我知道能成功,因為我給三個人的年齡都賦值了。但是,情況不總是這樣的。正如我前面說的,存在除0的情況,這時你就不能獲取到一個 double 型別返回值。我儲存並執行這段程式碼,請注意 optional double 類,它是一個複合物件。

Total of ages 100
Average: OptionalDouble[33.333333333333336]

所以,真實的值被包含在該型別中,回到這段程式碼,直接引用該物件,並呼叫 getAsDouble() 方法。

if (avg.isPresent()) {
    System.out.println("Average: " + avg.getAsDouble());
} else {
    System.out.println("average wasn't calculated");
}

現在,我就可以獲得 double 型別的值。我再次執行這段程式碼,輸出結果如下:

Total of ages 100
Average: 33.333333333333336

結論

通過流和 lambda 表示式,你可以用非常非常少的程式碼就可以完成集合的聚合計算。

關於Stream API,您也可以閱讀這篇文章:Java 8中的Stream API使用指南

相關文章