
這篇文章會用 TDD 手刻 myJoinToString 和 myJoinTo,搞懂預設參數、具名引數、nullable function type 這些語法,同時理解 joinToString 和 fold 的關係
| 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 才發現這東西內建就有,參數還比我自己刻的那版完整不少
聚合篇一路做的都是同一件事:把集合壓縮成一個值。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 存在的理由跟 sumOf、maxByOrNull 一樣:把常見的聚合模式包成語意清楚的 API。它是聚合篇的最後一個專用版,參數也最多
joinToString 底層靠 joinTo 做事。joinTo 接受一個 Appendable 當目標,把字串拼進去。joinToString 只是建一個 StringBuilder 丟給 joinTo,最後呼叫 toString()
這個設計模式跟 day 10 看到的 mapTo 一樣:先有一個寫入目標的版本,再用語法糖包一個方便版
參數的部分,separator、prefix、postfix 顧名思義,limit 是最多輸出幾個元素,超過就用 truncated 這個字串收尾
@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 還給你,方便鏈式呼叫
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 最清楚
邏輯已經和 stdlib 一致,剩下的差異只在 limit 的處理位置和計數寫法,留到後面的「與 stdlib 原始碼比較」一段對照
@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)
}
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()
一行委派給 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# 長得很像的地方
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 自己看不出來
為什麼不直接用字串 + 拼接?
// 不好的實作
var result = ""
for (element in list) {
result += element.toString() // 每次 + 都建立一個新 String
}
字串在 JVM 上是不可變的(immutable)。每次 + 都建一個新的 String 物件,N 個元素就建 N 個中間物件。StringBuilder 內部用可變的字元陣列,append 只是往陣列後面塞,不需要反覆建立新物件
Appendable 是 StringBuilder 實作的介面。joinTo 接受 Appendable 而不是 StringBuilder,這樣也能接受其他實作(比如 StringBuffer、Writer)
原始碼位置: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 時,會看元素型別,是 CharSequence 或 Char 就直接 append(避免多繞一次 toString()),其他型別才走 element.toString()。我們的版本沒做這層特判,一律 toString(),結果一樣只是少了那點最佳化
聚合篇到這裡結束。五篇走過的路線
fold(通用累加器) → reduce(省略初始值) → Grouping(分組之後各組各自聚合) → sum/max/min/average(數值專用) → joinToString(字串專用)
大部分是一層層往專用的方向走。fold 什麼都能做但囉嗦,專用函式語意清楚但只能做一件事。選哪個取決於場景:如果 stdlib 有現成的專用版就用專用版,沒有的話回到 fold
day 19 的 Grouping 是這條線上唯一的岔路。它不是再幫 fold 多包一層專用版,而是把 fold 搬到分組的維度上,讓同一套折疊邏輯對每一組各跑一次
| 函式 | 篇號 | 用途 |
|---|---|---|
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)? 完全兩回事
真正做事的是 myJoinTo。myJoinToString 只是替它準備一個 StringBuilder,做完再 toString(),跟 stdlib 到處都在用的 xxxTo 模式一樣
下一篇進入切片篇。take / drop 從集合的頭尾取走或丟掉元素,操作簡單但和 Sequence 搭配時威力倍增(day 27 以後會看到)
同步刊登於 Blog
圖片來源:AI 產生