
這篇文章會用 TDD 手刻 myZip 和 myUnzip。zip 把兩個 List 拉鍊式合併成 Pair 的 List,unzip 把 Pair 的 List 拆回兩個 List
| Kotlin | C# LINQ | 備註 |
|---|---|---|
zip(other) |
Zip(other) |
|
zip(other) { a, b -> } |
Zip(other, (a, b) => ...) |
帶 transform |
unzip() |
無直接對應 |
想像兩排拉鍊的齒,一左一右交錯扣合。zip 做的就是這件事:把兩個 List 對應位置的元素配對
names: ["Alice", "Bob", "Charlie"]
ages: [30, 25, 35]
↓ zip
result: [("Alice", 30), ("Bob", 25), ("Charlie", 35)]
如果兩個 List 長度不同,zip 取較短的那個長度,多出來的元素直接丟棄。不會拋例外,也不會補 null
zip 兩邊不能對調。receiver(點號左邊那個)的元素會跑到 Pair 的 first,參數那邊才是 second
val names = listOf("Alice", "Bob", "Charlie")
val ages = listOf(30, 25, 35)
names zip ages // [(Alice, 30), (Bob, 25), (Charlie, 35)]
ages zip names // [(30, Alice), (25, Bob), (35, Charlie)]
這裡的 names zip ages 是中綴語法,因為 stdlib 的 zip 是 infix 函式,等一下實作時會回頭講
寫反了頂多型別對調,編譯器會攔下來。真正麻煩的是接上 toMap(),因為 Pair 的 first 就是 Map 的 key
(names zip ages).toMap() // Map<String, Int>,用名字查年齡
(ages zip names).toMap() // Map<Int, String>,用年齡查是誰
後者還藏了一個問題:年齡本來就會重複,而 toMap() 遇到重複的 key 會後面蓋掉前面。三個人裡只要有兩個同齡,Map 就只剩兩筆,資料默默變少
兩邊型別相同的時候(例如兩個 List<String>)更難抓,寫反了編譯器完全不會出聲,要到查不到資料才發現
@Test
fun `zip two lists into pairs`() {
val names = listOf("Alice", "Bob", "Charlie")
val ages = listOf(30, 25, 35)
val result = names.myZip(ages)
assertEquals(listOf("Alice" to 30, "Bob" to 25, "Charlie" to 35), result)
}
@Test
fun `zip with transform`() {
val names = listOf("Alice", "Bob")
val ages = listOf(30, 25)
val result = names.myZip(ages) { name, age -> "$name($age)" }
assertEquals(listOf("Alice(30)", "Bob(25)"), result)
}
@Test
fun `zip different lengths takes shorter`() {
val a = listOf(1, 2, 3, 4, 5)
val b = listOf("a", "b", "c")
val result = a.myZip(b)
assertEquals(3, result.size)
}
@Test
fun `zip with empty list returns empty`() {
val a = listOf(1, 2, 3)
val b = emptyList<String>()
val result = a.myZip(b)
assertEquals(emptyList<Pair<Int, String>>(), result)
}
兩個版本的 zip:基本版回傳 List<Pair<T, R>>,帶 transform 的版本回傳 List<V>
第三個測試驗證長度不同時取較短的行為。5 個元素 zip 3 個元素,結果是 3 個 Pair
infix fun <T, R> Iterable<T>.myZip(other: Iterable<R>): List<Pair<T, R>> {
val first = iterator()
val second = other.iterator()
val result = ArrayList<Pair<T, R>>()
while (first.hasNext() && second.hasNext()) {
result.add(first.next() to second.next())
}
return result
}
這是到目前為止第一個同時遍歷兩個集合的實作
for (element in this) 一次只能遍歷一個集合。要讓兩邊同步前進,得手動拿出兩個 iterator 來操作
while (first.hasNext() && second.hasNext()):只要任一邊耗盡就停下來。這就是「取較短長度」的實作方式。用 && 而不是 ||
注意 infix 這個修飾字,加在 fun 前面,意思是「這個函式允許用中綴呼叫」。開不開放是寫這個函式的人決定的,標了 infix,呼叫端才能把點號和括號都省掉,寫成 names zip ages
標了之後原本的寫法還在,names.myZip(ages) 一樣能用
day 12 提到的 to 也是 infix,zip 是第二個例子
帶 transform 的版本
inline fun <T, R, V> Iterable<T>.myZip(
other: Iterable<R>,
transform: (a: T, b: R) -> V
): List<V> {
val first = iterator()
val second = other.iterator()
val result = ArrayList<V>()
while (first.hasNext() && second.hasNext()) {
result.add(transform(first.next(), second.next()))
}
return result
}
差別只在 result.add 裡面放的是 transform(a, b) 還是 a to b。跟 map vs flatMap 的差異模式很像:一個直接配對,一個經過轉換
帶 transform 的版本沒加 infix,因為 infix 函式只能有一個參數。這裡有兩個參數(other 和 transform),不符合 infix 的限制
兩個版本的 while 迴圈長得一模一樣,重複的邏輯可以合併:讓基本版直接委派給帶 transform 的版本
infix fun <T, R> Iterable<T>.myZip(other: Iterable<R>): List<Pair<T, R>> =
myZip(other) { a, b -> a to b }
迴圈邏輯只剩一份,基本版縮成一行
@Test
fun `unzip pairs into two lists`() {
val pairs = listOf("Alice" to 30, "Bob" to 25, "Charlie" to 35)
val (names, ages) = pairs.myUnzip()
assertEquals(listOf("Alice", "Bob", "Charlie"), names)
assertEquals(listOf(30, 25, 35), ages)
}
@Test
fun `unzip single pair`() {
val pairs = listOf("only" to 1)
val (keys, values) = pairs.myUnzip()
assertEquals(listOf("only"), keys)
assertEquals(listOf(1), values)
}
@Test
fun `unzip empty list`() {
val pairs = emptyList<Pair<String, Int>>()
val (keys, values) = pairs.myUnzip()
assertEquals(emptyList<String>(), keys)
assertEquals(emptyList<Int>(), values)
}
val (names, ages) = pairs.myUnzip() 用了解構宣告。myUnzip() 回傳的是 Pair<List<T>, List<R>>,解構後第一個變數是所有 first 的 List,第二個是所有 second 的 List
這個順序跟 zip 剛好對得上:zip 把 receiver 放進 first,unzip 就把所有 first 湊回第一個 List。所以兩個長度一樣的 List,names zip ages 再 unzip 回來還是 names 在前。長度不同就回不去了,zip 那一步已經把尾巴截掉,unzip 也補不回來
unzip 不會拋例外:來源是空 List 就回傳兩個空 List,第三個測試驗證的就是這個行為
fun <T, R> Iterable<Pair<T, R>>.myUnzip(): Pair<List<T>, List<R>> {
val listT = ArrayList<T>()
val listR = ArrayList<R>()
for ((first, second) in this) {
listT.add(first)
listR.add(second)
}
return listT to listR
}
receiver 型別是 Iterable<Pair<T, R>>,只能在 Pair 的集合上呼叫。跟 day 11 myFlatten 的 Iterable<Iterable<T>> 是同一種模式:用巢狀泛型限制可呼叫的目標
for ((first, second) in this) 在 for 迴圈裡直接解構。每個 Pair 拆成 first 和 second,分別丟進兩個 ArrayList
最後 return listT to listR 用 to 把兩個 List 包成一個 Pair 回傳。外層再用 val (names, ages) 解構接住
實作的形狀已經沒什麼好改的,能加的最佳化只有一個:如果來源是 Collection,可以預先知道大小,讓 ArrayList 一次分配足夠的容量,省掉中途擴容
fun <T, R> Iterable<Pair<T, R>>.myUnzip(): Pair<List<T>, List<R>> {
val expectedSize = if (this is Collection<*>) size else 10
val listT = ArrayList<T>(expectedSize)
val listR = ArrayList<R>(expectedSize)
for ((first, second) in this) {
listT.add(first)
listR.add(second)
}
return listT to listR
}
原始碼位置:kotlin.collections 的 _Collections.kt
先看 zip
public infix fun <T, R> Iterable<T>.zip(other: Iterable<R>): List<Pair<T, R>> {
return zip(other) { t1, t2 -> t1 to t2 }
}
stdlib 的基本版直接呼叫帶 transform 的版本,transform 是 { t1, t2 -> t1 to t2 },跟我們 Refactor 之後的做法一樣
再看 unzip
public fun <T, R> Iterable<Pair<T, R>>.unzip(): Pair<List<T>, List<R>> {
val expectedSize = collectionSizeOrDefault(10)
val listT = ArrayList<T>(expectedSize)
val listR = ArrayList<R>(expectedSize)
for (pair in this) {
listT.add(pair.first)
listR.add(pair.second)
}
return listT to listR
}
collectionSizeOrDefault(10) 是 stdlib 的內部輔助函式,做的事跟我們的 if (this is Collection<*>) size else 10 一樣:是 Collection 就拿實際大小,不是就用預設值 10。剩下的差別只有 stdlib 在 for 迴圈裡用 pair.first / pair.second 而不是解構,行為完全相同
解構宣告(destructuring declaration)不是魔法,背後靠的是 componentN() 函式
val (a, b) = pair
// 編譯成
val a = pair.component1()
val b = pair.component2()
Pair 類別定義了 component1() 回傳 first,component2() 回傳 second
data class 會自動幫你產生 componentN 函式,按照建構子參數的順序
data class Employee(val id: Int, val name: String, ...)
val (id, name) = employee
// id = employee.component1() = employee.id
// name = employee.component2() = employee.name
所以在 for 迴圈裡寫 for ((first, second) in pairList) 能動,就是因為 Pair 有 component1 和 component2
到目前為止碰過兩個 infix 函式:to 和 zip。整理一下 infix 的規則
所以帶 transform 的 myZip 不能加 infix,因為有兩個參數。基本版的 myZip 可以,因為只有一個參數 other
中綴語法讓程式碼讀起來更像英文
val pairs = names zip ages // 中綴寫法
val pairs = names.myZip(ages) // 一般寫法
val pair = "key" to "value" // to 也是中綴
中綴函式不是 Kotlin 發明的。Haskell、Scala、F# 都有,語意都是「把二元運算子當成函式呼叫」。Kotlin 在這條路上選了中庸:infix 必須主動加、只能單參數、不能有預設值。對比 Scala 2 任何單參數方法都自動可中綴呼叫,社群裡難解的 DSL 也確實不少(Scala 3 後來補了 infix 軟關鍵字,未標記的字母數字方法用中綴呼叫會發警告)。Kotlin 一開始就要求主動標記,走的是同一個方向
實務上 Kotlin 的中綴函式集中在三類:Pair 建構(to)、集合操作(zip、union、intersect)、DSL 構造(Kotest 的 shouldBe、shouldContain 之類)。原則是「讀起來像自然語言,但別自創方言」
day 33 會完整講解 infix 函式的設計哲學和更多用法
zip 用兩個 iterator 同時前進,把對應位置的元素配對。長度不同取較短的,多的直接丟。unzip 是反向操作,把 Pair 的 List 拆成兩個 List
底層技巧是 while (a.hasNext() && b.hasNext()),這是 zip 特有的模式。之前的函式都只遍歷一個集合,zip 是第一個同時走兩條路的
下一篇進入轉換篇的最後一個主題:groupBy 把 List 按條件分組成 Map<K, List<V>>,處理一對多的關係
同步刊登於 Blog
圖片來源:AI 產生