
這篇文章會說清楚 Collection 鏈式操作的中間集合問題,理解 Sequence 怎麼用 Lazy evaluation 避免不必要的計算和記憶體浪費
| Kotlin | C# LINQ | 備註 |
|---|---|---|
Sequence<T> |
IEnumerable<T> |
都是 Lazy |
asSequence() |
不需要 (C# 預設就是 Lazy) | |
toList() |
ToList() |
終端操作 |
C# LINQ 的 Where、Select 等轉換運算子預設延遲執行,所以沒有 asSequence() 這個切換點。Kotlin Collection 的 filter、map 會立刻建立結果,要延遲處理才改用 Sequence
剛從 C# 轉到 Kotlin 時,看到 filter 和 map 長得很像,很自然會以為執行方式也一樣。差別要等到管線變長,或在 Lambda 裡加 log,才會浮出來
C# 的 Where、Select 通常等到列舉才執行;ToList()、Count()、First() 則會取得結果。因此「LINQ 預設 Lazy」是個簡化的說法,延遲的是轉換運算子的那些,而取值的方法一樣是立刻執行
Kotlin 把兩種行為拆成不同型別。Collection 操作回傳新的集合,Sequence<T> 的中間操作建立延遲管線;asSequence() 就是兩者之間的切換點。這是從 API 就能直接確認的行為;至於當初為什麼這樣設計,官方文件沒有說明,這裡就不多做猜測
Java 的做法又不同:集合本身保留原有 API,另外用 Stream<T> 提供延遲管線與平行模式。stream() 的角色接近 Kotlin 的 asSequence();Kotlin Sequence 的介面則只有一個 iterator()
如果只記使用方式,可以先這樣分
.ToList()
前面 day 05 到 day 26 手刻的 Collection 函式都是 Eager 的。看一段典型的鏈式操作
employees
.filter { it.salary > 70000 } // 建立 List A
.map { it.name } // 建立 List B
.first() // 只需要第一個
filter 會先走完 7 個 employee,產出 4 人的 List A;map 再走完這 4 人,產出 List B。可是最後的 first() 只拿第一個名字,其餘計算與兩個中間 List 都沒有被後續使用
兩個中間 List,一個都不需要留下來。如果資料量是十萬筆,這個浪費就很明顯
用計數器追蹤每個步驟實際處理了多少元素
@Test
fun `eager processes all elements at each step`() {
var filterCount = 0
var mapCount = 0
val result = employees
.filter { filterCount++; it.salary > 70000 }
.map { mapCount++; it.name }
.first()
assertEquals("Alice", result)
assertEquals(7, filterCount) // filter 走完全部 7 個
assertEquals(4, mapCount) // map 走完 filter 結果的 4 個
}
filterCount 是 7,表示 filter 走了全部 7 個員工。mapCount 是 4,表示 map 走了 filter 結果的 4 個人。總共處理了 11 次,但只需要 2 次 (Alice 通過 filter 一次、map 一次) 就能拿到答案
加一個 asSequence() 就好,後面其餘程式碼一字不改
@Test
fun `lazy processes elements one by one`() {
var filterCount = 0
var mapCount = 0
val result = employees.asSequence()
.filter { filterCount++; it.salary > 70000 }
.map { mapCount++; it.name }
.first()
assertEquals("Alice", result)
assertEquals(1, filterCount) // 第一個就通過 filter,停了
assertEquals(1, mapCount) // 只 map 一個
}
filterCount 和 mapCount 都是 1。Sequence 拿到 Alice 後就停止,不會再處理後面六位員工
Eager 和 Lazy 的處理順序完全不同
Step 1 — filter 全跑完
Alice(85K) ✓ Bob(72K) ✓ Charlie(65K) ✗ Diana(78K) ✓
Eve(60K) ✗ Frank(55K) ✗ Grace(92K) ✓
→ 產出 List A: [Alice, Bob, Diana, Grace]
Step 2 — map 全跑完
Alice → "Alice" Bob → "Bob" Diana → "Diana" Grace → "Grace"
→ 產出 List B: ["Alice", "Bob", "Diana", "Grace"]
Step 3 — first
→ 取第一個 "Alice"
Eager 是「一個步驟跑完所有元素,再進下一個步驟」。像工廠流水線,每個工站處理完所有零件,裝箱送到下一站
元素 1 — Alice(85K)
filter: 85000 > 70000 → ✓ 通過
map: Alice → "Alice"
first: 拿到了,結束!
一個元素就搞定了。Bob、Charlie 他們根本不用處理
Lazy 是「一個元素走完整條 pipeline,再處理下一個元素」。如果中途拿到答案,後面的元素不會被碰到
在 debugger 裡單步走 Sequence,斷點的順序跟程式碼寫的順序是反的:不是從 filter 開始,而是先進 toList(),再跳到 map,最後才進 filter。寫過 LINQ 的人對這個順序應該不陌生,Where().Select().ToList() 在 C# 也是這樣反著跑
原因是這條鏈建立出來的物件是由外往內包的
TransformingSequence (map)
└─ FilteringSequence (filter)
└─ 原始 List 的 iterator
toList() 只認得最外層的 map,所以跟它要元素時,呼叫方向只能一路往上游傳
toList() ← 終端操作啟動
→ map 的 next() ← 我沒有,跟上游要
→ filter 的 hasNext() ← 我也沒有,再跟上游要
→ 原始 iterator ← Alice
← predicate 判斷 Alice 通過
← transform 產出 "Alice"
← "Alice" 放進 List
控制流由下往上 (誰跟誰要資料),資料流由上往下 (結果怎麼回傳)。LINQ 的 deferred execution 也是靠巢狀的 enumerator 做的,所以兩邊看到的順序才會一致
有兩點跟 C# 不太一樣。第一,Kotlin 只有 Sequence 是這樣,前面沒加 asSequence() 的 Collection 版本,debug 順序就是老實的由上往下:filter 跑完 7 個、map 跑完 4 個、才進 first()。第二,C# 的 Where、Select 是編譯器產生的 yield return 狀態機,predicate 跑在 MoveNext() 裡;Kotlin 的 filter 則把判斷放在 hasNext(),FilteringSequence 的 iterator 會在那裡先算好下一個通過的元素,map 的 transform 才在 next() 執行。所以斷點會停在 FilteringSequence$iterator$1.hasNext() 這種地方,行為結果相同,只是 call stack 的長相不同
原始碼位置:kotlin.sequences 的 Sequence.kt
這篇還沒開始手刻函式,但可以先看 Kotlin stdlib 裡 Sequence 介面的真實長相
public interface Sequence<out T> {
public operator fun iterator(): Iterator<T>
}
只有一個方法。跟 C# 的 IEnumerable<T> 一樣,只要求提供一個 Iterator。out T 是型別協變,表示 Sequence<Dog> 可以當成 Sequence<Animal> 用
對照 stdlib 的設計可以看出兩件事。第一,介面本身極簡,所有 Lazy 行為都藏在 Iterator 的實作裡,不在介面上。第二,stdlib 的 filter、map 這些中間操作其實是 Sequence<T> 的擴充函式,各自回傳一個包了上一層的新 Sequence,這就是下一篇開始要動手刻的東西
@Test
fun `sequence is lazy until terminal operation`() {
var executed = false
val seq = listOf(1, 2, 3).asSequence()
.map { executed = true; it * 2 }
assertFalse(executed) // 還沒執行
val result = seq.toList() // 終端操作觸發執行
assertTrue(executed)
assertEquals(listOf(2, 4, 6), result)
}
asSequence().map { ... } 之後,Lambda 完全沒有被呼叫。executed 還是 false。要到 toList() 這個終端操作出現,整條 pipeline 才會啟動
Sequence 的操作分兩種
filter、map、take 等,回傳新的 Sequence,不執行任何計算toList()、first()、count() 等,觸發整條 pipeline,產出最終結果這跟 C# 的 LINQ 一模一樣。Where 和 Select 是中間操作,ToList() 和 First() 是終端操作。這兩種操作的判斷規則 day 30 會深入講,這裡先知道「中間不執行、終端才觸發」就夠了
在這篇的 filter → map → first 範例裡,Collection 先建立兩個中間 List,Sequence 則一路處理到拿到答案為止。這不代表看到大集合就該一律加上 asSequence();先看管線能不能短路,以及最後是否仍要完整物化,判斷會比較準
至於實務上該怎麼在兩者之間選,day 31 會用 JMH 實測的耗時和記憶體配置量,給一份完整的判斷準則
下一篇開始手刻 Sequence 的基礎設施,自己實作 mySequenceOf、myEmptySequence、myAsSequence、myGenerateSequence
同步刊登於 Blog
圖片來源:AI 產生