iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Software Development

Kotlin Lambda 從零開始系列 第 11

Kotlin Lambda 從零開始 Day 11:flatMap / flatten — 攤平巢狀結構

  • 分享至 

  • xImage
  •  

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

這篇文章會用 TDD 手刻 myFlatMapmyFlatten。當 map 的 transform 回傳的是一個集合而不是單一元素時,就需要 flatMap 來把巢狀結構攤平

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
flatMap { } SelectMany()
flatten() SelectMany(x => x) C# 用 SelectMany 的恆等版本

為什麼需要 flatMap

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 把中間那步和最後那步合併了

TDD 實作 myFlatMap

Red:先寫測試

@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 處理中的妙用〉會再展開

Green:最小實作

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>。這是唯一的差別

Refactor:往 stdlib 的寫法靠近

內層迴圈逐一 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」

TDD 實作 myFlatten

Red:先寫測試

@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 之前就要處理好

Green:最小實作

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

Refactor:往 stdlib 的寫法靠近

跟 myFlatMap 一樣,內層迴圈換成 addAll

fun <T> Iterable<Iterable<T>>.myFlatten(): List<T> {
    val result = ArrayList<T>()
    for (inner in this) {
        result.addAll(inner)
    }
    return result
}

與 stdlib 原始碼比較

原始碼位置: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,沒有什麼好客製的

flatMap 和 flatten 的關係

可以這樣理解

// 這兩行等價
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

flatMap 在 nullable 處理中的妙用

實務上的巢狀集合常常帶著 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 的 orEmptylistOfNotNull 把這些樣板省掉了

flatMap 不只是攤平

回到 flatMap 的核心:把 Iterable<T> 接上 T → Iterable<R> 的轉換,得到 Iterable<R>。在函式式語言這個操作有個正式名字,叫 monadic bind(寫成 >>=)

monad 中文譯作「單子」。bind 沒有通行的中文譯名,講 bind 就好。單子指的是「一個包著值的容器型別,加上兩個操作」

  • unit(也叫 return):把普通的值 T 包成容器 M<T>listOf(x) 就是 Iterable 的 unit
  • bind:簽名是 (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 換成其他「容器型別」,結構完全一樣

  • Optional / Nullable:x?.let { ... } 是 nullable 的 bind
  • CompletableFuturefuture.thenCompose { ... }
  • Result / Try:錯誤傳遞的 chain

這些看起來不同的操作,在 monad 理論下有相似的結構。Kotlin stdlib 沒有提供統一的 Monad 介面,而是讓 Iterable、nullable、Result 等型別各自提供合適的操作。這是 API 現況;沒有官方資料能把原因單純歸結為 JVM 限制

知道這個關係的好處:遇到陌生 API 的 flatMapthenbindthenCompose,大概就能猜到語意,就是「上一個結果接到下一個會回傳容器的函式」

實際場景

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

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 10:map / mapNotNull / mapIndexed — 最核心的轉換操作
下一篇
Kotlin Lambda 從零開始 Day 12:associate / associateBy / associateWith — 轉成 Map
系列文
Kotlin Lambda 從零開始15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言