CodeGym /課程 /Kotlin SELF /結果後處理 — mapValues、排序與輸出準備

結果後處理 — mapValues、排序與輸出準備

Kotlin SELF
等級 24, 課堂 2
開放

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?」

為了不混淆,可以記住一個小小的對照表:

任務 工具 結果型別 何時方便
顯示每個分組內的細節
groupBy
Map<K, List<T>>
當你真的會去遍歷分組裡的元素
計算「每個 key 有幾個元素」
groupingBy().eachCount()
Map<K, Int>
當你根本不需要分組元素的清單
已經有
groupBy
,但現在想要摘要
mapValues { size }
Map<K, Int>
當你不想把整個 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())應該在分組與計數之前就做;否則你會漂亮地排序並輸出……錯誤的統計。

1
任務
Kotlin SELF, 等級 24, 課堂 2
上鎖
字母彙總
字母彙總
1
任務
Kotlin SELF, 等級 24, 課堂 2
上鎖
平均成績
平均成績
1
任務
Kotlin SELF, 等級 24, 課堂 2
上鎖
按字母順序的報表
按字母順序的報表
1
任務
Kotlin SELF, 等級 24, 課堂 2
上鎖
銷售排行榜
銷售排行榜
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION