使用Java 8 Stream像操作SQL一樣處理資料(上)

liuyatao發表於2018-01-17

幾乎每個Java應用都要建立和處理集合。集合對於很多程式設計任務來說是一個很基本的需求。舉個例子,在銀行交易系統中你需要建立一個集合來儲存使用者的交易請求,然後你需要遍歷整個集合才能找到這個客戶這段時間總共花費了多少金額。儘管集合非常重要,但是在java中對集合的操作並不完美。

首先,對一個集合處理的模式應該像執行SQL語言操作一樣可以進行比如查詢(一行交易中最大的一筆)、分組(用於消費日常用品總金額)這樣的操作。大多資料庫也是可以有明確的相關操作指令,比如"SELECT id, MAX(value) from transactions"SQL查詢語句可以讓你找到所有交易中最大的一筆交易和其ID。

正如你所看到的,我們不需要去實現怎樣計算最大值(比如迴圈和變數跟蹤得到最大值)。我們只需要表達我們期待什麼。那麼為什麼我們不能實現與資料庫查詢方式相似的方式來設計實現集合呢?

其次,我們應該怎麼有效處理很大資料量的集合呢?要加速處理的理想方式是採用多核架構CPU,但是編寫並行程式碼很難而且會出錯。

Java 8 將能夠完美解決這這個問題!Stream的設計可以讓你通過陳述式的方式來處理資料。stream還能讓你不寫多執行緒程式碼也是可以使用多核架構。聽起來很棒不是嗎?這將是這系列文章將要探索的主要內容。

在我們探索我們怎麼樣使用stream之前,我們先看一個使用Java 8 Stream的新的程式設計模式。我們需要找出所有銀行交易中型別是grocery的,並且以交易金額的降序的方式返回交易ID。在Java 7中我們需要這樣實現:

List<Transaction> groceryTransactions = new Arraylist<>();
for(Transaction t: transactions){
  if(t.getType() == Transaction.GROCERY){
    groceryTransactions.add(t);
  }
}
Collections.sort(groceryTransactions, new Comparator(){
  public int compare(Transaction t1, Transaction t2){
    return t2.getValue().compareTo(t1.getValue());
  }
});
List<Integer> transactionIds = new ArrayList<>();
for(Transaction t: groceryTransactions){
  transactionsIds.add(t.getId());
}
複製程式碼

在Java 8中這樣就可以實現:

List<Integer> transactionsIds =
    transactions.stream()
                .filter(t -> t.getType() == Transaction.GROCERY)
                .sorted(comparing(Transaction::getValue).reversed())
                .map(Transaction::getId)
                .collect(toList());

複製程式碼

下圖展示了Java 8的實現程式碼,首先,我們使用stream()函式從一個交易明細列表中獲取一個stream物件。接下來是一些操作(filtersortedmapcollect)連線在一起形成了一個管道,管道可以被看做是類似資料庫查詢資料的一種方式。

Stream 模型
Stream 模型

那麼怎麼處理並行程式碼呢?在Java8中非常簡單:只需要使用parallelStream()取代stream()就可以了,如下面所示,Stream API將在內部將你的查詢條件分解應用到多核上。

List<Integer> transactionsIds =
    transactions.parallelStream()
                .filter(t -> t.getType() == Transaction.GROCERY)
                .sorted(comparing(Transaction::getValue).reversed())
                .map(Transaction::getId)
                .collect(toList());
複製程式碼

你可以把stream看做是一種對集合資料提高效能、提供像SQL操作一樣的抽象概念,這個像SQL一樣的操作可以使用lambda表示式表示。

在這一系列關於Java 8 Stream文章的結尾,你將會使用Stream API寫類似於上述程式碼來實現強大的查詢功能。

開始使用Stream

我們先以一些理論作為開始。stream的定義是什麼?一個簡單的定義是:"對一個源中的一系列元素進行聚合操作。"把概念拆分一下:

  • 一系列元素:Stream對一組有特定型別的元素提供了一個介面。但是Stream並不真正儲存元素,元素根據需求被計算出結果。

  • :Stream可以處理任何一種資料提供源,比如結合、陣列,或者I/O資源。

  • 聚合操作:Stream支援類似SQL一樣的操作,常規的操作都是函數語言程式設計語言,比如filter,map,reduce,find,match,sorted,等等。

