
這篇文章會用 TDD 手刻 myGroupBy(含兩個多載版本),再介紹 groupingBy 的 Grouping 介面概念。這是轉換篇的最後一篇
| Kotlin | C# LINQ | 備註 |
|---|---|---|
groupBy { it.department } |
GroupBy(x => x.Department) |
|
groupBy(keySelector, valueTransform) |
GroupBy(keySelector, elementSelector) |
|
groupBy { } |
ToLookup() |
都保留完整分組;ToLookup 立即執行,GroupBy 延遲執行 |
groupingBy { }.eachCount() |
CountBy() (.NET 9+) |
舊寫法 GroupBy().Select(g => g.Count()) 仍會保留完整分組 |
day 12 的 associateBy 和這篇的 groupBy 都是 List → Map 的轉換,但行為完全不同
associateBy:一個 key 對應一個 value。key 重複時 last write wins,前面的被覆蓋
groupBy:一個 key 對應一個 List。key 重複時所有元素都收集起來,不會丟東西
// associateBy:key 重複 → 覆蓋
employees.associateBy { it.department }
// {"Engineering"=Grace, "Marketing"=Diana, "HR"=Frank}
// 只剩最後一個!
// groupBy:key 重複 → 收集
employees.groupBy { it.department }
// {"Engineering"=[Alice, Bob, Grace], "Marketing"=[Charlie, Diana], "HR"=[Eve, Frank]}
// 全部保留
選擇很簡單:確定 key 唯一就用 associateBy,key 會重複就用 groupBy
@Test
fun `groupBy numbers by even odd`() {
val numbers = listOf(1, 2, 3, 4, 5, 6)
val result = numbers.myGroupBy { if (it % 2 == 0) "even" else "odd" }
assertEquals(listOf(2, 4, 6), result["even"])
assertEquals(listOf(1, 3, 5), result["odd"])
}
@Test
fun `groupBy key selector`() {
val result = employees.myGroupBy { it.department }
assertEquals(3, result.size)
assertEquals(3, result["Engineering"]?.size)
assertEquals(2, result["Marketing"]?.size)
assertEquals(2, result["HR"]?.size)
}
@Test
fun `groupBy preserves order within groups`() {
val result = employees.myGroupBy { it.department }
val engineering = result["Engineering"]!!
assertEquals("Alice", engineering[0].name)
assertEquals("Bob", engineering[1].name)
assertEquals("Grace", engineering[2].name)
}
@Test
fun `groupBy with key and value transform`() {
val result = employees.myGroupBy(
keySelector = { it.department },
valueTransform = { it.name }
)
assertEquals(listOf("Alice", "Bob", "Grace"), result["Engineering"])
assertEquals(listOf("Charlie", "Diana"), result["Marketing"])
}
@Test
fun `groupBy empty list returns empty map`() {
val empty = emptyList<Employee>()
val result = empty.myGroupBy { it.department }
assertEquals(emptyMap<String, List<Employee>>(), result)
}
@Test
fun `groupBy all same key returns single group`() {
val numbers = listOf(1, 2, 3, 4)
val result = numbers.myGroupBy { "all" }
assertEquals(1, result.size)
assertEquals(listOf(1, 2, 3, 4), result["all"])
}
第三個測試驗證群組內的順序。groupBy 保持原始插入順序,先出現的元素在 List 前面
第四個測試用了帶 valueTransform 的版本。不是把整個 Employee 收集起來,而是只收集 name。回傳 Map<String, List<String>> 而不是 Map<String, List<Employee>>
倒數第二個測試是邊界案例:空 List 進去,回傳空 Map。最後一個測試是另一個邊界,所有元素都對應到同一個 key,結果只有一組,全部收進同一個 List。groupBy 沒有 key 衝突的問題(衝突本來就是它要處理的事),也不會對 null 拋例外,所以這兩個邊界就涵蓋了會出問題的情況
inline fun <T, K> Iterable<T>.myGroupBy(keySelector: (T) -> K): Map<K, List<T>> {
val result = LinkedHashMap<K, MutableList<T>>()
for (element in this) {
val key = keySelector(element)
val list = result.getOrPut(key) { ArrayList() }
list.add(element)
}
return result
}
跟 myAssociate 比,差別在 value 的型別。associate 的 value 是單一元素,groupBy 的 value 是 MutableList<T>
getOrPut 是 Map 的擴充函式:如果 key 已存在就回傳對應的 value,不存在就執行 Lambda 建立一個新值放進去再回傳。這裡 Lambda 是 { ArrayList() },所以第一次遇到某個 key 時會建立空的 ArrayList
有了 list 之後,list.add(element) 把元素加進去。因為 list 是從 map 裡拿出來的參考(reference),修改 list 就等於修改 map 裡的那個 list
帶 valueTransform 的版本差別只在 list.add(valueTransform(element))
inline fun <T, K, V> Iterable<T>.myGroupBy(
keySelector: (T) -> K,
valueTransform: (T) -> V
): Map<K, List<V>> {
val result = LinkedHashMap<K, MutableList<V>>()
for (element in this) {
val key = keySelector(element)
val list = result.getOrPut(key) { ArrayList() }
list.add(valueTransform(element))
}
return result
}
getOrPut + add 這個組合已經就是 stdlib 的核心邏輯,沒有可以再簡化的地方。stdlib 唯一多做的是把實作抽成 groupByTo,下一段直接看原始碼
原始碼位置:kotlin.collections 的 _Collections.kt、kotlin.collections 的 Grouping.kt
stdlib 的 groupBy 結構跟我們一樣,也用了 getOrPut
public inline fun <T, K> Iterable<T>.groupBy(
keySelector: (T) -> K
): Map<K, List<T>> {
return groupByTo(LinkedHashMap<K, MutableList<T>>(), keySelector)
}
還是那套 xxxTo 模式。groupByTo 裡面就是 getOrPut + add,跟我們的完全一致
stdlib 還有一個 groupingBy 函式,回傳的不是 Map 而是 Grouping<T, K> 物件
val grouping = employees.groupingBy { it.department }
Grouping 是一個中間介面,本身不做分組。它記住了原始集合和 keySelector,等你呼叫後面的聚合操作時才真正執行
介面本身小得有點出乎意料,只有兩個方法
public interface Grouping<T, out K> {
fun sourceIterator(): Iterator<T>
fun keyOf(element: T): K
}
sourceIterator() 交出原始集合的 Iterator,keyOf() 回答某個元素該歸到哪一組。groupingBy { } 做的事就是回傳一個匿名物件把這兩個方法實作掉,keySelector 被閉包捕捉進去。分組邏輯到這一步都還沒跑
可以接的操作有四個:eachCount()、fold()、reduce()、aggregate()
grouping.eachCount()
// {"Engineering"=3, "Marketing"=2, "HR"=2}
grouping.fold(0) { acc, emp -> acc + emp.salary }
// {"Engineering"=249000, "Marketing"=143000, "HR"=115000}
// reduce 跟 fold 像,但沒有初始值,用每組第一個元素當起點
grouping.reduce { _, acc, emp -> if (emp.salary > acc.salary) emp else acc }
// 每組薪水最高的人
// aggregate 最底層,自己決定每個 key 第一次出現(first 為 true)和後續累積的邏輯
grouping.aggregate { _, acc: Int?, emp, first ->
if (first) emp.salary else acc!! + emp.salary
}
// 等同上面的 fold,但起始值由你在 first 為 true 時決定
為什麼要多這一層?效率。如果你只想要每組的數量,groupBy { }.mapValues { it.value.size } 會先建立完整的 Map<K, List<T>>(把所有元素都收集起來),再遍歷一次算 size。groupingBy { }.eachCount() 只需要維護一個計數器,不用真的把元素收集為 List
不過對大多數場景來說,groupBy 已經夠用了。groupingBy 在資料量很大、而且你只需要聚合結果(不需要完整的分組列表)時才有明顯優勢
四個操作裡,aggregate() 是最底層的那個,另外三個都是它的專用版本:fold 和 reduce 把「這一組是不是第一次遇到」的判斷包起來,eachCount 則是把累積值固定成一個計數器
這篇不動手實作 myGroupingBy,原因是順序 —— fold 和 reduce 要到 day 17、day 18 才登場,在這裡手刻 Grouping.fold 等於提前把那兩篇的內容講完。等那邊的基礎有了,day 19 會用一整篇把 Grouping 介面連同四個操作一起手刻出來,包括 aggregate 那個「第一次遇到這個 key」的旗標要怎麼判斷才不會被 null 騙到
C# LINQ 走的是不一樣的路。GroupBy(...) 回傳 IEnumerable<IGrouping<K, T>>,每個 IGrouping 自己實作 IEnumerable<T>,要算數量 .Select(g => g.Count()),要 fold .Select(g => g.Aggregate(...))。LINQ 把「分組」跟「聚合」串在同一條 IEnumerable 管道上
但要注意一件常見的誤會:LINQ 的 GroupBy 雖然是 deferred execution,列舉的當下卻會把整個來源緩衝成一個 Lookup,每個 IGrouping 都持有那一組的全部元素。所以 GroupBy().Select(g => g.Count()) 一樣會先建好完整的分組、把每個元素都收進對應的組裡,再去數每組的 size。lazy 在這裡省不掉中間集合的記憶體
groupingBy { }.eachCount() 在這組對照裡比較省。它只維護每個 key 的計數器,不會把元素收集為 List;相較之下,GroupBy().Select(g => g.Count()) 仍會保留各組元素
那 groupBy 跟 groupingBy 的差別呢?groupBy 回傳完整的 Map<K, List<T>>,要算 count 得先把元素收集為 List 再算 size。groupingBy 把工作延後到聚合那一步,eachCount() 全程不建中間 List
C# 這邊補一下時間軸。早年只能用 GroupBy().Select(...Count()),那個寫法確實會先建好完整分組;.NET 9 之後 LINQ 補上了 CountBy 和 AggregateBy,employees.CountBy(x => x.Department) 就是 groupingBy { }.eachCount() 的直接對應,一樣只維護計數器。所以這組差異現在是「舊寫法 vs 新 API」,不是「Kotlin 有、C# 沒有」
轉換篇到這裡結束。從 day 10 到 day 14 走過的路線
map(一對一轉換) → flatMap(一對多展開) → associate(List→Map,key 唯一) → zip/unzip(兩個 List 配對) → groupBy(List→Map,key 重複收集為 List)
這些操作的共同模式:for 迴圈 + 某種容器(ArrayList 或 LinkedHashMap) + Lambda 決定轉換邏輯。結構都很單純,複雜度來自 Lambda 的簽名和回傳型別的變化
這部分一路手刻過的函式,全部列在這裡
| 函式 | 篇號 | 一句話用途 |
|---|---|---|
myMap |
day 10 | 每個元素套一次 transform,一對一轉成新 List |
myMapNotNull |
day 10 | transform 後過濾掉 null,剩下的收集為 List |
myMapIndexed |
day 10 | transform 多收一個 index 參數 |
myFlatMap |
day 11 | 每個元素展開成一個集合,再攤平成單層 List |
myFlatten |
day 11 | 把巢狀的 List<List<T>> 攤平成 List<T> |
myAssociate |
day 12 | 用 Lambda 產生 key-value pair,組成 Map |
myAssociateBy |
day 12 | 你選 key,value 是元素本身 |
myAssociateWith |
day 12 | 元素本身當 key,你選 value |
myZip |
day 13 | 兩個 List 逐位配對成 List<Pair<A, B>> |
myUnzip |
day 13 | List<Pair<A, B>> 拆回兩個 List |
myGroupBy |
day 14 | 同 key 的元素收集為 List,一對多分組 |
groupBy 和 associateBy 的差別只有一行:key 撞到時是覆蓋還是收集。associateBy 用 map[key] = value 直接蓋掉,groupBy 用 getOrPut(key) { ArrayList() } 拿到那個 key 的 List(第一次遇到就先建一個)再 add。同樣是 List → Map,一個丟資料一個留資料,選錯就是靜靜地少幾筆
groupingBy 則是另一個層次的東西。它不回傳 Map,回傳的是 Grouping 介面,把「怎麼分組」和「分完要做什麼」拆開,所以 eachCount() 可以只數數量、不建出中間的分組 List
轉換篇把「集合進、集合出」的各種變形走過一遍。下一篇進入排序篇,day 15 從 sorted / sortedBy / sortedByDescending 開始。Lambda 的簽名還是 (T) -> R?,但 R 這次多了一個上界 R : Comparable<R> —— 你選出來的鍵得自己知道怎麼比大小
同步刊登於 Blog
圖片來源:AI 產生