iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Software Development

Kotlin Lambda 從零開始系列 第 10

Kotlin Lambda 從零開始 Day 10:map / mapNotNull / mapIndexed — 最核心的轉換操作

  • 分享至 

  • xImage
  •  

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

這篇文章會用 TDD 手刻 myMapmyMapNotNullmyMapIndexed,正式進入轉換篇。Lambda 的簽名從 (T) -> Boolean 變成 (T) -> R,泛型參數也跟著多了一個

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
map { } Select()
mapNotNull { } Select().Where(x => x != null).Select(x => x.Value) Kotlin 一步到位;C# 濾完型別仍是 T?,要再取值
mapIndexed { index, value -> } Select((item, index) => ...) 參數順序相反:Kotlin index 在前,C# element 在前

從 filter 到 map

filter 和 map 是 Collection API 的兩根柱子。結構幾乎一樣,差別在於 filter 問「要不要留」,map 問「要變成什麼」

filter 的 Lambda 簽名是 (T) -> Boolean,回傳 true/false 決定去留。map 的 Lambda 簽名是 (T) -> RT 是輸入型別、R 是輸出型別。回傳值直接就是轉換後的結果

所以 map 的泛型參數從一個變成兩個。這是我們的手刻函式第一次出現 <T, R> 這種雙泛型簽名

TDD 實作 myMap

Red:先寫測試

@Test
fun `map transforms elements`() {
    val numbers = listOf(1, 2, 3)
    val result = numbers.myMap { it * 2 }
    assertEquals(listOf(2, 4, 6), result)
}

@Test
fun `map employee names`() {
    val names = employees.myMap { it.name }
    assertEquals(7, names.size)
    assertEquals("Alice", names[0])
    assertEquals("Grace", names[6])
}

@Test
fun `map to different type`() {
    val numbers = listOf(1, 2, 3)
    val result = numbers.myMap { it.toString() }
    assertEquals(listOf("1", "2", "3"), result)
}

@Test
fun `map empty list returns empty`() {
    val empty = emptyList<Int>()
    val result = empty.myMap { it * 2 }
    assertEquals(emptyList<Int>(), result)
}

第二個測試 map employee namesList<Employee> 轉成 List<String>。輸入和輸出型別不一樣,這就是 R 存在的意義

測試裡沒有例外案例,因為 map 根本不會拋例外,每個元素丟進 transform 轉換就結束了。null 的處理是後面 myMapNotNull 負責的

Green:最小實作

inline fun <T, R> Iterable<T>.myMap(transform: (T) -> R): List<R> {
    val result = ArrayList<R>()
    for (element in this) {
        result.add(transform(element))
    }
    return result
}

myFilter 的結構比一下

// myFilter
if (predicate(element)) {
    result.add(element)        // 加入「原本的元素」
}

// myMap
result.add(transform(element)) // 加入「轉換後的東西」

filter 有 if 判斷,只加符合條件的原始元素。map 沒有 if,每個元素都會經過 transform 然後無條件加入結果。所以 map 的回傳 List 長度一定跟原始 List 一樣

回傳型別也不同。filter 回傳 List<T>(跟輸入同型別),map 回傳 List<R>(可以是完全不同的型別)

Refactor:往 stdlib 的寫法靠近

Green 版的 ArrayList<R>() 沒有預先指定容量,第一次 add 才配置一個預設大小的陣列,之後元素一多就得反覆擴容。如果接收者本身是 Collection,事先就知道 size,可以直接拿來預分配容量

inline fun <T, R> Iterable<T>.myMap(transform: (T) -> R): List<R> {
    val capacity = if (this is Collection<*>) size else 10
    val result = ArrayList<R>(capacity)
    for (element in this) {
        result.add(transform(element))
    }
    return result
}

this is Collection<*> 成立時,smart cast 讓我們直接拿 size。不是 Collection 的話就使用預設值 10。等一下看 stdlib 原始碼會發現它做了一模一樣的事

TDD 實作 myMapNotNull

Red:先寫測試

@Test
fun `mapNotNull filters out null results`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val result = numbers.myMapNotNull { if (it > 3) it * 10 else null }
    assertEquals(listOf(40, 50), result)
}