Stream操作還具備兩個基本特性使它與集合操作不同:

  • 管道:許多Stream操作會返回一個stream物件本身。這就允許所有操作可以連線起來形成一個更大的管道。這就就可以進行特定的優化了,比如懶載入和短迴路,我們將在下面介紹。

  • 內部迭代:和集合的顯式迭代(外部迭代)相比,Stream操作不需要我們手動進行迭代。

讓我們再次看一下之前的程式碼的一些細節:

stream模型細節
stream模型細節

我們首先通過stream()函式從一個交易列表中獲取一個Stream物件。這個資料來源是一個交易的列表,將會為Stream提供一系列元素。接下來,我們對Stream物件應用一些列的聚合操:filter(通過給定一個謂詞來過濾元素),sorted(通過給定一個比較器實現排序),和map(用於提取資訊)。除了collect其他操作都會返回Stream,這樣就可以形成一個管道將它們連線起來,我們可以把這個鏈看做是一個對源的查詢條件。

在collect被呼叫之前其實什麼實質性的東西都都沒有被呼叫。 collect被呼叫後將會開始處理管道,最終返回結果(結果是一個list)。

在我們探討stream的各種操作前,我們還是看一個stream和collection的概念層面的不同之處吧。

Stream VS Collection

Collection和Stream都對一些列元素提供了一些介面。他們的不同之處是:Collection是和資料相關的,Stream是和計算相關的。

想一下存在DVD中的電影,這是一個collection,因為他包含了所有的資料結構。然而網路上的電影是一種流資料。流媒體播放器只需要在使用者觀看前先下載一些幀就可以觀看了,不必全都下載下來。

簡單點說,Collection是一個記憶體中的資料結構,Collection包括資料結構中的所有值——每個Collection中的元素在它被新增到集合中之前已經被計算出來了。相反,Stream是一種當需要的時候才會被計算的資料結構。

使用Collection介面需要使用者做迭代(比如使用foreach),這種方式叫外部迭代。相反,Stream使用的是內部迭代——它會自己為你做好迭代,並且幫助做好排序。你只需要提供一個函式說明你想要幹什麼。下面程式碼使用Collection做外部迭代:

List<String> transactionIds = new ArrayList<>();
for(Transaction t: transactions){
    transactionIds.add(t.getId());
}
複製程式碼

下面程式碼使用Stream做內部迭代

List<Integer> transactionIds =
    transactions.stream()
                .map(Transaction::getId)
                .collect(toList());
複製程式碼

使用Stream處理資料

Stream 介面定義了許多操作,可以被分為兩類。

  • filter,sorted,和map,這些可以連線起來形成一個管道的操作

  • collect,可以關閉管道返回結果的操作

可以被連線起來的操作叫做中間操作。你可以把他們連線起來,因為他們返回都型別都是Stream。關閉管道的操作叫做終結操作。他們可以從管道中產生一個結果,比如一個List,一個Integer,甚至一個void。

中間操作其實不執行任何處理直到一個終結操作被呼叫;他們很“懶”。因為終結操作通常可以被合併,並且被終結操作一次性執行。

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
List<Integer> twoEvenSquares = 
    numbers.stream()
           .filter(n -> {
                    System.out.println("filtering " + n); 
                    return n % 2 == 0;
                  })
           .map(n -> {
                    System.out.println("mapping " + n);
                    return n * n;
                  })
           .limit(2)
           .collect(toList());
複製程式碼

上面的程式碼會計算集合中的前兩個偶數,執行結果如下:

filtering 1
filtering 2
mapping 2
filtering 3
filtering 4
mapping 4
複製程式碼

