iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Software Development

Kotlin Lambda 從零開始系列 第 6

Kotlin Lambda 從零開始 Day 06:first / last / single — 尋找單一元素

  • 分享至 

  • xImage
  •  

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

這篇文章會用 TDD 手刻六組函式:myFirst / myFirstOrNullmyLast / myLastOrNullmySingle / mySingleOrNull。每組都有無參數版和帶 predicate 版,共 12 個函式

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
first() / first { } First() / First(predicate) 找不到都拋例外
firstOrNull() / firstOrNull { } FirstOrDefault() C# 回傳型別預設值,Kotlin 回傳 null
last() / last { } Last() / Last(predicate)
lastOrNull() / lastOrNull { } LastOrDefault()
single() / single { } Single() / Single(predicate) 超過一個也拋例外
singleOrNull() SingleOrDefault() Kotlin 多筆回傳 null;C# 多筆拋例外

最後一行要注意。singleOrNullSingleOrDefault 的行為不一樣,後面會細講

從「篩出一堆」到「找出一個」

day 05 的 filter 有個共同前提:不管符合條件的有幾個,都得把集合走完,再把結果裝進新的 List。這篇的六組函式不需要那個 List,它們只要一個元素

聽起來簡單,但這一改就冒出兩個 filter 不用回答的問題:什麼時候可以不用再往下走,以及走完了卻什麼都沒找到要怎麼辦。十二個函式的差別基本上都落在這裡

TDD 實作 myFirst / myFirstOrNull

Red:先寫測試

@Test
fun `first returns first element`() {
    val numbers = listOf(1, 2, 3)
    assertEquals(1, numbers.myFirst())
}

@Test
fun `first with predicate returns first matching element`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    assertEquals(4, numbers.myFirst { it > 3 })
}

@Test
fun `first on empty list throws NoSuchElementException`() {
    val empty = emptyList<Int>()
    assertThrows<NoSuchElementException> { empty.myFirst() }
}

@Test
fun `first with predicate no match throws NoSuchElementException`() {
    val numbers = listOf(1, 2, 3)
    assertThrows<NoSuchElementException> { numbers.myFirst { it > 10 } }
}

@Test
fun `first employee in Engineering`() {
    val result = employees.myFirst { it.department == "Engineering" }
    assertEquals("Alice", result.name)
}

@Test
fun `firstOrNull on empty list returns null`() {
    val empty = emptyList<Int>()
    assertNull(empty.myFirstOrNull())
}

@Test
fun `firstOrNull with predicate no match returns null`() {
    val numbers = listOf(1, 2, 3)
    assertNull(numbers.myFirstOrNull { it > 10 })
}

myFirst 有兩種用法:不帶參數就取第一個元素,帶 predicate 就取第一個符合條件的。找不到就拋 NoSuchElementExceptionmyFirstOrNull 找不到就回傳 null,所以測試從 assertThrows 換成 assertNull

Green:最小實作

fun <T> Iterable<T>.myFirst(): T {
    when (this) {
        is List -> {
            if (isEmpty()) {
                throw NoSuchElementException("List is empty.")
            }
            return this[0]
        }
        else -> {
            val iterator = iterator()
            if (!iterator.hasNext()) {
                throw NoSuchElementException("Collection is empty.")
            }
            return iterator.next()
        }
    }
}

inline fun <T> Iterable<T>.myFirst(predicate: (T) -> Boolean): T {
    for (element in this) {
        if (predicate(element)) {
            return element
        }
    }
    throw NoSuchElementException("Collection contains no element matching the predicate.")
}

注意三件事

第一,無參數版用了 when (this) 做型別檢查。如果 receiver 是 List,直接用 this[0] 取,不需要建立 iterator。如果是一般 Iterable,只能走 iterator

第二,else 分支是自己開 iterator,而不是寫成 for (element in this) return element。這跟效能無關,Kotlin 的 for 本來就是 iterator()hasNext()next() 這三個呼叫的語法糖,兩種寫法跑起來一樣

