
這篇文章會用 TDD 手刻 myTakeWhile 和 myDropWhile,搞清楚它們和 filter 的差異,理解「遇到不符合就停」的短路邏輯
| Kotlin | C# LINQ | 備註 |
|---|---|---|
takeWhile { } |
TakeWhile() |
|
dropWhile { } |
SkipWhile() |
Kotlin 叫 drop,C# 叫 Skip |
這兩個函式都接受 predicate,都是留下符合條件的元素。但 takeWhile 遇到第一個不符合的就停,後面的元素一律不看
val list = listOf(1, 2, 3, 1, 2)
list.filter { it < 3 } // [1, 2, 1, 2] — 全部檢查,留下所有 < 3 的
list.takeWhile { it < 3 } // [1, 2] — 碰到 3 就停了,後面的 1, 2 也不要
filter 是「從整個集合裡挑出符合的」。takeWhile 是「從頭開始拿,拿到不符合為止」。前者像在一堆履歷裡篩選,後者像在排隊隊伍前面數人頭
什麼時候用 takeWhile 而不是 filter?資料已經排過序的時候。比如一串按時間排序的 log,要拿「今天以前的所有記錄」,用 takeWhile 碰到今天的就停,不需要掃完整個集合
employees 也可以示範這個差異。先按年齡排序,再拿出年齡小於 35 的開頭區段
val byAge = employees.sortedBy { it.age }
byAge.takeWhile { it.age < 35 }.map { it.name } // [Bob, Diana, Alice, Eve]
byAge.dropWhile { it.age < 35 }.map { it.name } // [Charlie, Frank, Grace]
這裡成立的前提是資料已經照 age 排序。如果 employees 維持原本順序,takeWhile { it.age < 35 } 會在 Charlie(35) 停下來,後面的 Diana(28) 和 Eve(32) 都不會被拿到
短路特性在 lazy 管道裡威力會放大。對 Iterable 的 takeWhile,走到不符合就停,但來源本來就已經在記憶體裡;對 Sequence 的 takeWhile,後面的元素根本不會被 demand,連讀進記憶體都沒有(day 27 之後會看到)。無限序列更能看出差別,像 generateSequence(1) { it + 1 }.takeWhile { it < 100 } 拿到 99 個元素就停,不會無限跑下去
stdlib 把 takeWhile 設計成「碰到不符就 break」,而不是「全跑完再切」。如果是 eager 全掃描,成本就跟 filter 一樣要掃完整個集合,短路省下的就是那些不必要的掃描
@Test
fun `takeWhile less than 3`() {
val numbers = listOf(1, 2, 3, 4, 5)
val result = numbers.myTakeWhile { it < 3 }
assertEquals(listOf(1, 2), result)
}
@Test
fun `takeWhile stops at first non-matching even if later elements match`() {
val numbers = listOf(1, 2, 3, 1, 2)
val result = numbers.myTakeWhile { it < 3 }
assertEquals(listOf(1, 2), result)
}
@Test
fun `takeWhile all match returns all`() {
val numbers = listOf(1, 2, 3)
val result = numbers.myTakeWhile { it < 10 }
assertEquals(listOf(1, 2, 3), result)
}
@Test
fun `takeWhile none match returns empty`() {
val numbers = listOf(5, 6, 7)
val result = numbers.myTakeWhile { it < 3 }
assertEquals(emptyList<Int>(), result)
}
@Test
fun `takeWhile on empty list returns empty`() {
val numbers = emptyList<Int>()
val result = numbers.myTakeWhile { it < 3 }
assertEquals(emptyList<Int>(), result)
}
@Test
fun `takeWhile stops early on non-Collection iterable`() {
val source = CountingIterable((1..1000).toList())
val result = source.myTakeWhile { it < 4 }
assertEquals(listOf(1, 2, 3), result)
assertEquals(4, source.visited)
}
第二個測試是重點:後面的 1 和 2 明明符合條件,但因為碰到 3 已經停了,它們不會出現在結果裡
最後那個測試沿用 day 22 的 CountingIterable,因為短路這件事光看回傳值是看不出來的。注意 visited 是 4 不是 3:takeWhile 得先讀到第 4 個、發現它不符合,才知道該停。這也補上了前面 generateSequence 那個例子沒說破的細節——拿到 99 個元素,實際上碰了 100 個
inline fun <T> Iterable<T>.myTakeWhile(predicate: (T) -> Boolean): List<T> {
val result = ArrayList<T>()
for (element in this) {
if (!predicate(element)) {
break
}
result.add(element)
}
return result
}
跟 take(n) 一樣有 break,差別在停止條件:take 靠計數,takeWhile 靠 predicate。一旦 predicate 回傳 false 就跳出迴圈,後面的元素完全不處理
for 迴圈加 break 就是最終形狀,沒什麼好再改的。後面「與 stdlib 原始碼比較」會直接拿原始碼來印證
@Test
fun `dropWhile less than 3`() {
val numbers = listOf(1, 2, 3, 4, 5)
val result = numbers.myDropWhile { it < 3 }
assertEquals(listOf(3, 4, 5), result)
}
@Test
fun `dropWhile keeps elements after first non-match`() {
val numbers = listOf(1, 2, 3, 1, 2)
val result = numbers.myDropWhile { it < 3 }
assertEquals(listOf(3, 1, 2), result)
}
@Test
fun `dropWhile all match returns empty`() {
val numbers = listOf(1, 2, 3)
val result = numbers.myDropWhile { it < 10 }
assertEquals(emptyList<Int>(), result)
}
@Test
fun `dropWhile none match returns all`() {
val numbers = listOf(5, 6, 7)
val result = numbers.myDropWhile { it < 3 }
assertEquals(listOf(5, 6, 7), result)
}
@Test
fun `dropWhile on empty list returns empty`() {
val numbers = emptyList<Int>()
val result = numbers.myDropWhile { it < 3 }
assertEquals(emptyList<Int>(), result)
}
@Test
fun `dropWhile visits every element on non-Collection iterable`() {
val source = CountingIterable(listOf(1, 2, 3, 4, 5))
val result = source.myDropWhile { it < 3 }
assertEquals(listOf(3, 4, 5), result)
assertEquals(5, source.visited)
}
第二個測試一樣是重點:碰到 3 之後的 1 和 2 都保留,不管它們是否符合原本的條件。「dropWhile 只在開頭丟」這件事要用測試確認清楚
最後那個測試的 visited == 5 則說明另一件事:dropWhile 不能提前結束走訪,五筆全部都得經手。takeWhile 那邊一千筆只碰了 4 筆,這裡五筆一筆都跑不掉,兩個函式的成本差別就攤開了
inline fun <T> Iterable<T>.myDropWhile(predicate: (T) -> Boolean): List<T> {
var dropping = true
val result = ArrayList<T>()
for (element in this) {
if (dropping && predicate(element)) {
continue
}
dropping = false
result.add(element)
}
return result
}
用一個 boolean flag dropping 追蹤狀態。開頭的階段 dropping 是 true,碰到不符合 predicate 的元素就把 dropping 設成 false,之後所有元素都加進結果
跟 drop(n) 的差異:drop 靠計數器跳過固定數量,dropWhile 靠 predicate 跳過開頭符合條件的
注意 dropWhile 沒辦法提前終止(沒有 break)。一旦進入「保留」模式,後面每個元素都要加進結果,必須跑完整個集合
flag 寫法已經貼著 stdlib 走,差別只在 flag 的名字和語意方向,留到下一節對照原始碼時一起看
原始碼位置:kotlin.collections 的 _Collections.kt
stdlib 的 takeWhile 和我們幾乎一樣
public inline fun <T> Iterable<T>.takeWhile(predicate: (T) -> Boolean): List<T> {
val list = ArrayList<T>()
for (item in this) {
if (!predicate(item))
break
list.add(item)
}
return list
}
dropWhile 也幾乎一樣,用 yielding 代替我們的 dropping(語意相反:我們追蹤「還在丟嗎」,stdlib 追蹤「開始產出了嗎」)
public inline fun <T> Iterable<T>.dropWhile(predicate: (T) -> Boolean): List<T> {
var yielding = false
val list = ArrayList<T>()
for (item in this)
if (yielding)
list.add(item)
else if (!predicate(item)) {
list.add(item)
yielding = true
}
return list
}
stdlib 用 yielding 這個名字,可能是呼應 Sequence 的 yield 概念,意思是開始產出元素了。我們的 dropping 比較口語化,兩種寫法效果一樣
兩篇下來,切片家族有六個主要成員。以「只能從頭走一遍」的角度看
| 函式 | 行為 | 判斷邊界的依據 | 能提前停嗎 |
|---|---|---|---|
take(n) |
從頭取 n 個 | 計數 | 可以,取滿就停 |
takeLast(n) |
從尾取 n 個 | size,或 n 大小的緩衝 | 不行,要走完 |
takeWhile { } |
從頭取到不符合為止 | predicate | 可以,不符合就停 |
drop(n) |
從頭丟掉 n 個 | 計數 | 不行,要走完 |
dropLast(n) |
從尾丟掉 n 個 | size,或 n 大小的緩衝 | 不行,要走完 |
dropWhile { } |
從頭丟到不符合為止 | predicate | 不行,要走完 |
六個裡面只有兩個能提前停,而且都是從頭開始的操作。尾端的 takeLast / dropLast 要看 receiver:size 已知就直接算起點,連前面那段都不必走;只能單向走訪的話才得靠 n 大小的緩衝滾完整個序列(day 22 手刻過兩種版本)。「能不能提前停」這件事到 day 27 的 Sequence 會變成重點
沒有 takeLastWhile 和 dropLastWhile 嗎?有,stdlib 有定義在 List<T> 上的版本,跟 takeLast / dropLast 一樣不提供 Iterable 多載。但使用頻率低很多,這裡就不特別實作了
老實說,這個函式我沒有實際用過。以前寫 C# 的時候看到 SkipWhile 就覺得很不直覺,一直沒找到該用它的場合;換到 Kotlin 變成 dropWhile,感覺還是一樣
takeWhile 我想得出來要幹嘛:資料排過序,拿開頭符合條件的那一段,前面 log 的例子就很自然。但 dropWhile 反過來——「丟掉開頭符合條件的,剩下的全要」——腦袋要轉一下才知道結果長什麼樣,而且多數時候我要的其實是 filter,不是「只在開頭丟」
硬要想,大概是讀檔案時跳過開頭的註解列或標頭,從第一列真正的資料開始。但我自己沒這樣寫過,標頭固定一行的話 drop(1) 就解決了,註解列不固定的話我也是直接 filter 掉
認得 API 比用不用得到重要,哪天碰到適合的場景至少知道有這個東西
takeWhile 和 dropWhile 是條件版的 take / drop。跟 filter 長得像但行為不同:filter 每個元素都要判斷,takeWhile / dropWhile 只在分界點之前判斷,過了就不再呼叫 predicate
下一篇是 chunked / windowed / zipWithNext,把集合切成固定大小的區塊或用滑動視窗掃描
同步刊登於 Blog
圖片來源:AI 產生