這是因為limit(2)使用了短迴路;我們只需要處理stream的一部分,然後並返回結果。這就像要計算一個很大的Boollean表示式:只要一個表示式返回false,我們就可以斷定這個表示式將會返回false而不需要計算所有。這裡limit操作返回一個大小為2的stream。還有就是filter操作和map操作合併起來一起傳給給了stream。

總結一下我們現已經已經學到的東西:Stream的操作包括如下三個東西:

  • 一個需要進行資料查詢的資料來源(比如一個collection)
  • 一連串組成管道的中間操作
  • 一個執行管道併產生結果的終結操作

Stream提供的操作可分為如下四類:

  • 過濾:有如下幾種可以過濾操作

    • filter(Predicate):使用一個謂詞java.util.function.Predicate作為引數,返回一個滿足謂詞條件的stream。
    • distinct:返回一個沒有重複元素的stream(根據equals的實現)
    • limit(n): 返回一個不超過給定長度的stream
    • skip(n): 返回一個忽略前n個的stream
  • 查詢和匹配:一個通常的資料處理模式是判斷一些元素是否滿足給定的屬性。可以使用 anyMatch, allMatch, 和 noneMatch 操作來幫助你實現。他們都需要一個predicate作為引數,並且返回一個boolean作為作為結果(因此他們是終結操作)。比如,你可以使用allMatch來檢車在Stream中的所有元素是否有一個值大於100,像下面程式碼中表示的那樣。

boolean expensive =
    transactions.stream()
                .allMatch(t -> t.getValue() > 100);
複製程式碼

另外,Stream提供了findFirstfindAny,可以從Stream中獲取任意元素。它們可以和Stream的其他操作連線在一起,比如filter。findFirst和findAny都返回一個Optional物件,像下面這樣:


Optional<Transaction> = 
    transactions.stream()
                .filter(t -> t.getType() == Transaction.GROCERY)
                .findAny();
複製程式碼

Optional<T>類可以存放一個存在或者不存在的值。在下面程式碼中,findAny可能沒有返回一個交易型別是grocery類的資訊。Optional存在好多方法檢測元素是否存在。比如,如果一個交易資訊存在,我們可以使用相關函式處理optional物件。

 transactions.stream()
              .filter(t -> t.getType() == Transaction.GROCERY)
              .findAny()
              .ifPresent(System.out::println);
複製程式碼
  • 對映:Stream支援map方法,map使用一個函式作為一個引數,你可以使用map從Stream的一個元素中提取資訊。在下面的例子中,我們返回列表中每個單詞的長度。
List<String> words = Arrays.asList("Oracle", "Java", "Magazine");
 List<Integer> wordLengths = 
    words.stream()
         .map(String::length)
         .collect(toList());
複製程式碼

你可以定製更加複雜的查詢,比如“交易中最大值的id”或者“計算交易金額總和”。這種處理需要使用reduce操作,reduce可以將一個操作應用到每個元素上,知道輸出結果。reduce也經常被叫做摺疊操作,因為你可以看到這種操作像把一個長的紙張(你的stream)不停地摺疊直到想成一個小方格,這就是摺疊操作。

看一下一個例子:

int sum = 0;
for (int x : numbers) {
    sum += x;
}
複製程式碼

列表中的每個元素使用加號都迭代地進行了結合,從而產生了結果。我們本質上是“減少”了集合中的資料,最終變成了一個數。上面的程式碼有兩個引數:初始值和結合list中元素的操作符“+”

當使用Stream的reduce方法時,我們可以使用下面的程式碼將集合中的數字元素加起來。reduce方法有兩個引數:

int sum = numbers.stream().reduce(0, (a, b) -> a + b);
複製程式碼
  • 初始值,這裡是0。
  • 一個將連個數相加返回一個新值的BinaryOperator

reduce方法本質上抽象了重複的模式。其他查詢比如“計算產品”或者“計算最大值”是reduce方法的常規使用場景。

數值型Stream

你已經看到了你可以使用reduce方法來計算一個Integer的Stream了。然而,我們卻執行了很多次的開箱操作去重複地把一個Integer物件新增到另一個上。如果我們呼叫sum方法豈不是很好?像下面程式碼那樣,這樣程式碼的意圖也更加明確。