差別在讀起來的語意。for 講的是「走訪全部」,但這裡要做的是「只往前一步」。寫成 for (element in this) return element 會變成一個永遠只跑一圈的迴圈,讀的人得停下來確認它到底會不會跑第二圈。自己開 iterator 就直說了:先問 hasNext(),沒有就拋例外,有就 next() 拿走。對照帶 predicate 的版本更清楚,那個版本用 for,因為它真的可能要走完整個集合

這個選擇到 mySingle 會更明顯。它取完一個元素之後還要再問一次 hasNext(),確認沒有第二個,這種「取一個再回頭看一眼」的流程 for 表達不出來

第三,帶 predicate 版加了 inline,因為接收 Lambda。無參數版沒有 Lambda,不需要 inline

接著實作 myFirstOrNull,讓 Red 階段那兩個測試轉綠。差別只在回傳型別是 T?,找不到回傳 null 而不是拋例外

fun <T> Iterable<T>.myFirstOrNull(): T? {
    when (this) {
        is List -> return if (isEmpty()) null else this[0]
        else -> {
            val iterator = iterator()
            if (!iterator.hasNext()) {
                return null
            }
            return iterator.next()
        }
    }
}

inline fun <T> Iterable<T>.myFirstOrNull(predicate: (T) -> Boolean): T? {
    for (element in this) {
        if (predicate(element)) {
            return element
        }
    }
    return null
}

Refactor:往 stdlib 的寫法靠近

帶 predicate 的版本只有一個 if,stdlib 習慣把它壓成單行的 for

inline fun <T> Iterable<T>.myFirst(predicate: (T) -> Boolean): T {
    for (element in this) if (predicate(element)) return element
    throw NoSuchElementException("Collection contains no element matching the predicate.")
}

行為完全一樣,測試照樣全綠。這只是風格調整,stdlib 在這類短迴圈上幾乎都這樣寫,看習慣之後讀原始碼會快很多

不過我自己寫不會這樣壓。省下的那一行沒賺到什麼,之後想在 if 裡面加一行 log,還得先把大括號補回去。讀 stdlib 的時候認得就好

TDD 實作 myLast / myLastOrNull

Red:先寫測試

@Test
fun `last returns last element`() {
    val numbers = listOf(1, 2, 3)
    assertEquals(3, numbers.myLast())
}

@Test
fun `last with predicate returns last matching element`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    assertEquals(5, numbers.myLast { it > 3 })
}

@Test
fun `last on empty list throws NoSuchElementException`() {
    val empty = emptyList<Int>()
    assertThrows<NoSuchElementException> { empty.myLast() }
}

@Test
fun `last with predicate no match throws NoSuchElementException`() {
    val numbers = listOf(1, 2, 3)
    assertThrows<NoSuchElementException> { numbers.myLast { it > 10 } }
}

@Test
fun `last employee in Engineering`() {
    val result = employees.myLast { it.department == "Engineering" }
    assertEquals("Grace", result.name)
}

@Test
fun `lastOrNull on empty list returns null`() {
    val empty = emptyList<Int>()
    assertNull(empty.myLastOrNull())
}

@Test
fun `lastOrNull with predicate no match returns null`() {
    val numbers = listOf(1, 2, 3)
    assertNull(numbers.myLastOrNull { it > 10 })
}

測試跟 first 差不多,只是從後面找而已,myLast 找不到就拋 NoSuchElementExceptionmyLastOrNull 找不到就回傳 null

Green:最小實作

fun <T> Iterable<T>.myLast(): T {
    when (this) {
        is List -> {
            if (isEmpty()) {
                throw NoSuchElementException("List is empty.")
            }
            return this[lastIndex]
        }
        else -> {
            val iterator = iterator()
            if (!iterator.hasNext()) {
                throw NoSuchElementException("Collection is empty.")
            }
            var last = iterator.next()
            while (iterator.hasNext()) {
                last = iterator.next()
            }
            return last
        }
    }
}

myLast 的 List 版本用 this[lastIndex] 直接跳到最後一個,在 ArrayList 這類 RandomAccess 實作上是 O(1)(List 介面本身不保證,LinkedList 取 index 仍是 O(n))。但一般 Iterable 連 index 概念都沒有,只能用 while (iterator.hasNext()) 跑到底,O(n)

