[譯]Effective Kotlin系列之使用Sequence來優化集合的操作(四)

極客熊貓發表於2018-12-12

簡述: 今天迎來了Effective Kotlin系列的第四篇文章: 使用Sequence序列來優化大集合的頻繁操作.關於Sequence這個主題應該大家都不陌生,我寫過幾篇有關它的文章,可以說得上很詳細了。如果你對它的使用不太熟悉,歡迎檢視下面幾篇有關文章:

翻譯說明:

原標題: Effective Kotlin: Use Sequence for bigger collections with more than one processing step

原文地址: blog.kotlin-academy.com/effective-k…

原文作者: Marcin Moskala

開發者經常會忽略了Iterable(迭代器)與Sequence(序列)的區別。這其實也很正常,特別是當你去比較IterableSequence的介面定義時,它們長得幾乎一樣。

interface Iterable<out T> {
    operator fun iterator(): Iterator<T>
}
interface Sequence<out T> {
    operator fun iterator(): Iterator<T>
}
複製程式碼

對比上面程式碼,你只能說出它們之間唯一不一樣就是介面名不同而已。但是IterableSequence卻有著完全不同的用法,因此它們操作集合的函式的工作原理也是完全不同的。

序列是基於惰性的工作原理,因此處理序列的中間操作函式是不進行任何計算的。相反,它們會返回上一個中間操作處理後產生的新序列。所有這些一系列中間計算都將在終端操作執行中被確定,例如常見的終端操作toListcount.在另一方面,處理Iterable的每個中間操作函式都是會返回一個新的集合。

fun main(args: Array<String>) {
    val seq = sequenceOf(1,2,3)
    print(seq.filter { it % 2 == 1 }) 
    // Prints: kotlin.sequences.FilteringSequence@XXXXXXXX
    print(seq.filter { it % 2 == 1 }.toList()) // Prints: [1, 3]
    val list = listOf(1,2,3)
    print(list.filter { it % 2 == 1 }) // Prints: [1, 3]
}
複製程式碼

序列的filter函式是一箇中間操作,所以它不會做任何的計算,而是經過新的處理步驟對序列進行了加工。最終的計算將在終端操作中完成,如toList函式

正因為這樣,處理操作的順序也是不同的。在處理序列過程中,我們通常會對單個元素進行一系列的整體操作,然後再對下一個元素做進行一系列的整體操作,直到處理完集合中所有元素為止。在處理iterable過程中,我們是每一步操作都是針對整個集合進行,直到所有操作步驟執行完畢為止。

sequenceOf(1,2,3)
        .filter { println("Filter $it, "); it % 2 == 1 }
        .map { println("Map $it, "); it * 2 }
        .toList()
// Prints: Filter 1, Map 1, Filter 2, Filter 3, Map 3,
listOf(1,2,3)
        .filter { println("Filter $it, "); it % 2 == 1 }
        .map { println("Map $it, "); it * 2 }
// Prints: Filter 1, Filter 2, Filter 3, Map 1, Map 3,
複製程式碼

由於這種惰性處理方式以及針對每個元素進行處理的順序,我們可以生成一個不定長的Sequence序列

generateSequence(1) { it + 1 }
        .map { it * 2 }
        .take(10)
        .forEach(::print)
// Prints: 2468101214161820
複製程式碼

對於具有一定經驗的Kotlin開發人員來說,這應該不陌生吧,但是在大多數文章或書籍中並沒有提及到有關序列一個更重要的知識點: 對於處理多個單一處理步驟的集合使用序列更高效

多個處理步驟是什麼意思?我的意思不僅僅是多個處理集合的單一函式。所以如果你比較這兩個函式:

fun singleStepListProcessing(): List<Product> {
    return productsList.filter { it.bought }
}

fun singleStepSequenceProcessing(): List<Product> {
    return productsList.asSequence()
            .filter { it.bought }
            .toList()
}
複製程式碼

你會注意到它們效能對比幾乎沒有任何差異,或者說處理簡單的集合速度更快(因為它是內聯的)。假如你對比了多個處理步驟的函式,比如先是filter處理然後進行了map處理的twoStepListProcessing函式,那麼差異將是很明顯了。

fun twoStepListProcessing(): List<Double> {
    return productsList
            .filter { it.bought }
            .map { it.price }
}

fun twoStepSequenceProcessing(): List<Double> {
    return productsList.asSequence()
            .filter { it.bought }
            .map { it.price }
            .toList()
}

fun threeStepListProcessing(): Double {
    return productsList
            .filter { it.bought }
            .map { it.price }
            .average()
}