int statement = 
    transactions.stream()
                .map(Transaction::getValue)
                .sum(); // 這裡是會報錯的
複製程式碼

在Java 8 中引入了三種原始的特定數值型Stream介面來解決這個問題,它們是IntStream, DoubleStream, 和 LongStream。它們各自可以數值型Stream變成一個int、double、long。

可以使用mapToInt, mapToDouble, and mapToLong將通用Stream轉化成一個數值型Stream,我們可以將上面程式碼改成下面程式碼。當然你可以使用通用Stream型別取代數值型Stream,然後使用開箱操作。

int statementSum =
    transactions.stream()
                .mapToInt(Transaction::getValue)
                .sum(); // 可以正確執行
複製程式碼

數值型別Stream的另一個用途就是獲取一個區間的數。比如你可能想要生成1到100之前的所有數。Java 8在IntStream, DoubleStream, 和 LongStream 中引入了兩個靜態方法來幫助生成一個區間,它們是rangerangeClosed.

這兩個方法以區間開始的數為第一個引數,以區間結束的數為第二個引數。但是range的區間是開區間的,rangeClosed是閉區間的。下面是一個使用rangeClosed返回10到30之間的奇數的stream。

IntStream oddNumbers =
    IntStream.rangeClosed(10, 30)
             .filter(n -> n % 2 == 1);
複製程式碼

建立Stream

有幾種方式可以建立Stream。你已經知道了可以從一個集合中獲取一個Stream,還你使用過數值型別Stream。你可以使用數值、陣列或者檔案建立一個Stream。另外,你甚至可以使用一個函式生成一個無窮盡的Stream。

通過數值或者陣列建立Stream可以很直接:對於數值是要使用靜態方法Stream .of,對於陣列使用靜態方法Arrays.stream ,像下面程式碼這樣:

Stream<Integer> numbersFromValues = Stream.of(1, 2, 3, 4);
int[] numbers = {1, 2, 3, 4};
IntStream numbersFromArray = Arrays.stream(numbers);
複製程式碼

你可以使用Files.lines靜態方法將一個檔案轉化為一個Stream。比如,下面程式碼計算一個檔案的行數。

long numberOfLines =
    Files.lines(Paths.get(“yourFile.txt”), Charset.defaultCharset())
         .count();
複製程式碼

無窮Stream

到現在為止你知道了Stream元素是根據需求產生的。有兩個靜態方法Stream.iterateStream.generate可以讓你從從一個函式中建立一個Stream,因為元素是根據需求計出來的,這兩個方法可以一直產生元素。這也是我們叫無窮Stream的原因:Stream沒有一個固定的大小,但是它和從固定大小的集合中建立的stream是一樣的。

下面程式碼是一個使用iterate建立了包含一個10的倍數的Stream。iterate的第一個引數是初始值,第二個至是用於產生每個元素的lambda表示式(型別是UnaryOperator)。

Stream<Integer> numbers = Stream.iterate(0, n -> n + 10);
複製程式碼

我們可以使用limit操作將一個無窮的Stream轉化為一個大小固定的stream,像下面這樣:

numbers.limit(5).forEach(System.out::println); // 0, 10, 20, 30, 40
複製程式碼

總結

Java 8引入了Stream API,這可以讓你實現複雜的資料查詢處理。在這片文章中,我們已經看到了Stream支援很多操作,比如filter、mpa,reduce和iterate,這些操作可以方便我們寫簡潔的程式碼和實現複雜的資料處理查詢。這和Java 8之前使用的集合有很大的不同。Stream有很多好處。首先,Stream API使用了注入懶載入和短迴路的技術優化了資料處理查詢。第二,Stream可以自動地並行執行,充分使用多核架構。在下一篇文章中,我們將探討更多高階操作,比如flatMap和collect,請持續關注。

最後

感謝閱讀,有興趣可以關注微信公眾賬號獲取最新推送文章。

歡迎關注微信公眾賬號
歡迎關注微信公眾賬號

相關文章