iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Software Development

Kotlin Lambda 從零開始系列 第 9

Kotlin Lambda 從零開始 Day 09:contains / elementAt — 存在性與直接存取

  • 分享至 

  • xImage
  •  

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

這篇文章會用 TDD 手刻 myContainsmyElementAtmyElementAtOrNullmyElementAtOrElse,收束篩選與尋找篇

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
contains(element) Contains(element)
element in list list.Contains(element) Kotlin 的 in 是 operator 語法糖
elementAt(index) ElementAt(index)
elementAtOrNull(index) ElementAtOrDefault(index)
elementAtOrElse(index) { } 無直接對應 Lambda 算預設值,C# 沒有這招

TDD 實作 myContains

Red:先寫測試

@Test
fun `contains returns true when element exists`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    assertTrue(numbers.myContains(3))
}

@Test
fun `contains returns false when element not found`() {
    val numbers = listOf(1, 2, 3)
    assertFalse(numbers.myContains(99))
}

@Test
fun `contains on empty list returns false`() {
    val empty = emptyList<Int>()
    assertFalse(empty.myContains(1))
}

@Test
fun `contains with string`() {
    val words = listOf("hello", "world")
    assertTrue(words.myContains("hello"))
    assertFalse(words.myContains("foo"))
}

測試的範圍說明一下。contains 沒有例外,找不到就回傳 false,所以涵蓋找得到、找不到、空集合就夠了,不用硬湊例外案例

換成共用的 employees 資料也一樣。contains 比的是元素本身,Employee 是 data class,所以 id、name、department、salary、age 全部相同才算同一筆

val alice = employees[0]
employees.myContains(alice) // true

employees.myContains(Employee(99, "Zoe", "Engineering", 90000, 29)) // false

Green:最小實作

fun <T> Iterable<T>.myContains(element: T): Boolean {
    if (this is Collection) {
        return contains(element)
    }
    for (item in this) {
        if (item == element) {
            return true
        }
    }
    return false
}

先用 is Collection 檢查。Collection 介面本身就有 contains 方法,委派過去能讓具體實作用自己的方式比對。HashSetcontains 是 O(1),ArrayList 還是 O(n) 線性掃描,但有最佳化的實作就能直接吃到,不要自己遍歷

一般 Iterable 就只能 for 迴圈逐一比對了

Refactor:往 stdlib 的寫法靠近

day 08 已經有 myIndexOf 了,「存不存在」這個問題可以直接問它

fun <T> Iterable<T>.myContains(element: T): Boolean {
    if (this is Collection) {
        return contains(element)
    }
    return myIndexOf(element) >= 0
}

少一個自己寫的迴圈,多一層既有函式的重用。等一下看 stdlib 原始碼,會發現它就是這樣寫的

in 運算子的真相

Kotlin 裡寫 3 in numbers 會編譯成 numbers.contains(3)。這個 incontains 的 operator 語法糖

要讓自己的函式支援 in 語法,除了加上 operator 修飾,函式名稱還必須精確叫 contains

operator fun <T> Iterable<T>.contains(element: T): Boolean { ... }

我們的 myContains 不能寫成 3 in list 來呼叫,而且問題不是出在沒加 operator。函式名稱不是 contains,就算硬加上 operator,編譯器也會直接丟出 'operator' modifier is not applicable to function: illegal function name. 拒絕你

// 編譯不過:名字不是 contains
operator fun <T> Iterable<T>.myContains(element: T): Boolean { ... }

// 呼叫端也寫不出來,in 只認 contains,不會去找 myContains
3 in numbers

這也是為什麼本篇維持 myContains 這個名字、不加 operator,完整機制留到 day 33 再講

簡單預告一下:Kotlin 的 operator 關鍵字讓特定命名的函式可以用運算子呼叫。contains 對應 inplus 對應 +get 對應 []。命名要精確對上,少一個字母都不行

TDD 實作 myElementAt / myElementAtOrNull / myElementAtOrElse

Red:先寫測試

@Test
fun `elementAt returns element at index`() {
    val numbers = listOf(10, 20, 30, 40, 50)
    assertEquals(30, numbers.myElementAt(2))
}

@Test
fun `elementAt throws on negative index`() {
    val numbers = listOf(1, 2, 3)
    assertThrows<IndexOutOfBoundsException> { numbers.myElementAt(-1) }
}