帶 predicate 的版本更有趣

inline fun <T> Iterable<T>.myLast(predicate: (T) -> Boolean): T {
    var last: T? = null
    var found = false
    for (element in this) {
        if (predicate(element)) {
            last = element
            found = true
        }
    }
    if (!found) {
        throw NoSuchElementException("Collection contains no element matching the predicate.")
    }
    @Suppress("UNCHECKED_CAST")
    return last as T
}

myFirst(predicate) 不同,myLast(predicate) 不能提前 return。它必須走訪整個集合,每次遇到符合條件的就更新 last,最後才知道哪個是最後一個

這裡出現了 @Suppress("UNCHECKED_CAST")。因為 last 宣告成 T? 才能初始化為 null,但我們用 found flag 確保回傳時一定有值,所以 as T 的強制轉型是安全的。stdlib 也用了同樣的手法

接著實作 myLastOrNull,讓 Red 階段那兩個測試轉綠

fun <T> Iterable<T>.myLastOrNull(): T? {
    when (this) {
        is List -> return if (isEmpty()) null else this[lastIndex]
        else -> {
            val iterator = iterator()
            if (!iterator.hasNext()) {
                return null
            }
            var last = iterator.next()
            while (iterator.hasNext()) {
                last = iterator.next()
            }
            return last
        }
    }
}

inline fun <T> Iterable<T>.myLastOrNull(predicate: (T) -> Boolean): T? {
    var last: T? = null
    for (element in this) {
        if (predicate(element)) {
            last = element
        }
    }
    return last
}

帶 predicate 的版本比 myLast(predicate) 還短。因為回傳型別本來就是 T?,「沒找到」直接用初始值 null 表達,found flag 跟 @Suppress 都不需要了

Refactor:往 stdlib 的寫法靠近

myLast(predicate) 還有一個 stdlib 有做、我們沒做的最佳化:對 List 可以從後面往前找,找到第一個符合的就 return,不用每次都掃完整個集合

inline fun <T> List<T>.myLast(predicate: (T) -> Boolean): T {
    val iterator = listIterator(size)
    while (iterator.hasPrevious()) {
        val element = iterator.previous()
        if (predicate(element)) {
            return element
        }
    }
    throw NoSuchElementException("List contains no element matching the predicate.")
}

listIterator(size) 會建立一個指在尾端的 iterator,hasPrevious() / previous() 往回走。這個版本宣告在 List<T> 上,比 Iterable<T> 更具體,呼叫端是 List 時編譯器會優先選它,這就是函式多載的解析規則在發揮作用

同一招對 myLastOrNull(predicate) 也成立,stdlib 一樣給了 List 的多載

inline fun <T> List<T>.myLastOrNull(predicate: (T) -> Boolean): T? {
    val iterator = listIterator(size)
    while (iterator.hasPrevious()) {
        val element = iterator.previous()
        if (predicate(element)) {
            return element
        }
    }
    return null
}

結構跟上面一模一樣,只差在找不到時回傳 null 而不是拋例外

至於兩個無參數版,不需要這樣改。它們的 is List 分支已經用 this[lastIndex] 直接跳到最後一個,倒著走反而多繞路。倒著找的意義是「從尾巴開始逐個試 predicate」,沒有 predicate 就沒有東西要試

順帶一提,listIterator(size) 不依賴隨機存取。LinkedListlistIterator(index) 會從比較近的那一端開始走,指定 size 就是直接從尾巴接手,之後每次 previous() 都是 O(1)。這跟前面 this[lastIndex] 那個 RandomAccess 的但書不一樣,倒著走這招在哪種 List 上都划算

最後有個容易忽略的差異:倒著找不只變快,predicate 的呼叫次數和順序都變了。Iterable 版每個元素都會呼叫一次 predicate,List 版找到就停。如果 predicate 帶副作用(順手計數、印 log 之類的),兩個版本的結果會不一樣。stdlib 敢這樣最佳化,前提是預期你傳進來的 predicate 是純函式,這也是寫 Lambda 時盡量別塞副作用的理由之一

TDD 實作 mySingle / mySingleOrNull