@Test
fun `mapNotNull with string parsing`() {
    val strings = listOf("1", "abc", "3", "def", "5")
    val result = strings.myMapNotNull { it.toIntOrNull() }
    assertEquals(listOf(1, 3, 5), result)
}

@Test
fun `mapNotNull all null returns empty`() {
    val numbers = listOf(1, 2, 3)
    val result = numbers.myMapNotNull<Int, String> { null }
    assertEquals(emptyList<String>(), result)
}

@Test
fun `mapNotNull none null returns all`() {
    val numbers = listOf(1, 2, 3)
    val result = numbers.myMapNotNull { it * 2 }
    assertEquals(listOf(2, 4, 6), result)
}

@Test
fun `mapNotNull empty list returns empty`() {
    val empty = emptyList<Int>()
    val result = empty.myMapNotNull { it * 2 }
    assertEquals(emptyList<Int>(), result)
}

第二個測試 mapNotNull with string parsing 很實用。toIntOrNull() 把字串轉數字,轉不了的回傳 null,mapNotNull 自動濾掉。一步完成轉換加過濾

C# 要達到同樣效果得寫 Select(x => int.TryParse(x, out var n) ? n : (int?)null).Where(x => x.HasValue).Select(x => x.Value)。注意最後還得再接一個 Select 取值,因為 Where 濾掉 null 之後元素型別還是 int?。Kotlin 的 mapNotNull 一步到位,回傳的直接就是 List<Int>

不過這只是「stdlib 有沒有直接給你」的差別,不是 C# 做不到。LINQ 本來就是一組擴充方法,寫得出 LINQ 的人自己補一個也就十行的事(笑)

public static IEnumerable<TResult> SelectNotNull<TSource, TResult>(
    this IEnumerable<TSource> source,
    Func<TSource, TResult?> selector) where TResult : struct
{
    foreach (var item in source)
    {
        var value = selector(item);
        if (value.HasValue)
            yield return value.Value;
    }
}

補完之後呼叫端就乾淨了:strings.SelectNotNull(x => int.TryParse(x, out var n) ? n : (int?)null),一樣一步到位。實務上專案裡有個 LinqExtensions 放這種小工具是很常見的做法

只是這一版加了 where TResult : struct,只吃得下 nullable 實值型別。想處理 string? 這種 nullable 參考型別的話,還得再寫一個 where TResult : class 的多載。這兩個多載可以並存,因為 TResult? 在兩種約束下展開出來的東西不一樣:前者是 Nullable<TResult>,是實實在在的另一個型別;後者只是編譯器的可為 null 標註,執行期還是 TResult

Kotlin 這邊 R : Any 加上 (T) -> R? 一個簽名就把兩種情況都涵蓋了,因為 nullability 從語言第一天起就長在型別系統裡,不是後來才補的標註。這是兩邊起跑點不同,不是誰的擴充方法寫得比較好

Green:最小實作

inline fun <T, R : Any> Iterable<T>.myMapNotNull(transform: (T) -> R?): List<R> {
    val result = ArrayList<R>()
    for (element in this) {
        val transformed = transform(element)
        if (transformed != null) {
            result.add(transformed)
        }
    }
    return result
}

簽名上有兩個重點

R : Any:泛型上界約束。R 必須是非 null 型別,因為回傳的 List<R> 保證不含 null。Kotlin 的 Any 是所有非 null 型別的根,加上 : Any 等於告訴編譯器「R 不可能是 nullable」

transform: (T) -> R?:transform 的回傳值是 R?(nullable)。轉換結果可能是 null,這正是 mapNotNull 要處理的情況

實作本身很直白:轉換、檢查、加入。if (transformed != null) 之後,smart cast 把 transformedR? 變成 R,所以 result.add(transformed) 不會出現錯誤

Refactor:往 stdlib 的寫法靠近

「轉換、檢查、加入」三行可以用 Kotlin 的慣用寫法縮成一行

inline fun <T, R : Any> Iterable<T>.myMapNotNull(transform: (T) -> R?): List<R> {
    val result = ArrayList<R>()
    for (element in this) {
        transform(element)?.let { result.add(it) }
    }
    return result
}

