1. 為什麼需要對結果做後處理
當你第一次拿到 groupBy 或 eachCount 的結果時,常會有一種感覺:「喔,資料就在這裡!現在 println(map) — 就搞定了」。是的,從技術上說那確實是輸出。但如果你曾在週五晚上讀過伺服器 log(只想好好活著的那種晚上),你就會知道:「輸出」和「報表」是兩種完全不同的心理狀態。
後處理就是我們解決兩個問題的步驟:把 value 轉成什麼(例如 list → list 的大小),以及以什麼順序顯示結果(按 key、按頻率、top‑N)。理想情況下,我們還會把輸出整理成更舒服的樣子:欄位對齊、加上標題,讓報表是給人眼看的,而不只是給編譯器看的。
想像我們延續之前那個主控台迷你應用:支出清單(目前先全部放在記憶體裡,不用 class、不用資料庫 — 先保持誠實與簡單)。假設一筆支出就是一對 (category, amount):
fun main() {
val expenses = listOf(
"food" to 120,
"food" to 80,
"taxi" to 250,
"books" to 600,
"taxi" to 140
)
println(expenses) // [(food, 120), (food, 80), (taxi, 250), (books, 600), (taxi, 140)]
}
這個清單本身沒問題。但一旦我們想要「按分類的報表」,生活就開始了:需要分組、計數、排序,最後才是文字輸出。
如何選:eachCount() 還是 groupBy().mapValues { size }
當你看到這樣的程式碼:
val counts = list.groupBy { key }.mapValues { it.value.size }
很合理會問:「那為什麼還需要 eachCount,用這樣寫不就好了?」反過來也會問:「如果我只需要數字,為什麼要先分組成 list?」
為了不混淆,可以記住一個小小的對照表:
| 任務 | 工具 | 結果型別 | 何時方便 |
|---|---|---|---|
| 顯示每個分組內的細節 | |
|
當你真的會去遍歷分組裡的元素 |
| 計算「每個 key 有幾個元素」 | |
|
當你根本不需要分組元素的清單 |
已經有 ,但現在想要摘要 |
|
|
當你不想把整個 pipeline 重做一遍 |
也就是說,eachCount() 是通往計數結果更直接的路;而 mapValues { size } 則是在你已經有分組結果時,把它「榨成」摘要數字的救生圈。
2. mapValues:轉換 Map 的 value
當你做 groupBy 時,幾乎可以保證你會得到一個 value 是 list 的 Map: Map<K, List<T>>。這很合理:一個 group 就是一組元素。但報表通常不需要整組元素本身,而是需要從 group 衍生出的值:group 的大小、平均值、名稱清單、第一個/最後一個,等等。
這類轉換可以用 mapValues:它保留 key 不變,只改變 value。這也正是「集合轉換鏈」的延伸概念:多數操作都會產生新的結果,而不是就地修改原本的物件。
最常見的情境:「我想要 group 的大小」
先按分類把支出分組:
fun main() {
val expenses = listOf(
"food" to 120,
"food" to 80,
"taxi" to 250,
"books" to 600,
"taxi" to 140
)
val byCategory: Map<String, List<Pair<String, Int>>> =
expenses.groupBy { it.first }
println(byCategory["taxi"]) // [(taxi, 250), (taxi, 140)]
}
接著我們想要「分類 → 記錄數量」的 Map:
fun main() {
val expenses = listOf(
"food" to 120,
"food" to 80,
"taxi" to 250,
"books" to 600,
"taxi" to 140
)
val countsByCategory: Map<String, Int> = expenses
.groupBy { it.first }
.mapValues { entry -> entry.value.size }
println(countsByCategory) // {food=2, taxi=2, books=1}
}
這裡要抓到的重點是機制:mapValues 拿到的是 entry(key+value 的一對),而 entry.value 就是那個分組元素清單,size 則是它的長度。
如果你比較習慣用解構來讀,也可以這樣寫:
fun main() {
val expenses = listOf("food" to 120, "food" to 80, "taxi" to 250)
val countsByCategory = expenses
.groupBy { it.first }
.mapValues { (_, group) -> group.size }
println(countsByCategory) // {food=2, taxi=1}
}
mapKeys 也很近,但今天用不到
有時候你會想「修正 key」(例如全部轉小寫)。這時可以用 mapKeys,但今天我們聚焦在分組結果的後處理;在這裡更常改的是 value:list → 數字、list → 字串、數字 → 字串,等等。
3. 排序:處理 entries
在算完頻率後,新手的第二個痛點會出現:「我想漂亮地輸出:把最常見的分類排在前面。」這時常見的生活場景是:你嘗試「排序 Map」,編譯器帶著一點淡淡的哀傷看著你,然後你突然明白:Map 不是「有順序的字串清單」,它是 key → value 的結構。
正確做法是:為了排序,我們幾乎總是取 entries(key/value 的記錄集合),把它變成 list;list 才能排序,才能按你要的順序輸出。
按 key 排序
fun main() {
val counts = mapOf("taxi" to 2, "food" to 5, "books" to 1)
val sortedByKey = counts.entries.sortedBy { it.key }
for (e in sortedByKey) {
println("${e.key} -> ${e.value}")
// books -> 1
// food -> 5
// taxi -> 2
}
}
這裡我們按 it.key 排序。這對「參考型」報表很方便:全部按字母順序排列,眼睛很好找。
按 value 由大到小排序
fun main() {
val counts = mapOf("taxi" to 2, "food" to 5, "books" to 1)
val sortedByCountDesc = counts.entries.sortedByDescending { it.value }
for (e in sortedByCountDesc) {
println("${e.key} -> ${e.value}")
// food -> 5
// taxi -> 2
// books -> 1
}
}
用 take(n) 取得 Top‑N
排序完成後,挑出前 N 筆就很簡單:
fun main() {
val counts = mapOf("taxi" to 2, "food" to 5, "books" to 1, "games" to 4)
val top2 = counts.entries
.sortedByDescending { it.value }
.take(2)
println(top2.map { it.key to it.value }) // [(food, 5), (games, 4)]
}
take(n) 是 list 的基本操作:拿前 N 個元素。
4. 輸出準備:字串與 StringBuilder
排序與 mapValues 讓我們拿到資料結構,但「報表的最終形態」通常是字串(或多行字串)。這時你通常不想把所有東西硬塞進一個巨大的 println(...),而是希望程式可讀:先把報表文字準備好,再把它輸出。
這在主控台應用特別有用:今天你印到 console,明天你可能想從函式回傳字串,後天(遙遠而光明的未來)想寫進檔案。只要把「組字串的邏輯」分離出來,生活就會更簡單。
字串清單 → 用 joinToString 串接
fun main() {
val counts = mapOf("food" to 5, "taxi" to 2, "books" to 1)
val lines = counts.entries
.sortedByDescending { it.value }
.map { "${it.key}: ${it.value}" }
println(lines.joinToString(separator = "\n"))
// food: 5
// taxi: 2
// books: 1
}
這已經不錯:可讀、可預期。但有時你需要更多控制:標題、編號、對齊。
StringBuilder:當報表是一份多行文件
我們之前已經用過 StringBuilder 來組裝大字串,避免無止盡的串接。Kotlin 的一個加分點是:StringBuilder 有像 appendLine 這類好用的 extension function,讓組多行文字幾乎像在「寫文章」。
先組一個有標題的簡單報表:
import kotlin.text.appendLine
fun main() {
val counts = mapOf("food" to 5, "taxi" to 2, "books" to 1)
val report = StringBuilder()
.appendLine("Expenses by category (count)")
.appendLine("---------------------------")
.apply {
for (e in counts.entries.sortedByDescending { it.value }) {
appendLine("${e.key}: ${e.value}")
}
}
.toString()
println(report)
// Expenses by category (count)
// ---------------------------
// food: 5
// taxi: 2
// books: 1
}
注意:我們今天不把 scope function 當成主題來講,但你可以把這裡的 apply 理解成「一個方便的區塊,用來繼續往 builder 寫東西」。如果你現在覺得不想用 apply,也可以不用:
import kotlin.text.appendLine
fun main() {
val counts = mapOf("food" to 5, "taxi" to 2)
val sb = StringBuilder()
sb.appendLine("Report")
for (e in counts.entries.sortedByDescending { it.value }) {
sb.appendLine("${e.key}: ${e.value}")
}
println(sb.toString())
// Report
// food: 5
// taxi: 2
}
用 padEnd 與 padStart 進行欄位對齊
有時報表會因為數字對齊而變得好讀很多:讓數字都垂直對齊。這時用 padEnd/padStart 很方便。
import kotlin.text.appendLine
fun main() {
val counts = mapOf("food" to 12, "taxi" to 2, "books" to 100)
val sb = StringBuilder()
sb.appendLine("Category Count")
sb.appendLine("---------------------")
for (e in counts.entries.sortedByDescending { it.value }) {
val category = e.key.padEnd(14)
val count = e.value.toString().padStart(5)
sb.appendLine("$category$count")
}
println(sb.toString())
// Category Count
// ---------------------
// books 100
// food 12
// taxi 2
}
這不是「為了美觀的美觀」。這是在照顧未來的自己:當報表變大時,對齊能顯著降低大腦負擔。
5. 實作範例:按分類的報表
現在我們來做一段完整的邏輯,像真實專案裡的一個步驟:給你一份支出清單,我們要得到一份「每個分類有幾筆」的排序報表。
先做資料準備的函式。我們不把模型搞複雜:仍然用 Pair<String, Int>。
fun buildCategoryCounts(expenses: List<Pair<String, Int>>): Map<String, Int> {
return expenses
.groupBy { (category, _) -> category.trim().lowercase() }
.mapValues { (_, group) -> group.size }
}
接著做一個把 Map 轉成漂亮文字的函式:
import kotlin.text.appendLine
fun formatCategoryCounts(counts: Map<String, Int>, topN: Int): String {
val sorted = counts.entries
.sortedByDescending { it.value }
.take(topN)
val sb = StringBuilder()
sb.appendLine("Top categories by number of expenses")
sb.appendLine("-----------------------------------")
for ((category, count) in sorted) {
sb.appendLine("${category.padEnd(15)} ${count.toString().padStart(4)}")
}
return sb.toString()
}
最後是 main,展示整個 pipeline:
fun main() {
val expenses = listOf(
" Food " to 120,
"food" to 80,
"taxi" to 250,
"BOOKS" to 600,
"taxi" to 140,
"food" to 30
)
val counts = buildCategoryCounts(expenses)
val report = formatCategoryCounts(counts, topN = 3)
println(report)
// Top categories by number of expenses
// -----------------------------------
// food 3
// taxi 2
// books 1
}
這裡我們做的正是前面說的:對分組結果做後處理,把資料轉成適合人閱讀的形式。沒錯,「人」就是一週後的你。
6. 對 Map 做後處理時的常見錯誤
錯誤 №1:以為 mapValues 會「就地修改 Map」。
新手常會這樣想:「我呼叫了 mapValues,那 Map 就更新了」。但 Kotlin 中多數集合操作都會回傳 新的結果,而不會去動原本的集合。所以如果你寫了 counts.mapValues{...},卻沒有把結果存到任何地方,那你等於把計算丟進空氣裡 — 就像你寫了報表卻忘了按「儲存」。
錯誤 №2:嘗試排序 Map,而不是排序 entries。
排序是一個針對「有順序的序列」的操作。Map 本身不是用來「保證輸出順序」的地方,而且直接印 Map 也不是報表。實務上的好習慣是:要輸出就拿 map.entries,把它當作記錄清單來排序(按 key 或按 value),再印排序後的結果。這同時解決了順序問題,也解決了「為什麼每次輸出看起來有點不一樣」。
錯誤 №3:在 mapValues { ... } 裡塞太多邏輯。
mapValues 很方便,但如果你的 lambda 裡有半個螢幕的程式碼(迴圈、判斷、格式化全塞進去),閱讀就會變成解謎遊戲。這種情況下,把複雜的 value 計算抽成獨立函式會更清楚(例如 fun groupSum(group: List<Pair<String, Int>>): Int),然後在 mapValues 裡只留下一行短短的呼叫。
錯誤 №4:排序了結果,卻印出原本的 Map。
常見的搞笑狀況:你很認真寫了 val sorted = map.entries.sortedByDescending{...},但在下面某行又不小心寫了 println(map)。結果 console 裡一點也沒排序,你開始懷疑 Kotlin 在搞你,但其實只是那一行的問題。好習慣是:報表只印你真的準備好的內容(通常是 sorted 或已組好的字串 report)。
錯誤 №5:不做 key 的正規化,還期待「應該也差不多」。
如果 key 來自使用者輸入或「髒」字串,那 "Food"、" food "、"FOOD" 會變成三個不同 key,你的「分類 Top」看起來就像你在三個不同宇宙吃飯一樣。正規化(trim().lowercase())應該在分組與計數之前就做;否則你會漂亮地排序並輸出……錯誤的統計。
GO TO FULL VERSION