@Test
fun `elementAt throws on out of bounds`() {
    val numbers = listOf(1, 2, 3)
    assertThrows<IndexOutOfBoundsException> { numbers.myElementAt(10) }
}

myElementAtOrNull 的測試把 assertThrows 換成 assertNullmyElementAtOrElse 則多測 Lambda 是否收到 index 作為參數

@Test
fun `elementAtOrNull returns null on out of bounds`() {
    val numbers = listOf(1, 2, 3)
    assertNull(numbers.myElementAtOrNull(10))
    assertNull(numbers.myElementAtOrNull(-1))
}

@Test
fun `elementAtOrElse passes index to lambda`() {
    val numbers = listOf(1, 2, 3)
    assertEquals(20, numbers.myElementAtOrElse(10) { index -> index * 2 })
}

employees 的例子更接近日常查表情境

employees.myElementAt(2).name        // Charlie
employees.myElementAtOrNull(99)      // null
employees.myElementAtOrElse(99) {
    Employee(-1, "Unknown", "N/A", 0, 0)
}

Green:最小實作

fun <T> Iterable<T>.myElementAt(index: Int): T {
    if (this is List) {
        return get(index)
    }
    if (index < 0) {
        throw IndexOutOfBoundsException("Index $index is out of bounds.")
    }
    var count = 0
    for (element in this) {
        if (count == index) {
            return element
        }
        count++
    }
    throw IndexOutOfBoundsException("Index $index is out of bounds of collection of size $count.")
}

is List 分支直接呼叫 get(index),在 ArrayList 這類 RandomAccess 實作上是 O(1)(LinkedList 仍要走過去,是 O(n))。一般 Iterable 則一定要跑迴圈數到那個位置

myElementAtOrNull 把拋例外改成回傳 null

fun <T> Iterable<T>.myElementAtOrNull(index: Int): T? {
    if (this is List) {
        return if (index in 0..lastIndex) get(index) else null
    }
    if (index < 0) {
        return null
    }
    var count = 0
    for (element in this) {
        if (count == index) {
            return element
        }
        count++
    }
    return null
}

注意 is List 分支不能直接呼叫 get(index),因為 index 超出範圍會拋例外。要先檢查 index in 0..lastIndex

myElementAtOrElse 更有意思

inline fun <T> Iterable<T>.myElementAtOrElse(
    index: Int,
    defaultValue: (Int) -> T
): T {
    if (this is List) {
        return if (index in 0..lastIndex) get(index) else defaultValue(index)
    }
    if (index < 0) {
        return defaultValue(index)
    }
    var count = 0
    for (element in this) {
        if (count == index) {
            return element
        }
        count++
    }
    return defaultValue(index)
}

defaultValue 是一個 Lambda,接收 index 作為參數。超出範圍時不是回傳固定值,而是讓呼叫端自己決定怎麼算出預設值。比 OrNull 更有彈性

// 超出範圍就用 index 自己算
numbers.myElementAtOrElse(10) { index -> index * 2 }

C# 沒有對應的 ElementAtOrElse。想做類似的事得自己寫 extension method 或用三元運算子

Lambda 預設值 vs 固定預設值,差別在 lazy 計算。固定值 getOrDefault(expensiveCall()) 不管要不要都先算好;Lambda 版只在落到 default 那條路時才呼叫,適合預設值很貴(資料庫查詢、IO、複雜運算)的情境

// 預設值當參數傳入,呼叫前就先求值:不管 key 存不存在,DB 查詢都會執行
map.getOrDefault(key, queryDatabase())

// Lambda 版只在缺值時才呼叫
map.getOrElse(key) { queryDatabase() }

函式式語言傳統都偏好 lambda 版的 default。stdlib 把這個模式做得很廣,getOrElsegetOrPutcomputeIfAbsent 都是同樣的做法,把「需要時才算」做進 API 設計裡

Refactor:往 stdlib 的寫法靠近

回頭看三個函式,迴圈的部分長得一模一樣。最通用的是 myElementAtOrElse,越界時要做什麼完全由 Lambda 決定,那 myElementAt 就可以改成委派給它,越界的 Lambda 直接拋例外

fun <T> Iterable<T>.myElementAt(index: Int): T {
    if (this is List) {
        return get(index)
    }
    return myElementAtOrElse(index) { throw IndexOutOfBoundsException("Index $index is out of bounds.") }
}

