
這篇文章會用 TDD 手刻六組函式:myFirst / myFirstOrNull、myLast / myLastOrNull、mySingle / mySingleOrNull。每組都有無參數版和帶 predicate 版,共 12 個函式
| 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# 多筆拋例外 |
最後一行要注意。singleOrNull 跟 SingleOrDefault 的行為不一樣,後面會細講
day 05 的 filter 有個共同前提:不管符合條件的有幾個,都得把集合走完,再把結果裝進新的 List。這篇的六組函式不需要那個 List,它們只要一個元素
聽起來簡單,但這一改就冒出兩個 filter 不用回答的問題:什麼時候可以不用再往下走,以及走完了卻什麼都沒找到要怎麼辦。十二個函式的差別基本上都落在這裡
@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 就取第一個符合條件的。找不到就拋 NoSuchElementException;myFirstOrNull 找不到就回傳 null,所以測試從 assertThrows 換成 assertNull
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
}
帶 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 的時候認得就好
@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 找不到就拋 NoSuchElementException,myLastOrNull 找不到就回傳 null
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 都不需要了
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) 不依賴隨機存取。LinkedList 的 listIterator(index) 會從比較近的那一端開始走,指定 size 就是直接從尾巴接手,之後每次 previous() 都是 O(1)。這跟前面 this[lastIndex] 那個 RandomAccess 的但書不一樣,倒著走這招在哪種 List 上都划算
最後有個容易忽略的差異:倒著找不只變快,predicate 的呼叫次數和順序都變了。Iterable 版每個元素都會呼叫一次 predicate,List 版找到就停。如果 predicate 帶副作用(順手計數、印 log 之類的),兩個版本的結果會不一樣。stdlib 敢這樣最佳化,前提是預期你傳進來的 predicate 是純函式,這也是寫 Lambda 時盡量別塞副作用的理由之一
single 是這組裡最嚴格的。它不只要求「有」,還要求「剛好只有一個」
@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 不一樣,先把期望寫進測試,等一下實作時再細講
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 在多筆時會拋例外,這個假設是錯的
single 這組在 Green 階段寫完,結構就已經跟 stdlib 一致了:同樣的 when (this) 分支、同樣的 found flag。沒有要再動的地方,直接進下一節對照原始碼
Kotlin 和 C# 在「找不到」的處理上走了不同路線
Kotlin 用 OrNull 後綴,回傳 T?。呼叫端被迫處理 null,因為 null safety 是語言的一部分。你不可能忘記處理,編譯器會提醒你
C# 用 OrDefault 後綴,回傳 default(T)。reference type 是 null,value type 是 0 或 false。問題在於你沒辦法區分「真的找不到」跟「找到的值剛好是 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 設計進型別系統。String 跟 String? 是不同型別,呼叫 String? 的方法必須用 ?. 或 !!。firstOrNull() 回傳 T? 就是這個哲學的延續:讓「找不到」跟「找到了」在型別上明確分開,呼叫端逃不掉
OrDefault 是「拿到值就好,有歧義你自己處理」;OrNull 是「告訴你有沒有,編譯器幫你看著」。後者多了一道型別檢查的代價,換來「不可能忘記處理空值」的保證。Java 專案裡經典的 NullPointerException,在 Kotlin 專案裡少見很多,不是 Kotlin 開發者比較細心,是型別系統不讓你犯錯
原始碼位置: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 後的版本一模一樣。last、single 系列也是如此,連 List 上倒著找的 last(predicate) 最佳化版都跟我們 Refactor 階段寫的相同,核心邏輯沒有差異
first / last / single 可以想成「只要一個結果的 filter」。filter 掃完整個集合回傳一堆結果,而這幾個函式拿到答案就可以收工
但「可以收工」不代表每個都真的提前停。first 找到就停;single 得掃完才能確認沒有第二個;last 在一般 Iterable 上也非掃完不可,只有 List 多載能從尾巴倒著找、真正提前 return
寫完才會發現,六組函式的核心邏輯都不難,真正的變化只在兩個地方:什麼時候停、找不到怎麼辦
下一篇再換個角度:不是要拿到元素本身,而是想問「有沒有」。day 07 來看 any / all / none / count
同步刊登於 Blog
圖片來源:AI 產生