iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Software Development

Kotlin Lambda 從零開始系列 第 23

Kotlin Lambda 從零開始 Day 23:takeWhile / dropWhile — 條件式切片

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會用 TDD 手刻 myTakeWhilemyDropWhile,搞清楚它們和 filter 的差異,理解「遇到不符合就停」的短路邏輯

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
takeWhile { } TakeWhile()
dropWhile { } SkipWhile() Kotlin 叫 drop,C# 叫 Skip

takeWhile vs filter — 長得像但行為不同

這兩個函式都接受 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 管道裡威力會放大。對 IterabletakeWhile,走到不符合就停,但來源本來就已經在記憶體裡;對 SequencetakeWhile,後面的元素根本不會被 demand,連讀進記憶體都沒有(day 27 之後會看到)。無限序列更能看出差別,像 generateSequence(1) { it + 1 }.takeWhile { it < 100 } 拿到 99 個元素就停,不會無限跑下去

stdlib 把 takeWhile 設計成「碰到不符就 break」,而不是「全跑完再切」。如果是 eager 全掃描,成本就跟 filter 一樣要掃完整個集合,短路省下的就是那些不必要的掃描

TDD 實作 myTakeWhile

Red:先寫測試

@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 個

Green:最小實作

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 就跳出迴圈,後面的元素完全不處理

Refactor:往 stdlib 的寫法靠近

for 迴圈加 break 就是最終形狀,沒什麼好再改的。後面「與 stdlib 原始碼比較」會直接拿原始碼來印證

TDD 實作 myDropWhile

Red:先寫測試

@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 筆,這裡五筆一筆都跑不掉,兩個函式的成本差別就攤開了

Green:最小實作

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)。一旦進入「保留」模式,後面每個元素都要加進結果,必須跑完整個集合

Refactor:往 stdlib 的寫法靠近

flag 寫法已經貼著 stdlib 走,差別只在 flag 的名字和語意方向,留到下一節對照原始碼時一起看

與 stdlib 原始碼比較

原始碼位置: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 / drop 家族總覽

兩篇下來,切片家族有六個主要成員。以「只能從頭走一遍」的角度看

函式 行為 判斷邊界的依據 能提前停嗎
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 會變成重點

沒有 takeLastWhiledropLastWhile 嗎?有,stdlib 有定義在 List<T> 上的版本,跟 takeLast / dropLast 一樣不提供 Iterable 多載。但使用頻率低很多,這裡就不特別實作了

dropWhile 我到現在還是沒用過

老實說,這個函式我沒有實際用過。以前寫 C# 的時候看到 SkipWhile 就覺得很不直覺,一直沒找到該用它的場合;換到 Kotlin 變成 dropWhile,感覺還是一樣

takeWhile 我想得出來要幹嘛:資料排過序,拿開頭符合條件的那一段,前面 log 的例子就很自然。但 dropWhile 反過來——「丟掉開頭符合條件的,剩下的全要」——腦袋要轉一下才知道結果長什麼樣,而且多數時候我要的其實是 filter,不是「只在開頭丟」

硬要想,大概是讀檔案時跳過開頭的註解列或標頭,從第一列真正的資料開始。但我自己沒這樣寫過,標頭固定一行的話 drop(1) 就解決了,註解列不固定的話我也是直接 filter

認得 API 比用不用得到重要,哪天碰到適合的場景至少知道有這個東西

小結

takeWhiledropWhile 是條件版的 take / drop。跟 filter 長得像但行為不同:filter 每個元素都要判斷,takeWhile / dropWhile 只在分界點之前判斷,過了就不再呼叫 predicate

下一篇是 chunked / windowed / zipWithNext,把集合切成固定大小的區塊或用滑動視窗掃描

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 22:take / takeLast / drop / dropLast — 頭尾切片
下一篇
Kotlin Lambda 從零開始 Day 24:chunked / windowed / zipWithNext — 區塊與滑動視窗
系列文
Kotlin Lambda 從零開始35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言