iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Software Development

Kotlin Lambda 從零開始系列 第 21

Kotlin Lambda 從零開始 Day 21:joinToString / joinTo — 聚合成字串

  • 分享至 

  • xImage
  •  

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

這篇文章會用 TDD 手刻 myJoinToStringmyJoinTo,搞懂預設參數、具名引數、nullable function type 這些語法,同時理解 joinToString 和 fold 的關係

Kotlin ↔ C# 對照表

Kotlin C# 備註
joinToString(", ") string.Join(", ", list) C# 是靜態方法,Kotlin 是擴充函式

C# 的 string.Join 只做拼接,要加前綴後綴得自己串。Kotlin 的 joinToString 把 separator、prefix、postfix、limit、truncated、transform 全包在同一個函式裡,靠預設參數讓簡單場景寫起來一樣簡潔

靜態方法這件事在 C# 專案裡煩過我很多次。一串 LINQ 寫到最後要拼成字串,沒辦法用 . 接下去,得整個倒過來從外面包一層,鏈式的節奏就斷在那裡

// 靜態方法,鏈式寫法到這一步斷掉
string.Join(", ", employees.Where(e => e.IsActive).Select(e => e.Name));

// 自己補一個擴充方法之後
employees.Where(e => e.IsActive).Select(e => e.Name).JoinToString(", ");

所以我後來每個 C# 專案都會自己寫一個 JoinToString 擴充方法,等到寫 Kotlin 才發現這東西內建就有,參數還比我自己刻的那版完整不少

joinToString 是 fold 的字串專用版

聚合篇一路做的都是同一件事:把集合壓縮成一個值。fold 是通用版,day 20 的 sumOf 把「壓縮成數字」這個常見場景包起來。joinToString 也一樣,只是結果換成字串

用 fold 手動拼字串長這樣

// 用 fold 模擬 joinToString(簡化版,只有 separator)
list.fold(StringBuilder()) { sb, element ->
    if (sb.isNotEmpty()) sb.append(", ")
    sb.append(element)
}.toString()

累加器是 StringBuilder,每一步把元素接上去,除了第一個以外都先補一個 separator。邏輯不難,但麻煩的地方已經浮出來了:要判斷「現在是不是第一個元素」,只能靠 sb.isNotEmpty() 這種間接手段

這裡起始的 StringBuilder 是空的,跑起來沒問題。但同一招搬進 joinTo 就撐不住了,因為 buffer 是呼叫端給的。待會 Red 的第一個測試傳進去的就是 StringBuilder("Result: "),第一個元素進來時 sb.isNotEmpty() 已經是 true,separator 會被錯加在最前面。myJoinTo 改用 count > 0 從 0 起算,就是為了不管 buffer 原本裝了什麼

再往下就更卡。prefix 和 postfix 得在 fold 外面另外包一層,limit 又更尷尬,fold 沒有提前結束的機制,只能全部跑完再截斷。寫得出來,但會變成一段看不出意圖的程式碼

joinToString 存在的理由跟 sumOfmaxByOrNull 一樣:把常見的聚合模式包成語意清楚的 API。它是聚合篇的最後一個專用版,參數也最多

TDD 實作 myJoinTo

joinToString 底層靠 joinTo 做事。joinTo 接受一個 Appendable 當目標,把字串拼進去。joinToString 只是建一個 StringBuilder 丟給 joinTo,最後呼叫 toString()

這個設計模式跟 day 10 看到的 mapTo 一樣:先有一個寫入目標的版本,再用語法糖包一個方便版

參數的部分,separator、prefix、postfix 顧名思義,limit 是最多輸出幾個元素,超過就用 truncated 這個字串收尾

Red:先寫測試

@Test
fun `joinTo StringBuilder`() {
    val sb = StringBuilder("Result: ")
    val numbers = listOf(1, 2, 3)
    val result = numbers.myJoinTo(sb, separator = ", ")
    assertSame(sb, result)
    assertEquals("Result: 1, 2, 3", sb.toString())
}