single 是這組裡最嚴格的。它不只要求「有」,還要求「剛好只有一個」

Red:先寫測試

@Test
fun `single returns the only element`() {
    val numbers = listOf(42)
    assertEquals(42, numbers.mySingle())
}

@Test
fun `single with predicate returns the only matching element`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    assertEquals(5, numbers.mySingle { it > 4 })
}

@Test
fun `single on empty list throws NoSuchElementException`() {
    val empty = emptyList<Int>()
    assertThrows<NoSuchElementException> { empty.mySingle() }
}

@Test
fun `single with predicate no match throws NoSuchElementException`() {
    val numbers = listOf(1, 2, 3)
    assertThrows<NoSuchElementException> { numbers.mySingle { it > 10 } }
}

@Test
fun `single on multiple elements throws IllegalArgumentException`() {
    val numbers = listOf(1, 2, 3)
    assertThrows<IllegalArgumentException> { numbers.mySingle() }
}

@Test
fun `single with predicate multiple matches throws IllegalArgumentException`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    assertThrows<IllegalArgumentException> { numbers.mySingle { it > 3 } }
}

@Test
fun `single employee earning over 90000`() {
    val result = employees.mySingle { it.salary > 90_000 }
    assertEquals("Grace", result.name)
}

@Test
fun `singleOrNull on multiple elements returns null`() {
    val numbers = listOf(1, 2, 3)
    assertNull(numbers.mySingleOrNull())
}

@Test
fun `singleOrNull with predicate no match returns null`() {
    val numbers = listOf(1, 2, 3)
    assertNull(numbers.mySingleOrNull { it > 10 })
}

@Test
fun `singleOrNull with predicate multiple matches returns null`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    assertNull(numbers.mySingleOrNull { it > 3 })
}

注意 mySingle 例外的型別:0 個匹配是 NoSuchElementException(找不到),2 個以上匹配是 IllegalArgumentException(不合法)。兩種錯誤原因不同,例外型別也不同

mySingleOrNull 的測試藏了一個語意陷阱:不只 0 個匹配回傳 null,2 個以上匹配也回傳 null,不拋例外。這跟 C# 的 SingleOrDefault 不一樣,先把期望寫進測試,等一下實作時再細講

Green:最小實作

fun <T> Iterable<T>.mySingle(): T {
    when (this) {
        is List -> {
            return when (size) {
                0 -> throw NoSuchElementException("List is empty.")
                1 -> this[0]
                else -> throw IllegalArgumentException("List has more than one element.")
            }
        }
        else -> {
            val iterator = iterator()
            if (!iterator.hasNext()) {
                throw NoSuchElementException("Collection is empty.")
            }
            val single = iterator.next()
            if (iterator.hasNext()) {
                throw IllegalArgumentException("Collection has more than one element.")
            }
            return single
        }
    }
}

帶 predicate 的版本需要掃過整個集合,一邊找一邊數

inline fun <T> Iterable<T>.mySingle(predicate: (T) -> Boolean): T {
    var single: T? = null
    var found = false
    for (element in this) {
        if (predicate(element)) {
            if (found) {
                throw IllegalArgumentException("Collection contains more than one matching element.")
            }
            single = element
            found = true
        }
    }
    if (!found) {
        throw NoSuchElementException("Collection contains no element matching the predicate.")
    }
    @Suppress("UNCHECKED_CAST")
    return single as T
}

第一次找到時設 found = true,第二次找到就直接拋 IllegalArgumentException。走訪完都沒找到就拋 NoSuchElementException

接著實作 mySingleOrNull,讓 Red 階段那三個測試轉綠,0 個和 2+ 個匹配都回傳 null

fun <T> Iterable<T>.mySingleOrNull(): T? {
    when (this) {
        is List -> return if (size == 1) this[0] else null
        else -> {
            val iterator = iterator()
            if (!iterator.hasNext()) {
                return null
            }
            val single = iterator.next()
            if (iterator.hasNext()) {
                return null
            }
            return single
        }
    }
}

