Kotlin Sequences Api:入門

語言: CN / TW / HK

highlight: agate theme: orange


Kotlin Sequences Api:入門

前言

在日常開發中,專案處理特定型別是每個軟體開發人員日常工作的一部分,顯然,舉個例子,一個咖啡烘焙機、多個咖啡原產地、原產地農民之間的咖啡描繪,您可以通過多種方式處理此類資料。最常見的是通過API,比如說List<Roaster>``Set<Origin>``Map<Origin, Farmer>等等

一般情況下,集合可能適用所有情況,但是針對開發中的不同情況,我們需要找到更適合的方式去解決問題,正所謂術業有專攻,最合適的才是最好的;在本文中,我將帶領大家入門 Kotlin的序列(Sequences Api),具體來說,從以下三方面進行講解

  • 什麼是序列以及它是如何工作的

  • 如何去使用序列

  • 什麼時候應該考慮使用序列而不是集合

理解序列

​ 序列其實是一種資料容器,就像集合一樣,但是和集合又有些不同,具體體現在兩個方面

  • 執行序列操作是惰性的
  • 每次只處理一個元素

接下來,我們將深入瞭解使用序列處理的方式有什麼意義

##### 惰性處理

在處理資料的時候,序列懶惰地執行它們,而集合則急切地執行它們,例如,如果將map用於集合中:

java val list = listOf(1, 2, 3) val doubleList = list.map { number -> number * 2 }

該操作將立即執行,並且doubleList 將是第一個列表中元素的列表乘以 2。 但是,如果您使用序列執行此操作:

```java val originalSequence = sequenceOf(1, 2, 3) val doubleSequence = originalSequence.map { number -> number * 2 }

```

雖然doubleSequence是與 originalSequence 不同的序列,但它不會有加倍的值。相反,doubleSequence是由初始 originalSequencemap 操作組成的序列。當您查詢doubleSequence 的結果時,該操作只會在稍後執行。 但是,在瞭解如何從序列中獲取結果之前,您需要了解建立它們的不同方法。

建立序列
  1. 您可以通過幾種方式建立序列。 您已經在上文中看到了其中的一種方式

java val sequence = sequenceOf(1, 2, 3)

​ 這個sequenceOf() 函式的工作方式與 listOf() 函式或任何其他同類集合函式一樣。 您將元素作為引數傳入,它會輸出一個序列。

  1. 建立序列的另一種方法是從集合中這樣做,將集合轉為序列

    java val coffeeOriginsSequence = listOf( "Ethiopia", "Colombia", "El Salvador" ).asSequence()

asSequence() 函式可以在每個 Collection 實現的任何Iterable 迭代器上呼叫。 它輸出與所述Iterable 中存在的相同元素的序列

  1. 最後一個建立序列的方法就是使用生成器函式,下面有一個例子

java val naturalNumbersSequence = generateSequence(seed = 1) { previousNumber -> previousNumber + 1 }

可以看到generateSequence函式將seed引數作為序列的第一個元素,並從該元素開始生成剩餘的元素

Collection介面不同的,Sequence介面不會將其任何實現繫結到size屬性上面。換句話說,我們可以建立一個無限序列,這就是上面程式碼所作的事情,從1開始,然後到無窮大,並且在每個生成的值上累加1,那這樣您就會有疑問了,我如果嘗試在此序列上進行操作,獲取其所有元素,那麼該怎麼停下來呢,因為它是無限的

一種方法是在生成器函式本身中使用某種停止機制,實際上,generateSequence函式在返回null的時候就會停止生成,下面是建立有限序列的方法