@Test
fun `joinTo with prefix and postfix`() {
    val sb = StringBuilder()
    listOf("a", "b", "c").myJoinTo(sb, separator = "-", prefix = "<", postfix = ">")
    assertEquals("<a-b-c>", sb.toString())
}

@Test
fun `joinTo with limit`() {
    val sb = StringBuilder()
    listOf(1, 2, 3, 4, 5).myJoinTo(sb, separator = ", ", limit = 2, truncated = "...")
    assertEquals("1, 2, ...", sb.toString())
}

assertSame 驗證回傳的是同一個 StringBuilder 物件,不是另外建立的。這是 joinTo 設計的重點:它把東西寫進你給它的 buffer,然後把 buffer 還給你,方便鏈式呼叫

Green:最小實作

fun <T, A : Appendable> Iterable<T>.myJoinTo(
    buffer: A,
    separator: CharSequence = ", ",
    prefix: CharSequence = "",
    postfix: CharSequence = "",
    limit: Int = -1,
    truncated: CharSequence = "...",
    transform: ((T) -> CharSequence)? = null
): A {
    buffer.append(prefix)
    var count = 0
    for (element in this) {
        if (limit >= 0 && count >= limit) {
            if (count > 0) buffer.append(separator)
            buffer.append(truncated)
            buffer.append(postfix)
            return buffer
        }
        if (count > 0) buffer.append(separator)
        if (transform != null) {
            buffer.append(transform(element))
        } else {
            buffer.append(element.toString())
        }
        count++
    }
    buffer.append(postfix)
    return buffer
}

幾個重點拆開來看

泛型 A : Appendable 是回傳型別的泛型。buffer 的型別是 A,回傳型別也是 A。這樣傳進去 StringBuilder 就回傳 StringBuilder,傳進去 StringBuffer 就回傳 StringBuffer,不會退化成 Appendable

limit 預設是 -1,代表不限制。當 limit >= 0 且已經輸出的元素數 count 達到上限,就加上 truncated 標記然後提早 return

transform 如果是 null,就用 element.toString() 轉字串。不是 null 就呼叫它。這裡不需要 ?.let 之類的寫法,直接 if-else 最清楚

Refactor:往 stdlib 的寫法靠近

邏輯已經和 stdlib 一致,剩下的差異只在 limit 的處理位置和計數寫法,留到後面的「與 stdlib 原始碼比較」一段對照

TDD 實作 myJoinToString

Red:先寫測試

@Test
fun `joinToString with default separator`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val result = numbers.myJoinToString()
    assertEquals("1, 2, 3, 4, 5", result)
}

@Test
fun `joinToString with prefix and postfix`() {
    val numbers = listOf(1, 2, 3)
    val result = numbers.myJoinToString(
        separator = ", ",
        prefix = "[",
        postfix = "]"
    )
    assertEquals("[1, 2, 3]", result)
}

@Test
fun `joinToString with limit and truncated`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val result = numbers.myJoinToString(
        separator = ", ",
        limit = 3,
        truncated = "..."
    )
    assertEquals("1, 2, 3, ...", result)
}

@Test
fun `joinToString with transform`() {
    val result = employees.myJoinToString(separator = ", ") { it.name }
    assertEquals("Alice, Bob, Charlie, Diana, Eve, Frank, Grace", result)
}

@Test
fun `joinToString empty list`() {
    val empty = emptyList<Int>()
    val result = empty.myJoinToString()
    assertEquals("", result)
}

Green:最小實作

fun <T> Iterable<T>.myJoinToString(
    separator: CharSequence = ", ",
    prefix: CharSequence = "",
    postfix: CharSequence = "",
    limit: Int = -1,
    truncated: CharSequence = "...",
    transform: ((T) -> CharSequence)? = null
): String {
    return myJoinTo(StringBuilder(), separator, prefix, postfix, limit, truncated, transform).toString()
}

一行搞定。建一個空的 StringBuilder,丟給 myJoinTo,拿回來呼叫 toString()

Refactor:往 stdlib 的寫法靠近

一行委派給 myJoinTo,這就是 stdlib 本人的形狀,沒有再簡化的空間