inline fun <T> Iterable<T>.mySingleOrNull(predicate: (T) -> Boolean): T? {
    var single: T? = null
    var found = false
    for (element in this) {
        if (predicate(element)) {
            if (found) {
                return null
            }
            single = element
            found = true
        }
    }
    if (!found) {
        return null
    }
    return single
}

這就是前面說的語意陷阱。C# 的 SingleOrDefault 不一樣:在 C# 裡,集合有兩個以上匹配時會拋 InvalidOperationException,只有 0 個匹配才回傳 default(T)。Kotlin 比較寬容,兩種情況都給你 null,讓呼叫端自己決定怎麼處理。從 C# 轉過來的人很容易假設 singleOrNull 在多筆時會拋例外,這個假設是錯的

Refactor:往 stdlib 的寫法靠近

single 這組在 Green 階段寫完,結構就已經跟 stdlib 一致了:同樣的 when (this) 分支、同樣的 found flag。沒有要再動的地方,直接進下一節對照原始碼

OrNull 慣例 vs OrDefault 慣例

Kotlin 和 C# 在「找不到」的處理上走了不同路線

Kotlin 用 OrNull 後綴,回傳 T?。呼叫端被迫處理 null,因為 null safety 是語言的一部分。你不可能忘記處理,編譯器會提醒你

C# 用 OrDefault 後綴,回傳 default(T)。reference type 是 null,value type 是 0false。問題在於你沒辦法區分「真的找不到」跟「找到的值剛好是 0」。Kotlin 的 T? 不存在這種歧義

為什麼是兩條路線

這個差異有歷史脈絡

C# 1.0(2002)沒有區分「可空」和「不可空」的 reference type,任何 reference 都可能是 null,編譯器也不會擋。Nullable<T> 在 C# 2.0(2005)補上,但只給 value type 用,reference type 依然是「都可能是 null,你自己當心」。直到 C# 8.0(2019)才補上 nullable reference types,加上 string?string 的可空性區分,而且預設是關閉的,要靠專案層 <Nullable>enable</Nullable> 開啟

Kotlin 從設計之初(2011 年公開發表)就把 null safety 設計進型別系統。StringString? 是不同型別,呼叫 String? 的方法必須用 ?.!!firstOrNull() 回傳 T? 就是這個哲學的延續:讓「找不到」跟「找到了」在型別上明確分開,呼叫端逃不掉

OrDefault 是「拿到值就好,有歧義你自己處理」;OrNull 是「告訴你有沒有,編譯器幫你看著」。後者多了一道型別檢查的代價,換來「不可能忘記處理空值」的保證。Java 專案裡經典的 NullPointerException,在 Kotlin 專案裡少見很多,不是 Kotlin 開發者比較細心,是型別系統不讓你犯錯

與 stdlib 原始碼比較

原始碼位置:kotlin.collections_Collections.kt

stdlib 的 first() 跟我們的結構一樣,也有 is List 的最佳化分支。帶 predicate 的版本

public inline fun <T> Iterable<T>.first(predicate: (T) -> Boolean): T {
    for (element in this) if (predicate(element)) return element
    throw NoSuchElementException("Collection contains no element matching the predicate.")
}

跟我們 Refactor 後的版本一模一樣。lastsingle 系列也是如此,連 List 上倒著找的 last(predicate) 最佳化版都跟我們 Refactor 階段寫的相同,核心邏輯沒有差異

小結

first / last / single 可以想成「只要一個結果的 filter」。filter 掃完整個集合回傳一堆結果,而這幾個函式拿到答案就可以收工

但「可以收工」不代表每個都真的提前停。first 找到就停;single 得掃完才能確認沒有第二個;last 在一般 Iterable 上也非掃完不可,只有 List 多載能從尾巴倒著找、真正提前 return

寫完才會發現,六組函式的核心邏輯都不難,真正的變化只在兩個地方:什麼時候停、找不到怎麼辦

下一篇再換個角度:不是要拿到元素本身,而是想問「有沒有」。day 07 來看 any / all / none / count

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 05:filter / filterNot / filterIndexed — 最基本的篩選操作
下一篇
Kotlin Lambda 從零開始 Day 07:any / all / none / count — 集合的布林判斷與計數
系列文
Kotlin Lambda 從零開始7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言