java val naturalNumbersUpToTwoHundredMillion = generateSequence(seed = 1) { previousNumber -> if (previousNumber < 200_000_000) { // 1 previousNumber + 1 } else { null // 2 } }

​ 在上面程式碼中

  • 先檢查先前生成的值是否小於200,000,000,若是符合則新增下一個元素
  • 如果達到等於或大於 200,000,000 的值,則返回 null,從而有效地停止序列生成

停止序列生成的另一種方法是使用它的一些運算子,接下來會開始講解它們

使用序列運算子

序列有兩種運算子

  • 中間運算子(intermediate):用於構建序列的運算子

  • 終端運算子(terminal):用於執行構建序列的操作的操作符

接下來我們首先了解下中間運算子

中間運算子(Intermediate Operators)

要開始瞭解運算子的工作原理,我們先用上文提供的最後的一個序列的例子

java val naturalNumbersUpToTwoHundredMillion = generateSequence(seed = 1) { previousNumber -> if (previousNumber < 200_000_000) { previousNumber + 1 } else { null } }

現在,通過新增兩個中間運算子從中構建一個新序列。你可能會認出這些,因為序列和集合有很多類似的運算子,比如說filtertake

java val firstHundredEvenNaturalNumbers = naturalNumbersUpToTwoHundredMillion .filter { number -> number % 2 == 0 } // 1 .take(100) // 2

在上面程式碼中,主要做了如下工作

  • 按奇偶性過濾元素,只需要偶數
  • 只取前100個元素,丟棄其餘元素

如前所述,序列一次處理一個元素。 換句話說,過濾器首先對第一個數字 1 進行操作,然後將其丟棄,因為它不能被 2 整除。 然後,它對 2 進行運算,讓它繼續取,因為 2 是偶數。 操作一直持續到操作的元素為 200,因為在 [1, 200_000_000] 區間內,200 是第100 個偶數。 那時,takefilter都不再處理任何元素。

是不是有些難以理解,為了直觀體現,下圖我們將上述操作進行視覺化

intermediate-operators.gif

多虧了 take(100),從 200 開始,200,000,000 和它之前的所有數字永遠不會被操作。您會在臨時檔案中注意到,firstHundredEvenNaturalNumbers 實際上還沒有輸出任何值。 實際上,暫存檔案只是顯示型別:

Screenshot-2022-02-15-at-22.09.26-650x54.png

可以看到我們已經知道它是int型別的序列,這個時候我們就需要一個終端操作符terminal operators來處理輸出序列的結果

##### 終端運算子(Terminal Operators)

終端運算子可以採取多種方式,例如,toListtoSet就可以將序列結果作為集合輸出,其他的,類似first()sum()可以輸出單個值

實際上,有特別多終端操作符,但有一種簡單的方法可以識別它們,而無需深入研究實現或查閱文件。

回到剛剛所寫的程式碼中,就在 take(100) 下方,開始輸入map運算子。 在您鍵入時,Android Studio 會彈出程式碼完成。 如果您檢視提示,您會看到 map 的返回型別為Sequence,而 R 是 map 的返回型別。

Screenshot-2022-02-16-at-00.24.08.png 然後我們換成開始輸入forEach 終端操作符。 當代碼完成彈出提示的時候,請注意 forEach 的返回型別。

Screenshot-2022-02-16-at-00.59.25.png

map不同,forEach不返回序列。 這是有道理的,對吧? 畢竟是終端運算子。 所以,長話短說,這就是你如何一眼就能區分它們的方法:

  • 中間運算子總是會返回一個序列的
  • 終端運算子是絕對不會返回序列的

現在知道了如何去構建序列並且輸出結果了把,通過forEach迴圈列印每個元素來完成剛剛編寫的終端操作符。 最後,程式碼如下

```java val firstHundredEvenNaturalNumbers = naturalNumbersUpToTwoHundredMillion .filter { number -> number % 2 == 0 } .take(100) .forEach { number -> println(number) }

```

然後執行看下實際的輸出結果

Screenshot-2022-02-16-at-01.35.05-650x212.png

可以看到,它列印了每個偶數,最多 200 個。

接著我們來看,就像集合一樣,運算子順序在序列中很重要。 例如,我們將 takefilter 交換,如下所示:

```java val firstHundredEvenNaturalNumbers = naturalNumbersUpToTwoHundredMillion .take(100) .filter { number -> number % 2 == 0 } .forEach { number -> println(number) }

```

那麼它輸出的結果會和上面一樣麼?答案是不會的,您會看到它列印了最多 100 個偶數。由於 take 先執行,filter 只對前 100 個自然數進行操作,從一個開始。

all right,到這裡為止,我們已經知道如何使用序列了,接下來就要回到我們最初提到的什麼時候應該考慮使用序列而不是集合這個問題上來了

序列與集合

你現在知道如何構建和使用序列了。 但是什麼時候應該使用它們而不是集合呢? 你應該使用它們嗎? 這可以用軟體開發中最著名的一句話來快速回答:視情況而定。 :]

​ 長答案有點複雜。 它始終取決於您的用例。 事實上,確實,您應該始終測量這兩種實現,以檢查哪一種更快。 但是,瞭解序列的一些怪癖也將幫助你做出更明智的決定。

元素操作順序

​ 好了,如果我們有金魚的記憶,請記住序列一次是對每個元素進行操作的, 另一方面,集合是對整個集合執行每個操作,在進行下一個操作之前構建一箇中間結果。 因此,每個集合操作都會使用其結果建立一箇中間集合,下一個操作將在其中進行操作

```java val list = naturalNumbersUpToTwoHundredMillion .toList() .filter { number -> number % 2 == 0 } .take(100) .forEach { number -> println(number) }

```

​ 在上面的程式碼中,filter 將建立一個新列表,然後 take 將對該列表進行操作,建立一個自己的新列表,依此類推。 這浪費了很多的工作!特別是因為你最終只取了 100 個元素。 絕對沒有必要為百分之一之後的元素而煩惱。而序列有效地避免了計算中間結果,在這種情況下能夠勝過集合。

但是也會有問題,我們在新增的每個中間操作都會引入一些開銷。這種開銷來自這樣一個事實,即每個操作都涉及建立一個新的函式物件來儲存稍後要執行的轉換。 事實上,這種開銷對於不夠大的資料集或在您不需要那麼多操作的情況下可能是有問題的。 因為這種開銷甚至可能超過避免中間結果所帶來的收益。

為了更好地理解這種開銷來自哪裡,我們來檢視filter的具體實現:

java public fun Sequence.filter(predicate: (T) -> Boolean): Sequence { return FilteringSequence(this, true, predicate) }

FilteringSequence 是它自己的序列。 它包裝了您呼叫過濾器的序列。 換句話說,每個中間操作符都會建立一個新的序列物件來裝飾前一個序列。 最後,您留下的物件至少與中間運算子一樣多。更復雜的是,並非所有中間運算子都將自己限制為僅裝飾前一個序列。其中一些還需要了解序列的狀態

無狀態和有狀態運算子(Stateless and Stateful Operators)

中間運算子大致可以歸為

  • 無狀態:它們獨立處理每個元素,無需瞭解任何其他元素
  • 有狀態的:他們需要有關其他元素的資訊來處理當前元素。

到目前為止,上述提到的中間運算子基本都是無狀態的,那麼有狀態的運算子是怎麼樣的呢,我們在剛才的程式碼中,就在forEach之前新增一個sortedDescending呼叫

```java val firstHundredEvenNaturalNumbers = naturalNumbersUpToTwoHundredMillion .take(100) .filter { number -> number % 2 == 0 } .sortedDescending() // add this call .forEach { number -> println(number) }

```

然後我們在輸出中看到,得到的數字列表與以前相同,但列印的是相反的。為了使sortedDescending能夠反轉它,它必須處理每個元素,同時與序列中的每個其他元素進行比較。 但是它怎麼能做到這一點,因為序列一次只處理一個元素呢?

答案實際上很簡單,我們來看下sortedDescending是如何實現的,您會看到它將排序委託給一個名為 sortedWith的函式。 反過來,如果您檢查 sortedWith 的實現,你會看到如下內容

java public fun Sequence.sortedWith(comparator: Comparator): Sequence { return object : Sequence { // 1 override fun iterator(): Iterator { // 2 val sortedList = [email protected]() // 3 sortedList.sortWith(comparator) // 4 return sortedList.iterator() // 5 } } }

上面程式碼主要實現了以下作用

  1. 它建立並返回一個實現了Sequence介面的匿名物件。
  2. 該物件實現了Sequence 介面的iterator()方法。
  3. 該方法將序列轉換為MutableList
  4. 然後它根據比較器對列表進行排序。
  5. 最後,它返回列表的迭代器。

等等,什麼?大為震驚,它將序列轉換為集合。那個toMutableList 是一個終端操作符,這個中間運算元有效地呼叫序列上的終端運算元,然後在最後輸出一個新的運算元。因此,想想如果您在任何其他運算子之前呼叫 naturalNumbersUpToTwoHundredMillion上的 sortedDescending 會發生什麼:您將擁有一個MutableList,記憶體中有兩億個元素! 所以需要一段時間才能獲得任何結果。

stateful.gif

可以看到它需要等待一段時間才有結果

雖然並非所有有狀態的操作符都像sortedDescending這樣的,但它們都使用類似的技巧來獲得執行任務所需的狀態。 也就是說,這些運算子可能會對序列的效能產生巨大的負面影響,請始終注意何時使用它們,因為它們的影響可能足夠強大,這樣以來,還不如使用集合會更加適合

何時使用序列

畢竟,您應該對序列可能派上用場的情況有一個初步的瞭解。 以下簡要概括了下使用序列比集合更適合的原因

  • 在處理大型資料集的時候,應用大量操作。
  • 使用避免不必要工作的中間運算子——例如 take。
  • 應當避免有狀態的操作符,類似sortedDesending
  • 避免將序列轉換為集合的終端運算子,例如 toList

這些僅僅是提供給大家作為參考,在遇到實際情況下,需要結合當時環境自己判斷是否適合使用序列

小結

在本文中,我們瞭解了很多關於何時使用序列與集合的知識,但關於該主題仍有很多需要學習的地方。

如果您想深入瞭解 Sequence和運算子,Kotlin 的文件始終是一個不錯的起點。 檢視Sequence 的文件和運算子列表

要了解更多關於它們如何與集合進行比較的資訊,可以閱讀Kotlin中的集合和序列

本文來自翻譯☞[https://www.raywenderlich.com/31290959-kotlin-sequences-getting-started]