預設參數 + 具名引數

joinToString 的六個參數(separator、prefix、postfix、limit、truncated、transform)全部有預設值,所以 list.joinToString() 一個引數都不用傳也能跑。大部分時候只需要指定其中一兩個,這靠預設參數(default parameter)和具名引數(named argument)配合

// 全用預設值
list.joinToString()                              // "1, 2, 3"

// 只改 separator
list.joinToString(separator = " | ")             // "1 | 2 | 3"

// 跳過 separator,直接指定 prefix 和 postfix
list.joinToString(prefix = "[", postfix = "]")   // "[1, 2, 3]"

// 混著來
list.joinToString(
    separator = " | ",
    prefix = "{ ",
    postfix = " }",
    limit = 3,
    truncated = "..."
)

具名引數的好處是可以跳過中間的參數。不用像 Java 那樣被迫把前面的預設值重新寫一遍

C# 也有 optional parameter 和 named argument,語法幾乎一樣。這是少數 Kotlin 和 C# 長得很像的地方

nullable function type

transform: ((T) -> CharSequence)? = null 這個型別要拆開來看

// 這兩個不同
((T) -> CharSequence)?    // Lambda 本身可能不存在(null)
(T) -> CharSequence?      // Lambda 一定存在,但回傳值可能是 null

外層括號再加 ? 是「整個函式可以是 null」。用的時候需要先判斷 null

if (transform != null) {
    buffer.append(transform(element))   // Smart cast:這裡 transform 已經不是 null
} else {
    buffer.append(element.toString())
}

Kotlin 的 smart cast 在 if (transform != null) 分支裡自動把 transform((T) -> CharSequence)? 窄化成 (T) -> CharSequence,不需要額外的 !!?.invoke()

smart cast 不是魔法,是編譯器的型別流分析(flow analysis)。看到 if (x != null),compiler 在 then 分支裡把 x 的型別從 T? 推導為 T

有個細節:local val 和函式參數一律可以 smart cast,這裡 transform 是函式參數,所以沒問題。但不是所有 val 都能 smart cast。如果是 class 的 val property,而且它是 open、有自訂 getter、或定義在別的 module,compiler 同樣不啟動 smart cast,因為它無法保證每次讀取都拿到同一個值。var property 就更不用說了,compiler 沒辦法保證 thread A 看到非 null 之後,thread B 不會把它改成 null

C# 8.0 的 nullable reference types 也有類似的 flow analysis,同樣有 mutability 的限制。Kotlin 把 smart cast 當核心機制,C# 是後加的,效果類似但底層處理不同

day 35 的 contract 機制就是「告訴 compiler 額外資訊以強化 smart cast」,例如 myRequire(x != null) 之後也能 smart cast,即使 compiler 自己看不出來

StringBuilder 和 Appendable

為什麼不直接用字串 + 拼接?

// 不好的實作
var result = ""
for (element in list) {
    result += element.toString()  // 每次 + 都建立一個新 String
}

字串在 JVM 上是不可變的(immutable)。每次 + 都建一個新的 String 物件,N 個元素就建 N 個中間物件。StringBuilder 內部用可變的字元陣列,append 只是往陣列後面塞,不需要反覆建立新物件

AppendableStringBuilder 實作的介面。joinTo 接受 Appendable 而不是 StringBuilder,這樣也能接受其他實作(比如 StringBufferWriter)

與 stdlib 原始碼比較

原始碼位置:kotlin.collections_Collections.kt

stdlib 的 joinTo 跟我們的邏輯一樣,但寫法稍有不同

public fun <T, A : Appendable> Iterable<T>.joinTo(
    buffer: A,
    separator: CharSequence = ", ",
    prefix: CharSequence = "",
    postfix: CharSequence = "",
    limit: Int = -1,
    truncated: CharSequence = "...",
    transform: ((T) -> CharSequence)? = null
): A {
    buffer.append(prefix)
    var count = 0
    for (element in this) {
        if (++count > 1) buffer.append(separator)
        if (limit < 0 || count <= limit) {
            buffer.appendElement(element, transform)
        } else break
    }
    if (limit >= 0 && count > limit) buffer.append(truncated)
    buffer.append(postfix)
    return buffer
}