?.let { }:safe call 加上 scope function let。如果 transform(element) 回傳 null,?. 讓整個 chain 短路,不會執行 let。不是 null 的話,let 裡的 it 就是非 null 的值,直接加入結果

?.let 是 Kotlin 處理 nullable 值的常見慣用寫法,stdlib 自己就是這樣寫的。day 32(Scope Functions)會完整講解 letrunapply 這些函式的差異和適用場景

TDD 實作 myMapIndexed

Red:先寫測試

@Test
fun `mapIndexed uses index and element`() {
    val letters = listOf("a", "b", "c")
    val result = letters.myMapIndexed { index, value -> "$index:$value" }
    assertEquals(listOf("0:a", "1:b", "2:c"), result)
}

@Test
fun `mapIndexed multiply by index`() {
    val numbers = listOf(10, 20, 30)
    val result = numbers.myMapIndexed { index, value -> index * value }
    assertEquals(listOf(0, 20, 60), result)
}

@Test
fun `mapIndexed employee with rank`() {
    val ranked = employees.myMapIndexed { index, emp -> "${index + 1}. ${emp.name}" }
    assertEquals("1. Alice", ranked[0])
    assertEquals("7. Grace", ranked[6])
}

@Test
fun `mapIndexed empty list returns empty`() {
    val empty = emptyList<Int>()
    val result = empty.myMapIndexed { _, value -> value * 2 }
    assertEquals(emptyList<Int>(), result)
}

mapIndexedfilterIndexed 是同一個模式:Lambda 多一個 index 參數。差別在 filterIndexed 用 index 來決定去留,mapIndexed 用 index 來參與轉換

Green:最小實作

inline fun <T, R> Iterable<T>.myMapIndexed(transform: (index: Int, T) -> R): List<R> {
    val result = ArrayList<R>()
    var index = 0
    for (element in this) {
        result.add(transform(index, element))
        index++
    }
    return result
}

跟 day 05 的 myFilterIndexed 比,差別只在 result.add 的部分。filterIndexed 是「判斷後選擇性加入原始元素」,mapIndexed 是「每個元素都經過轉換後加入」

手動維護 index 計數器的模式在 day 05 就出現過了,到這裡應該很熟了

Refactor:往 stdlib 的寫法靠近

能做的最佳化跟 myMap 一樣是容量預先分配,寫法完全相同,這裡就不重複貼一次。結構本身已經和 stdlib 一致了,下一節對照原始碼就能看到

與 stdlib 原始碼比較

原始碼位置:kotlin.collections_Collections.kt

三個函式手刻完了,一次對照 stdlib 的版本。先看 map

public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
    return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(
    destination: C, transform: (T) -> R
): C {
    for (item in this) destination.add(transform(item))
    return destination
}

stdlib 的 map 呼叫 mapTo,這個 xxxTo 模式在 day 03 就看過了(filterTo)。到了 map 系列還是同一套

collectionSizeOrDefault(10) 就是我們在 Refactor 做的容量預先分配:如果 Iterable 同時是 Collection(知道 size),就用 size 來預先分配 ArrayList 容量,避免後續擴容。不是 Collection 的話就用預設值 10。stdlib 把這個判斷包成一個函式重複使用

再看 mapNotNull

public inline fun <T, R : Any> Iterable<T>.mapNotNull(transform: (T) -> R?): List<R> {
    return mapNotNullTo(ArrayList<R>(), transform)
}

public inline fun <T, R : Any, C : MutableCollection<in R>> Iterable<T>.mapNotNullTo(
    destination: C, transform: (T) -> R?
): C {
    forEach { element ->
        transform(element)?.let { destination.add(it) }
    }
    return destination
}

核心邏輯跟我們 Refactor 後的版本完全一樣。?.let 這個寫法就是 stdlib 自己用的

最後是 mapIndexed

