
這篇文章會用 TDD 手刻 myAny、myAll、myNone、myCount,搞懂短路邏輯(short-circuit)和空集合的行為
| Kotlin | C# LINQ | 備註 |
|---|---|---|
any() |
Any() |
無 predicate:集合是否非空 |
any { } |
Any(predicate) |
有 predicate:是否有任一符合 |
all { } |
All(predicate) |
是否全部符合 |
none() |
!Any() |
C# 沒有直接的 None |
none { } |
!Any(predicate) |
|
count() |
Count() |
|
count { } |
Count(predicate) |
C# 沒有 None(),通常寫 !Any()。Kotlin 把它獨立成一個函式,讀起來更直覺
@Test
fun `any on non-empty list returns true`() {
val numbers = listOf(1, 2, 3)
assertTrue(numbers.myAny())
}
@Test
fun `any on empty list returns false`() {
val empty = emptyList<Int>()
assertFalse(empty.myAny())
}
@Test
fun `any with predicate returns true when match exists`() {
val numbers = listOf(1, 2, 3, 4, 5)
assertTrue(numbers.myAny { it > 3 })
}
@Test
fun `any with predicate returns false when no match`() {
val numbers = listOf(1, 2, 3)
assertFalse(numbers.myAny { it > 10 })
}
fun <T> Iterable<T>.myAny(): Boolean {
return iterator().hasNext()
}
inline fun <T> Iterable<T>.myAny(predicate: (T) -> Boolean): Boolean {
for (element in this) {
if (predicate(element)) {
return true
}
}
return false
}
無 predicate 版只是檢查 iterator 有沒有下一個元素。帶 predicate 版找到第一個符合的就馬上 return true,不會繼續跑完剩下的元素,這就是短路(short-circuit)
這個行為在 day 06 其實已經出現過了,只是當時沒特別說明。myFirst(predicate) 找到第一個符合的元素就 return,myLast 對 List 倒著找也是找到就停,做的都是同一件事。差別在 myFirst 回傳元素本身,myAny 只回傳 boolean
順帶說明測試涵蓋。這組函式不會拋例外,找不到符合的元素就回傳 boolean,不像 day 06 的 first 會丟 NoSuchElementException,所以測試只需要 happy path 和空集合的邊界案例
帶 predicate 的版本已經是 stdlib 的形狀,沒有要再改的地方。無 predicate 版 stdlib 多了一個 is Collection 快速路徑,這招留到 myCount 的 Refactor 再講,那邊用得更明顯
@Test
fun `all returns true when all match`() {
val numbers = listOf(2, 4, 6)
assertTrue(numbers.myAll { it % 2 == 0 })
}
@Test
fun `all returns false when some dont match`() {
val numbers = listOf(1, 2, 3)
assertFalse(numbers.myAll { it % 2 == 0 })
}
@Test
fun `all on empty list returns true (vacuous truth)`() {
val empty = emptyList<Int>()
assertTrue(empty.myAll { it > 0 })
}
第三個測試是重點。空集合呼叫 all 會回傳 true,不管 predicate 是什麼。這叫做 vacuous truth(恆真空真):「所有元素都符合」在沒有元素時,邏輯上成立
聽起來反直覺,但想想看:你有沒有辦法找到一個不符合的元素?找不到,因為根本沒有元素。既然找不到反例,那「全部符合」就是 true
C# 的 All() 對空集合也回傳 true,邏輯一致
inline fun <T> Iterable<T>.myAll(predicate: (T) -> Boolean): Boolean {
for (element in this) {
if (!predicate(element)) {
return false
}
}
return true
}
基本上跟 myAny 是一樣的,只是 myAny 是找到符合的就 return true,myAll 是找到不符合的就 return false,都是短路
空集合的時候 for 迴圈不會執行,直接走到 return true。vacuous truth 不需要特別處理,自然就對了
不過短路是有方向的。myAll 只有在找到反例的時候才提早停;如果全部元素都符合 predicate,還是得一路跑到最後才能回傳 true。myAny 剛好相反,找到符合的就停,全都不符合時一樣要跑完整個集合才知道該回 false
換句話說,短路發生在答案已經確定的那一刻,不代表一定比較快。最差情況 any / all / none 通通都是 O(n)
跟 myAny 一樣,這個實作已經和 stdlib 一致,不需要再動
@Test
fun `none on empty list returns true`() {
val empty = emptyList<Int>()
assertTrue(empty.myNone())
}
@Test
fun `none on non-empty list returns false`() {
val numbers = listOf(1, 2, 3)
assertFalse(numbers.myNone())
}
@Test
fun `none with predicate returns true when no match`() {
val numbers = listOf(1, 2, 3)
assertTrue(numbers.myNone { it > 10 })
}
@Test
fun `none with predicate returns false when match exists`() {
val numbers = listOf(1, 2, 3)
assertFalse(numbers.myNone { it > 2 })
}
fun <T> Iterable<T>.myNone(): Boolean {
return !iterator().hasNext()
}
inline fun <T> Iterable<T>.myNone(predicate: (T) -> Boolean): Boolean {
for (element in this) {
if (predicate(element)) {
return false
}
}
return true
}
myNone(predicate) 可以理解成 !myAny(predicate)。但 stdlib 沒有直接呼叫 any 再取反,而是自己寫了獨立的邏輯。原因不在效能 —— any 本身就是 inline,寫成 !any(p) 展開後的 bytecode 跟獨立迴圈是一樣的。真正的原因是 _Collections.kt 整份檔案由 template 產生(路徑就在 commonMain/generated/),每個變體都各自展開一份
帶 predicate 版的獨立短路邏輯正是 stdlib 的寫法,這裡維持現狀就好。無 predicate 版跟 myAny 一樣,stdlib 有 is Collection 快速路徑,細節同樣留到 myCount 的 Refactor
@Test
fun `count returns size of collection`() {
val numbers = listOf(1, 2, 3, 4, 5)
assertEquals(5, numbers.myCount())
}
@Test
fun `count of empty list returns 0`() {
val empty = emptyList<Int>()
assertEquals(0, empty.myCount())
}
@Test
fun `count with predicate returns matching count`() {
val numbers = listOf(1, 2, 3, 4, 5)
assertEquals(2, numbers.myCount { it > 3 })
}
@Test
fun `count employees in Engineering`() {
assertEquals(3, employees.myCount { it.department == "Engineering" })
}
fun <T> Iterable<T>.myCount(): Int {
var count = 0
for (element in this) {
count++
}
return count
}
inline fun <T> Iterable<T>.myCount(predicate: (T) -> Boolean): Int {
var count = 0
for (element in this) {
if (predicate(element)) {
count++
}
}
return count
}
帶 predicate 版沒有短路的空間,因為必須掃完所有元素才知道有幾個符合。跟 any / all / none 不同
無 predicate 版可以加一個最佳化:用 is Collection 檢查 receiver 是不是 Collection(有 size 屬性的介面)。如果是,直接回傳 size,O(1)。不是的話才遍歷計數
fun <T> Iterable<T>.myCount(): Int {
if (this is Collection) {
return size
}
var count = 0
for (element in this) {
count++
}
return count
}
這跟 day 06 myFirst 的 is List 最佳化是同一招。stdlib 也這樣做
這三個函式在邏輯上互相等價,可以用 De Morgan 定律 (德摩根定律) 推導
none { p } ≡ !any { p } ≡ all { !p }
De Morgan 定律 (德摩根定律) 原本講的是否定跟 and / or 的互換:!(a && b) 等於 !a || !b,!(a || b) 等於 !a && !b。否定往括號裡面推的時候,&& 會變成 ||,|| 會變成 &&
把它推廣到集合上,any { p } 就是把每個元素的判斷用 || 串起來(有一個成立就成立),all { p } 則是用 && 串起來(全部成立才成立)。所以在前面加上否定,any 和 all 會互換,裡面的 predicate 也要跟著取反
用白話講更好記:「沒有人遲到」跟「每個人都沒遲到」是同一件事。前者是 none { 遲到 },後者是 all { !遲到 }
順帶一提,前面 myAll 的 vacuous truth 從這個角度看也很自然。空的 || 串是 false,空的 && 串是 true,所以空集合的 any() 回 false、all { } 回 true
用測試驗證
@Test
fun `none is equivalent to not any`() {
val numbers = listOf(1, 2, 3, 4, 5)
val predicate: (Int) -> Boolean = { it > 3 }
assertEquals(numbers.myNone(predicate), !numbers.myAny(predicate))
}
@Test
fun `none is equivalent to all with negated predicate`() {
val numbers = listOf(1, 2, 3, 4, 5)
val predicate: (Int) -> Boolean = { it > 3 }
assertEquals(numbers.myNone(predicate), numbers.myAll { !predicate(it) })
}
知道這層關係之後,選哪個函式就看語意。「沒有人超過 100 歲」用 none { age > 100 } 比 !any { age > 100 } 讀起來自然
any { ... } 的 return true 是邏輯短路。配上 inline,bytecode 層也跟著短路。Lambda 不會被包成 Function1 物件,return true 展開到呼叫端之後,變成直接跳出迴圈的跳轉指令
順帶一提,stdlib 把 any、all、none 寫成各自獨立的迴圈,並不是因為 none(p) = !any(p) 會少掉短路 —— any 是 inline,展開後短路照樣成立。那只是 template 產生程式碼的結果,讀原始碼時剛好也省得兜一圈
C# LINQ 的 Any(predicate) 也短路,實作是 foreach 裡找到符合的就直接 return true,跟 Kotlin 的邏輯一樣。差別在分配成本:C# 的短路只發生在邏輯層,省的是遍歷時間,有捕獲的 lambda 包成的 delegate 和從 IEnumerable 拿到的 enumerator 還是會在 heap 上分配(無捕獲的 lambda 會被 Roslyn 快取成 static 欄位,只配置一次);Kotlin 靠編譯期 inline,連這些物件都不會產生
原始碼位置:kotlin.collections 的 _Collections.kt
stdlib 的實作跟我們幾乎一樣。以 any 為例
public inline fun <T> Iterable<T>.any(predicate: (T) -> Boolean): Boolean {
for (element in this) if (predicate(element)) return true
return false
}
帶 predicate 的 all、none、count 也都是同樣的結構,沒有額外的花招。無 predicate 版就有一點差異:stdlib 的 any() / none() / count() 都先檢查 is Collection 走快速路徑,例如 any() 寫成 if (this is Collection) return !isEmpty(),跟我們在 myCount 的 Refactor 補上的是同一招
這組函式的共同特色是短路判斷,不過短路只發生在答案提早確定的時候。any 和 none 找到符合的就停,all 找到反例就停;反過來的情況(any 全不符合、all 全符合)還是得跑完整個集合。count 則是連提早停的機會都沒有,一定跑完
空集合的行為要注意:any() 回傳 false(沒東西),none() 回傳 true(確實沒東西),all { } 也回傳 true(vacuous truth)
下一篇繼續尋找系列:不只想知道「有沒有」,還想知道「在哪個位置」。day 08 來看 find / indexOfFirst / indexOfLast
同步刊登於 Blog
圖片來源:AI 產生