fun threeStepSequenceProcessing(): Double {
    return productsList.asSequence()
            .filter { it.bought }
            .map { it.price }
            .average()
複製程式碼

差異到底有多大呢? 讓我們對比一下基準測量出來的平均操作時間吧:

twoStepListProcessing                                   81 095 ns/op
twoStepSequenceProcessing                               55 685 ns/op
twoStepListProcessingAndAcumulate                       83 307 ns/op
twoStepSequenceProcessingAndAcumulate                    6 928 ns/op
複製程式碼

當我們使用Sequences時, twoStepSequenceProcessing函式明顯比twoStepListProcessing函式處理集合速度要快很多。在這種情況下,優化的效率約為30%左右。

當我們分別使用SequencesIterable處理集合資料來獲得某個具體的數值而不是獲得一個新集合時,它們之間的效率差異將會變大。因為在這種情況下,序列根本就不需要建立任何中間集合。

來看一些典型的現實生活的例子,假設我們需要計算成年人購買該產品的平均價格:

fun productsListProcessing(): Double {
    return clientsList
            .filter { it.adult }
            .flatMap { it.products }
            .filter { it.bought }
            .map { it.price }
            .average()
}
fun productsSequenceProcessing(): Double {
    return clientsList.asSequence()
            .filter { it.adult }
            .flatMap { it.products.asSequence() }
            .filter { it.bought }
            .map { it.price }
            .average()
}
複製程式碼

這是對比結果:

SequencesBenchmark.productsListProcessing             712 434 ns/op
SequencesBenchmark.productsSequenceProcessing         572 012 ns/op
複製程式碼

我們大概提高了20%的優化效率,這比直接處理沒有使用flatMap的情況要低一點,但這已經是一個很大的提升了。

當你一次又一次對比測量的效能資料時,你會發現如下這個規律:

當我們有多個處理步驟時,使用序列處理集合通常比直接處理集合更快。

哪種場景下序列處理速度不會更快呢?

在有一些不常用的操作中使用序列處理速度並不會更快,因為我們需要完整地操作整個集合。來自Kotlin stdlib標準庫中的sorted就是一個明顯的例子。

sorted使用的最佳實現:它將Sequence中的元素轉換到List中,然後使用Java stdlib標準庫中的sort函式進行排序操作。這個缺點就在於相比相同操作Collection的過程,中間這個轉換過程是需要花費額外的時間的(儘管如果Iterable不是Collection或陣列,那麼差異就並不大,因為它轉換過程也是需要花費時間的)

如果Sequence序列有類似sort這樣的函式是有爭議的,因為它只是固定空間長度上的惰性,並且不能用於不定長的序列。之所以引進它是因為它是一種比較受歡迎的函式,並且以這種方式使用它更容易。但是Kotlin開發人員應該要記住,它不能用於不定長的序列

generateSequence(0) { it + 1 }.sorted().take(10).toList()
// 不定長的計算時間
generateSequence(0) { it + 1 }.take(10).sorted().toList()
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
複製程式碼

sorted是一個少見處理步驟的例子,它在Collection上使用的效率會比Sequence更高。當我們使用多個處理步驟和單個排序函式是,我們可以期待使用Sequence序列處理的效能將得到提升。

// Took around 150 482 ns
fun productsSortAndProcessingList(): Double {
    return productsList
            .sortedBy { it.price }
            .filter { it.bought }
            .map { it.price }
            .average()
}

// Took around 96 811 ns
fun productsSortAndProcessingSequence(): Double {
    return productsList.asSequence()
            .sortedBy { it.price }
            .filter { it.bought }
            .map { it.price }
            .average()
}
複製程式碼

Java中的Stream(流)怎麼樣呢?

Java8中引入了流來處理集合。它們表現得看起來和Kotlin中的序列很像。

productsList.asSequence()
    .filter { it.bought }
    .map { it.price }
    .average()

productsList.stream()
    .filter { it.bought }
    .mapToDouble { it.price }
    .average()
    .orElse(0.0)
複製程式碼

它們也都是基於惰性求值的原理並且在最後(終端)處理集合。Java中的流對於集合的處理效率幾乎和Kotlin中的序列處理集合一樣高。Java中的Stream流和Kotlin中的Sequence序列兩者最大的差別如下所示:

  • Kotlin中Sequence序列有更多的操作符函式(因為它們可以被定義成擴充套件函式)並且它們的用法也相對更簡單(這是因為Kotlin的序列是已經在使用的Java流基礎上設計的 - 例如我們可以使用toList()而不是collect(Collectors.toList()))
  • Java的Stream流支援可以使用parallel函式以並行模式使用Java流. 當我們擁有一臺具有多個核心的計算機時,這可以為我們帶來巨大的效能提升。
  • Kotlin的Sequence序列可用於通用模組、Kotlin/JS模組和Kotlin/Native模組中。

除此之外,當我們不使用並行模式時,要說Java stream和 Kotlin sequence哪個更高效,這個真的很難說。

我的建議是僅僅將Java中的Stream用於計算量較大的處理以及需要啟用並行模式的場景。否則其他一般場景使用Kotlin Stdlib標準庫中Sequence序列,可以給你帶來相同效率並且操作函式使用起來也很簡單,程式碼更加簡潔。

譯者有話說

老實說這篇文章,好的地方在於原作者把Kotlin中的序列和Java 8中的流做了一個很好的對比,以及作者給出自己的使用建議以及針對效能效率都是通過實際基準測試結果進行對比的。但是唯一美中不足的是對於Kotlin中哪種場景下使用sequence更好,並沒有說的很清楚。關於這點我想補充一下:

  • 1、資料集量級是足夠大,建議使用序列(Sequences)
  • 2、對資料集進行頻繁的資料操作,類似於多個操作符鏈式操作,建議使用序列(Sequences)
  • 3、對於使用first{},last{}建議使用序列(Sequences)。補充一下,細心的小夥伴會發現當你對一個集合使用first{},last{}操作符的時候,我們IDE工具會提示你建議使用序列(Sequences) 代替 集合(Lists)
  • 4、當僅僅只有map操作時,使用sequence

這裡只是簡單總結了幾點,具體詳細內容可參考之前三篇有關Kotlin中序列的文章。好了第四篇完美收工。

Kotlin系列文章,歡迎檢視:

Effective Kotlin翻譯系列

原創系列:

翻譯系列:

實戰系列:

[譯]Effective Kotlin系列之使用Sequence來優化集合的操作(四)

歡迎關注Kotlin開發者聯盟,這裡有最新Kotlin技術文章,每週會不定期翻譯一篇Kotlin國外技術文章。如果你也喜歡Kotlin,歡迎加入我們~~~

相關文章