差異在 limit 的處理方式。stdlib 用 break 跳出迴圈,在迴圈外面統一加 truncated 和 postfix。我們的版本在迴圈裡面直接 return。效果一樣,但 stdlib 的寫法確保 postfix 只在一個地方處理,不會像我們的版本在兩個地方都寫 buffer.append(postfix)

另一個差異是 stdlib 用 ++count > 1 取代我們的 count > 0 + count++。前者把遞增和判斷合在一起,少寫一行但可讀性見仁見智

appendElement 是 stdlib 內部的 helper,邏輯比我們的 if-else 多一層:有 transform 就呼叫 transform;沒有 transform 時,會看元素型別,是 CharSequenceChar 就直接 append(避免多繞一次 toString()),其他型別才走 element.toString()。我們的版本沒做這層特判,一律 toString(),結果一樣只是少了那點最佳化

第五部分回顧

聚合篇到這裡結束。五篇走過的路線

fold(通用累加器) → reduce(省略初始值) → Grouping(分組之後各組各自聚合) → sum/max/min/average(數值專用) → joinToString(字串專用)

大部分是一層層往專用的方向走。fold 什麼都能做但囉嗦,專用函式語意清楚但只能做一件事。選哪個取決於場景:如果 stdlib 有現成的專用版就用專用版,沒有的話回到 fold

day 19 的 Grouping 是這條線上唯一的岔路。它不是再幫 fold 多包一層專用版,而是把 fold 搬到分組的維度上,讓同一套折疊邏輯對每一組各跑一次

本部分 API 速查

函式 篇號 用途
myFold day 17 帶初始值的通用累加
myFoldIndexed day 17 累加時拿得到元素索引
myReduce day 18 拿第一個元素當初始值的累加,空集合會丟例外
myReduceOrNull day 18 空集合回傳 null 而非丟例外
myReduceIndexed day 18 reduce 帶索引版
MyGrouping day 19 兩個方法的介面:元素從哪來、怎麼分組
myGroupingBy day 19 回傳 MyGrouping,建立時還不做任何分組
myAggregate day 19 四個操作的底層,帶 first 旗標的累加
myAggregateTo day 19 aggregate 的 destination 版
myFold(Grouping) day 19 每組各自從同一個初始值折疊
myReduce(Grouping) day 19 每組拿自己的第一個元素當初始值
myEachCount day 19 每組計數,全程不建中間 List
mySumOf day 20 把每個元素映射成數值後加總
myMaxByOrNull day 20 依 selector 找最大元素,空集合回 null
myMinByOrNull day 20 依 selector 找最小元素,空集合回 null
myAverage day 20 數值集合求平均
myJoinTo day 21 把元素拼進指定的 Appendable buffer
myJoinToString day 21 把元素聚合成一個字串

小結

joinToString 是這個系列到目前為止簽名最長的函式,難的地方卻不在演算法。六個參數全部有預設值,簡單場景寫 joinToString() 就好,要調哪一個就用具名引數指名,不必背順序

transform 那個 ((T) -> CharSequence)? 要看仔細。Kotlin 的 function type 預設不可為 null,要讓 Lambda 參數本身能收 null,得自己在外面補一層 ?。括號也不能省,(T) -> CharSequence? 是「回傳值可以是 null」,跟 ((T) -> CharSequence)? 完全兩回事

真正做事的是 myJoinTomyJoinToString 只是替它準備一個 StringBuilder,做完再 toString(),跟 stdlib 到處都在用的 xxxTo 模式一樣

下一篇進入切片篇。take / drop 從集合的頭尾取走或丟掉元素,操作簡單但和 Sequence 搭配時威力倍增(day 27 以後會看到)

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 20:sum / sumOf / maxBy / minBy / average — 數值聚合
下一篇
Kotlin Lambda 從零開始 Day 22:take / takeLast / drop / dropLast — 頭尾切片
系列文
Kotlin Lambda 從零開始35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言