iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

Kotlin Lambda 從零開始系列 第 13

Kotlin Lambda 從零開始 Day 13:zip / unzip — 配對與拆分

  • 分享至 

  • xImage
  •  

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

這篇文章會用 TDD 手刻 myZipmyUnzip。zip 把兩個 List 拉鍊式合併成 Pair 的 List,unzip 把 Pair 的 List 拆回兩個 List

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
zip(other) Zip(other)
zip(other) { a, b -> } Zip(other, (a, b) => ...) 帶 transform
unzip() 無直接對應

zip 的概念

想像兩排拉鍊的齒,一左一右交錯扣合。zip 做的就是這件事:把兩個 List 對應位置的元素配對

names:  ["Alice", "Bob", "Charlie"]
ages:   [30,      25,    35]
         ↓ zip
result: [("Alice", 30), ("Bob", 25), ("Charlie", 35)]

如果兩個 List 長度不同,zip 取較短的那個長度,多出來的元素直接丟棄。不會拋例外,也不會補 null

誰 zip 誰:receiver 決定 first

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>)更難抓,寫反了編譯器完全不會出聲,要到查不到資料才發現

TDD 實作 myZip

Red:先寫測試

@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

Green:最小實作

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 函式只能有一個參數。這裡有兩個參數(othertransform),不符合 infix 的限制

Refactor:往 stdlib 的寫法靠近

兩個版本的 while 迴圈長得一模一樣,重複的邏輯可以合併:讓基本版直接委派給帶 transform 的版本

infix fun <T, R> Iterable<T>.myZip(other: Iterable<R>): List<Pair<T, R>> =
    myZip(other) { a, b -> a to b }

迴圈邏輯只剩一份,基本版縮成一行

TDD 實作 myUnzip

Red:先寫測試

@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,第三個測試驗證的就是這個行為

Green:最小實作

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 myFlattenIterable<Iterable<T>> 是同一種模式:用巢狀泛型限制可呼叫的目標

for ((first, second) in this) 在 for 迴圈裡直接解構。每個 Pair 拆成 first 和 second,分別丟進兩個 ArrayList

最後 return listT to listRto 把兩個 List 包成一個 Pair 回傳。外層再用 val (names, ages) 解構接住

Refactor:往 stdlib 的寫法靠近

實作的形狀已經沒什麼好改的,能加的最佳化只有一個:如果來源是 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
}

與 stdlib 原始碼比較

原始碼位置: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() 回傳 firstcomponent2() 回傳 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 函式的限制

到目前為止碰過兩個 infix 函式:tozip。整理一下 infix 的規則

  • 必須是成員函式或擴充函式
  • 只能有一個參數(不能有多參數、不能有 vararg)
  • 參數不能有預設值

所以帶 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)、集合操作(zipunionintersect)、DSL 構造(Kotest 的 shouldBeshouldContain 之類)。原則是「讀起來像自然語言,但別自創方言」

day 33 會完整講解 infix 函式的設計哲學和更多用法

小結

zip 用兩個 iterator 同時前進,把對應位置的元素配對。長度不同取較短的,多的直接丟。unzip 是反向操作,把 Pair 的 List 拆成兩個 List

底層技巧是 while (a.hasNext() && b.hasNext()),這是 zip 特有的模式。之前的函式都只遍歷一個集合,zip 是第一個同時走兩條路的

下一篇進入轉換篇的最後一個主題:groupBy 把 List 按條件分組成 Map<K, List<V>>,處理一對多的關係

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 12:associate / associateBy / associateWith — 轉成 Map
下一篇
Kotlin Lambda 從零開始 Day 14:groupBy / groupingBy — 分組操作
系列文
Kotlin Lambda 從零開始15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言