
這篇文章會用 TDD 手刻 myFlatMap 和 myFlatten。當 map 的 transform 回傳的是一個集合而不是單一元素時,就需要 flatMap 來把巢狀結構攤平
| Kotlin | C# LINQ | 備註 |
|---|---|---|
flatMap { } |
SelectMany() |
|
flatten() |
SelectMany(x => x) |
C# 用 SelectMany 的恆等版本 |
day 10 的 map 做的是一對一轉換:一個輸入元素產出一個輸出元素。但有些場景是一對多的
假設有個部門清單,每個部門底下有多個成員
data class Department(val name: String, val members: List<String>)
val departments = listOf(
Department("Engineering", listOf("Alice", "Bob")),
Department("Marketing", listOf("Charlie"))
)
如果用 map 取出所有成員
val nested = departments.map { it.members }
// nested 的型別是 List<List<String>>
// [["Alice", "Bob"], ["Charlie"]]
拿到的是巢狀 List。但你想要的是一個扁平的 List<String>:["Alice", "Bob", "Charlie"]
flatMap 就是做這件事。先 map,再 flatten。或者說,在 map 的過程中直接把內層集合的元素攤到同一層
用圖來看
departments: [Eng, Mkt]
↓ map { it.members }
[["Alice", "Bob"], ["Charlie"]]
↓ flatten
["Alice", "Bob", "Charlie"]
flatMap 把中間那步和最後那步合併了
@Test
fun `flatMap expands each element to list`() {
val numbers = listOf(1, 2, 3)
val result = numbers.myFlatMap { listOf(it, it * 10) }
assertEquals(listOf(1, 10, 2, 20, 3, 30), result)
}
@Test
fun `flatMap departments to members`() {
data class Department(val name: String, val members: List<String>)
val departments = listOf(
Department("Engineering", listOf("Alice", "Bob")),
Department("Marketing", listOf("Charlie"))
)
val allMembers = departments.myFlatMap { it.members }
assertEquals(listOf("Alice", "Bob", "Charlie"), allMembers)
}
@Test
fun `flatMap with empty inner lists`() {
val lists = listOf(listOf(1, 2), emptyList(), listOf(3))
val result = lists.myFlatMap { it }
assertEquals(listOf(1, 2, 3), result)
}
@Test
fun `flatMap empty list returns empty`() {
val empty = emptyList<Int>()
val result = empty.myFlatMap { listOf(it, it) }
assertEquals(emptyList<Int>(), result)
}
@Test
fun `flatMap with orEmpty handles null inner lists`() {
val maybeLists: List<List<Int>?> = listOf(listOf(1, 2), null, listOf(3))
val result = maybeLists.myFlatMap { it.orEmpty() }
assertEquals(listOf(1, 2, 3), result)
}
第一個測試把每個數字展開成兩個元素(原始值和十倍值)。1 變成 [1, 10],2 變成 [2, 20],3 變成 [3, 30],最後攤平成 [1, 10, 2, 20, 3, 30]
第三個測試裡 { it } 就是恆等函式,transform 不做轉換直接回傳。這時候 flatMap 退化成 flatten
最後一個測試處理內層集合可能是 null 的情況。transform 必須回傳 Iterable<R>,不能回傳 null,所以用 orEmpty() 把 null 轉成空集合。這個場景後面〈flatMap 在 nullable 處理中的妙用〉會再展開
inline fun <T, R> Iterable<T>.myFlatMap(transform: (T) -> Iterable<R>): List<R> {
val result = ArrayList<R>()
for (element in this) {
for (item in transform(element)) {
result.add(item)
}
}
return result
}
雙層 for 迴圈。外層遍歷原始集合,對每個元素呼叫 transform 得到一個 Iterable<R>。內層遍歷這個結果,把每個 item 加入最終的 result
跟 myMap 比較
// myMap
for (element in this) {
result.add(transform(element)) // transform 回傳單一值
}
// myFlatMap
for (element in this) {
for (item in transform(element)) { // transform 回傳一個集合
result.add(item) // 逐一攤平加入
}
}
map 的 transform 回傳 R,flatMap 的 transform 回傳 Iterable<R>。這是唯一的差別
內層迴圈逐一 add,其實可以一行 addAll 解決
inline fun <T, R> Iterable<T>.myFlatMap(transform: (T) -> Iterable<R>): List<R> {
val result = ArrayList<R>()
for (element in this) {
result.addAll(transform(element))
}
return result
}
addAll 接收 Iterable,把整個集合的元素批次加進去。語意跟雙層迴圈完全一樣,但意圖更清楚:「把 transform 的結果整批倒進 result」
@Test
fun `flatten nested lists`() {
val nested = listOf(listOf(1, 2), listOf(3, 4), listOf(5))
val result = nested.myFlatten()
assertEquals(listOf(1, 2, 3, 4, 5), result)
}
@Test
fun `flatten with empty inner lists`() {
val nested = listOf(listOf(1), emptyList(), listOf(2, 3))
val result = nested.myFlatten()
assertEquals(listOf(1, 2, 3), result)
}
@Test
fun `flatten empty outer list`() {
val nested = emptyList<List<Int>>()
val result = nested.myFlatten()
assertEquals(emptyList<Int>(), result)
}
@Test
fun `flatten single inner list`() {
val nested = listOf(listOf(1, 2, 3))
val result = nested.myFlatten()
assertEquals(listOf(1, 2, 3), result)
}
這裡沒有 null 測試,因為寫不出來:receiver 限定 Iterable<Iterable<T>>,內層如果是 List<Int>? 就對不上型別,編譯器直接擋掉。null 的問題在進入 flatten 之前就要處理好
fun <T> Iterable<Iterable<T>>.myFlatten(): List<T> {
val result = ArrayList<T>()
for (inner in this) {
for (element in inner) {
result.add(element)
}
}
return result
}
注意函式簽名:Iterable<Iterable<T>>.myFlatten()。receiver 是巢狀的 Iterable<Iterable<T>>,不是普通的 Iterable<T>
這是第一次在 receiver 型別上使用巢狀泛型。意思是「這個函式只能在『集合的集合』上呼叫」。你沒辦法在 listOf(1, 2, 3) 上呼叫 myFlatten(),編譯器會出現錯誤,因為 List<Int> 不符合 Iterable<Iterable<T>> 的型別約束
另外 myFlatten 沒有 inline,因為它不接收 Lambda。沒有 FunctionN 物件的問題,不需要 inline
myFlatten 本質上就是 myFlatMap { it }。transform 是恆等函式的 flatMap
跟 myFlatMap 一樣,內層迴圈換成 addAll
fun <T> Iterable<Iterable<T>>.myFlatten(): List<T> {
val result = ArrayList<T>()
for (inner in this) {
result.addAll(inner)
}
return result
}
原始碼位置:kotlin.collections 的 _Collections.kt
先看 flatMap
public inline fun <T, R> Iterable<T>.flatMap(
transform: (T) -> Iterable<R>
): List<R> {
return flatMapTo(ArrayList<R>(), transform)
}
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.flatMapTo(
destination: C, transform: (T) -> Iterable<R>
): C {
for (element in this) {
val list = transform(element)
destination.addAll(list)
}
return destination
}
stdlib 用了 addAll 而不是內層 for 迴圈,跟我們 Refactor 之後的版本一樣。addAll 在某些集合實作上可能有批次最佳化。還是同一套 xxxTo 模式
再看 flatten
public fun <T> Iterable<Iterable<T>>.flatten(): List<T> {
val result = ArrayList<T>()
for (element in this) {
result.addAll(element)
}
return result
}
跟 myFlatten 的 Refactor 版本一模一樣:迴圈加 addAll。注意 flatten 沒有 xxxTo 變體也沒有 inline,因為它不接收 Lambda,沒有什麼好客製的
可以這樣理解
// 這兩行等價
list.flatMap { transform(it) }
list.map { transform(it) }.flatten()
flatMap = map + flatten。先把每個元素轉換成一個集合(map),再把所有集合攤成一層(flatten)
那為什麼不直接用 map + flatten 就好?因為效率。map 會先建立一個中間的 List<List<R>>,flatten 再建立最終的 List<R>。兩次記憶體分配。flatMap 一次迴圈就搞定了,只有一個 ArrayList
實務上的巢狀集合常常帶著 null。例如訂單的明細欄位可能根本沒填
data class Order(val id: Int, val items: List<String>?)
val orders = listOf(
Order(1, listOf("apple", "banana")),
Order(2, null),
Order(3, listOf("cherry"))
)
直接寫 orders.flatMap { it.items } 編譯不會過,因為 transform 回傳的是 List<String>?,不符合 (T) -> Iterable<R> 的簽名。前面測試用過的 orEmpty() 就是解法
val allItems = orders.flatMap { it.items.orEmpty() }
// ["apple", "banana", "cherry"]
orEmpty() 把 null 轉成空集合,空集合攤平之後什麼都不留,null 就這樣安靜地消失了
另一個搭檔是 listOfNotNull。它接收多個可能為 null 的引數,只保留非 null 的那些
fun loadTagsFromCache(): List<String>? = null // 快取沒中
fun loadTagsFromDisk(): List<String>? = listOf("kotlin", "lambda") // 磁碟有資料
val allTags = listOfNotNull(loadTagsFromCache(), loadTagsFromDisk()).flatten()
// ["kotlin", "lambda"]
兩個資料來源都可能拿不到東西。listOfNotNull 先把「拿得到的集合」收進一個 List,flatten 再攤成一層。不用寫任何 if null 判斷
C# 這邊通常得自己寫 ?? Enumerable.Empty<string>() 或先 Where(x => x != null) 再 SelectMany。Kotlin 的 orEmpty 和 listOfNotNull 把這些樣板省掉了
回到 flatMap 的核心:把 Iterable<T> 接上 T → Iterable<R> 的轉換,得到 Iterable<R>。在函式式語言這個操作有個正式名字,叫 monadic bind(寫成 >>=)
monad 中文譯作「單子」。bind 沒有通行的中文譯名,講 bind 就好。單子指的是「一個包著值的容器型別,加上兩個操作」
unit(也叫 return):把普通的值 T 包成容器 M<T>。listOf(x) 就是 Iterable 的 unitbind:簽名是 (M<T>, (T) -> M<R>) -> M<R>。給它一個容器和一個「會回傳容器的函式」,它負責把容器拆開、套上函式,再把結果攤平成同一層第二個就是 flatMap 本人。Iterable<T> 對上 M<T>,transform: (T) -> Iterable<R> 對上 (T) -> M<R>,回傳的 List<R> 對上 M<R>
重點在「攤平成同一層」。少了這步,每套一次函式就多包一層,拿到的是 M<M<R>>,要接下一個轉換得先拆一層。bind 每步都把層數壓回去,才有辦法一路串下去
把 Iterable 換成其他「容器型別」,結構完全一樣
x?.let { ... } 是 nullable 的 bindCompletableFuture:future.thenCompose { ... }
Result / Try:錯誤傳遞的 chain這些看起來不同的操作,在 monad 理論下有相似的結構。Kotlin stdlib 沒有提供統一的 Monad 介面,而是讓 Iterable、nullable、Result 等型別各自提供合適的操作。這是 API 現況;沒有官方資料能把原因單純歸結為 JVM 限制
知道這個關係的好處:遇到陌生 API 的 flatMap、then、bind、thenCompose,大概就能猜到語意,就是「上一個結果接到下一個會回傳容器的函式」
flatMap 在處理一對多關係的時候特別好用
// 所有員工的名字拆成字母
val allChars = employees.flatMap { it.name.toList() }
// 資料庫查詢:每個使用者的訂單
val allOrders = users.flatMap { userService.getOrders(it.id) }
// 解析設定檔:每行可能有多個 tag
val allTags = lines.flatMap { it.split(",").map(String::trim) }
C# 的 SelectMany 做的是同一件事。如果你用過 LINQ,flatMap 就是 SelectMany
flatMap 是 map 的延伸。map 處理一對一轉換,flatMap 處理一對多。核心就是雙層 for 迴圈,把 transform 回傳的集合攤平到同一層
flatten 是 flatMap 的特例,等同於 flatMap { it }。函式簽名上用 Iterable<Iterable<T>> 來限制只能在巢狀集合上呼叫
遇到可能為 null 的巢狀集合,記得 orEmpty() 和 listOfNotNull 這兩個搭檔,攤平的同時順手把 null 去掉
下一篇換個方向。前面的轉換都是 List → List,day 12 的 associate 系列要把 List 轉成 Map
同步刊登於 Blog
圖片來源:AI 產生