stdlib 的 Iterable<T>.elementAt 就是這樣做的:自己只留 List 的快速路徑,剩下的交給 elementAtOrElse,連例外都是在 Lambda 裡拋的

與 stdlib 原始碼比較

原始碼位置:kotlin.collections_Collections.kt

stdlib 的 elementAt 跟我們結構相同,都有 is List 的最佳化分支

elementAtOrElse 有個小差異。stdlib 定義在 Iterable<T> 上的版本是一般函式,沒有 inline;只有 List<T> 的多載版本標了 @InlineOnly inline,因為 List 版只是一行 index 檢查加 get,inline 掉最划算。我們手刻的 Iterable 版直接加了 inline,比 stdlib 激進一點,行為則一致

contains 在 stdlib 有兩個版本。Collection<T> 介面本身就有 contains 成員方法,由各個實作類別自己決定怎麼比對;stdlib 另外提供 Iterable<T> 的擴充版本,給不是 Collection 的一般 Iterable 用

public operator fun <T> Iterable<T>.contains(element: T): Boolean {
    if (this is Collection) return contains(element)
    return indexOf(element) >= 0
}

跟我們 Refactor 後的 myContains 是同一個結構:先委派給 Collection 的成員方法,不是 Collection 就問 indexOf。注意它有 operator 修飾,名字也叫 contains,所以一般 Iterable 寫 3 in iterable 才有得編譯

operator 預覽

Kotlin 支援的 operator 函式不少,這裡列幾個常見的

運算子 對應函式 範例
in contains 3 in list
+ plus list + element
- minus list - element
[] get / set list[0]
() invoke function()

day 26 會用到 plusminus 做集合運算,day 33 會完整講解 operator 多載的機制

第二部分回顧

篩選與尋找篇到這裡結束了。從 day 05 到 day 09,走過的路線是

filter 系列(篩出多個) → first / last / single(找一個) → any / all / none / count(問有沒有) → find / indexOf(找位置) → contains / elementAt(存在性和直接取值)

這些操作有個共同模式:for 迴圈 + 條件判斷 + 決定什麼時候停。anyfirstindexOfcontains 一找到就回傳,後面的元素連看都不用看;filtercountsinglelast 則非得跑完整個集合才知道答案。結構都很單純,差別只在停的時機和回傳值

本部分 API 速查

函式 篇號 用途
myFilterNot day 05 踢掉符合條件的元素,filter 的鏡像
myFilterIndexed day 05 篩選時連 index 一起判斷
myFirst / myFirstOrNull day 06 取第一個符合的元素,找不到拋例外或回傳 null
myLast / myLastOrNull day 06 取最後一個符合的元素
mySingle / mySingleOrNull day 06 確保恰好一個符合,零個或多個都算失敗
myAny day 07 有沒有任何元素符合
myAll day 07 是不是全部符合
myNone day 07 是不是全部都不符合
myCount day 07 數有幾個符合
myFind / myFindLast day 08 找第一個 / 最後一個符合的元素,找不到回傳 null
myIndexOfFirst / myIndexOfLast day 08 找符合條件的位置,找不到回傳 -1
myIndexOf / myLastIndexOf day 08 不帶 predicate,用 == 找元素位置
myContains day 09 元素存不存在
myElementAt / myElementAtOrNull / myElementAtOrElse day 09 用 index 直接取值,三種越界處理策略

myFilter 是 day 03 手刻、day 04 加上 inline 的,day 05 只做回顧,所以沒列進來

小結

in 不是關鍵字魔法,寫 3 in list 編譯出來就是 list.contains(3),而且函式名稱少一個字母都不行。day 33 會把整套 operator 的規則講完

elementAt 的三個變體則是 Kotlin 處理「越界」的一貫做法:拋例外、回傳 null、用 Lambda 算預設值。同一個問題給三種強度的答案,讓 caller 自己選。這個模式在 first / firstOrNull 上已經看過一次,後面還會一直出現

下一篇進入轉換篇。Lambda 的簽名從 (T) -> Boolean(判斷)變成 (T) -> R(轉換),複雜度會稍微上升。day 10 從 map / mapNotNull / mapIndexed 開始

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 08:find / indexOfFirst / indexOfLast — 位置導向尋找
下一篇
Kotlin Lambda 從零開始 Day 10:map / mapNotNull / mapIndexed — 最核心的轉換操作
系列文
Kotlin Lambda 從零開始15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言