public inline fun <T, R> Iterable<T>.mapIndexed(transform: (index: Int, T) -> R): List<R> {
    return mapIndexedTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapIndexedTo(
    destination: C, transform: (index: Int, T) -> R
): C {
    var index = 0
    for (item in this)
        destination.add(transform(checkIndexOverflow(index++), item))
    return destination
}

一樣的 index 計數器模式,多了一個 checkIndexOverflow:index 超過 Int.MAX_VALUE 會丟出 ArithmeticException,而不是默默溢位成負數。對一般的 List 來說幾乎碰不到,這個防護主要是給元素數量沒有上限的 Iterable 用的

mapTo 的設計模式

前面看 stdlib 原始碼時,每個函式都有一個 xxxTo 的版本:mapTomapNotNullTomapIndexedTofilterTo

這個模式讓呼叫端自己決定目標集合的型別

// 預設用 ArrayList
val list = numbers.map { it * 2 }

// 想用 LinkedList?
val linked = numbers.mapTo(LinkedList()) { it * 2 }

// 想把結果累加到已經存在的集合?
val existing = mutableListOf(0)
numbers.mapTo(existing) { it * 2 }
// existing = [0, 2, 4, 6]

mapTo 的泛型簽名是 <T, R, C : MutableCollection<in R>>。三個泛型參數,C 被約束為 MutableCollection<in R>in R 是型變(variance)標記,day 34 會解釋

我們不需要自己實作 myMapTo,但知道這個模式存在就好。當你看 stdlib 原始碼發現 map 只有一行 return mapTo(ArrayList(), transform) 時,就知道真正的邏輯在 mapTo

xxxTo 的設計哲學是「分離轉換邏輯與目標容器」。這跟 C# LINQ 的取捨不一樣,LINQ 的 Select 永遠回傳 IEnumerable,要存到具體容器靠 ToList()ToArray()ToHashSet() 一連串 To... 方法接在管道末端。Kotlin 反過來:map 直接回傳 List,要換容器就在函式參數帶進去:mapTo(LinkedList()) { ... }

LINQ 來自 lazy IEnumerable 的單一資料管道,容器選擇放在管道末端;Kotlin 來自 eager Collection,每個函式立即產出一個容器,容器選擇放在函式參數

實務上 99% 場景用預設的 map 就夠。xxxTo 留給少數需要客製目標容器的情境,或把多次 map 結果累加到同一個容器

val result = mutableListOf<Int>()
list1.mapTo(result) { it * 2 }
list2.mapTo(result) { it + 100 }  // 累加到同一個 result

stdlib 內部大量用第二種寫法,groupByflatten 都是把多批次資料累加到同一個 destination

map 和 filter 的結構比較

整理一下目前手刻過的函式,把 filter 系列和 map 系列放在一起看

函式 Lambda 簽名 回傳型別 核心動作
myFilter (T) -> Boolean List<T> 符合條件才加入
myFilterNot (T) -> Boolean List<T> 不符合條件才加入
myFilterIndexed (Int, T) -> Boolean List<T> 帶 index 的 filter
myMap (T) -> R List<R> 每個都轉換後加入
myMapNotNull (T) -> R? List<R> 轉換後非 null 才加入
myMapIndexed (Int, T) -> R List<R> 帶 index 的 map

filter 系列的回傳型別永遠是 List<T>(跟輸入同型別)。map 系列的回傳型別是 List<R>(可以不同型別)

另一個差異:filter 系列的 result.size <= 原始 size,因為是在篩選。map 系列的 result.size == 原始 size(mapNotNull 例外),因為是一對一轉換

小結

map 和 filter 的骨架幾乎一樣:for 迴圈 + 加入結果。差別在 filter 是「條件篩選」,map 是「型別轉換」。Lambda 簽名從 (T) -> Boolean 變成 (T) -> R,多了一個泛型參數

mapNotNull 引入了 ?.let 這個 Kotlin 慣用寫法,把「轉換 + 過濾 null」合成一步。R : Any 的上界約束保證回傳的 List 不含 null

下一篇來看一個有趣的問題:如果 map 的 transform 回傳的不是單一元素,而是一個 List 呢?day 11 的 flatMapflatten 就是處理這種「一對多」轉換、攤平巢狀結構的工具

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 09:contains / elementAt — 存在性與直接存取
下一篇
Kotlin Lambda 從零開始 Day 11:flatMap / flatten — 攤平巢狀結構
系列文
Kotlin Lambda 從零開始15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言