CodeGym /課程 /Kotlin SELF /指令分派:輸入正規化與 when

指令分派:輸入正規化與 when

Kotlin SELF
等級 13, 課堂 4
開放

1. 指令分派與把 when 當作「動作對照表」

當你在寫第一批程式時,通常一切看起來都是線性的:問一個數字、計算、輸出。但很快你就會想讓程式「活起來」:讓它能接受像 help、add 10、show、exit 這樣的指令,而且不會做完一件事就結束。這就是最簡單的 CLI(command-line interface),而在其中你需要能夠根據使用者輸入的文字來選擇要做什麼。

想像你在做一個迷你應用「Wallet v0」(不用清單、不用檔案、不用資料庫——純粹練邏輯)。使用者輸入指令,程式要嘛改變目前狀態(例如餘額)、要嘛顯示資訊、要嘛印出說明。核心問題是:怎麼把「指令字串」乾淨地對應到正確動作,讓程式碼不要變成一片「if 森林」,看了就迷路。

這時 when 就是理想的分派器:它讀起來像一張「指令表」。Kotlin 明確把 when 定位為多分支結構,類似 switch,並強調 when 既可以當作運算式(expression),也可以當作敘述(statement)。

用 when 做最小可用的指令分派器

當你已經拿到正規化過的指令後,when 幾乎就是完美的分派器:每個分支是一條指令,而 else 則是清楚的訊息「我不知道這是什麼」。就算是第一次看到這段程式的人,也能很快讀懂:從上到下就是支援的指令清單。

Kotlin 強調 when 會依序比對分支,並且可用作 statement(執行動作)或 expression(回傳值)。對指令分派來說,多半是 statement 風格最合適:「如果指令是這個——就做那個」。

fun main() {
    val command = readln().trim().lowercase()

    when (command) {
        "help" -> println("Commands: help, exit")     // Commands: help, exit
        "exit" -> println("Shutting down...")         // Shutting down...
        else -> println("Unknown command: $command")
    }
}

這裡要注意的是 else 必須放在最後。這不只是「習慣」:如果你把 else 放前面,下面的分支會變成不可達(unreachable),編譯器(以及未來的你)都會不高興。

2. 輸入前處理:正規化、空輸入、指令與參數

輸入正規化:trim() 與 lowercase()

如果你拿使用者輸入直接去跟指令「原樣比對」,程式就會對空白與大小寫很敏感。指令 Help、HELP、 help 和 help 在人的角度是一樣的;在「原始字串」角度卻是四個不同值。因此在分析之前,輸入通常需要正規化:去掉兩端空白並統一大小寫。

最基本、也幾乎總是夠用的組合是:trim()(去掉兩端空白)與 lowercase()(全部轉小寫)。重點是:trim() 不會刪掉字串中間的空白,這很好——否則指令 add 10 會變成 add10,參數就像洗衣後消失的襪子一樣不見了。

來看個迷你例子:讀入一行並把它變成適合比較的「穩定版本」。

fun main() {
    val raw = readln()
    val normalized = raw.trim().lowercase()

    println("raw='$raw'")                 // raw='   HeLp  '
    println("normalized='$normalized'")   // normalized='help'
}

接下來程式只比對 normalized。這不是「魔法」,而是一種紀律:先把資料統一成標準格式,後面就能更穩定地處理。

想更直觀理解正規化,腦中可以先放著這張表:

使用者輸入 套用 trim() 後 套用 trim().lowercase() 後
"help"
"help"
"help"
" Help "
"Help"
"help"
"EXIT"
"EXIT"
"exit"
"  add 10  "
"add 10"
"add 10"

請注意:"add 10" 套用 lowercase() 後不會壞掉——中間的空白仍然存在,因此我們可以在之後再把參數分離出來。

空字串是一個需要獨立處理的情況

當使用者按下 Enter 時,有時他輸入的是空字串。這很常見:人會手滑、會測試「如果我什麼都不輸入會怎樣……」,或只是貓咪在鍵盤上走了一圈(貓咪是世界上最重要的 QA 工程師,只是牠不會簽 bug report)。

空字串應該被明確處理,否則你在 when 中會得到「Unknown command: ""」,使用者會看不懂發生什麼事。技術上程式沒錯,但心理上聽起來像是:「未知請求:(沉默)」。

最簡單的模式是:在 trim() 之後檢查 isEmpty(),並做出清楚的反應——例如繼續下一輪迴圈或輸出提示。

fun main() {
    val input = readln().trim()
    if (input.isEmpty()) {
        println("Please enter a command. For help: help") // Please enter a command. For help: help
        return
    }

    println("Command accepted: '$input'") // e.g.: Command accepted: 'help'
}

這裡我們用 return,因為例子很短。在真正「活著的」程式(有迴圈)中,你更常用 continue,直接跳到下一次輸入。

不用 split() 拆「指令 + 參數」:indexOf(' ') 與 substring(...)

指令常常會帶參數:add 10、set 100、echo 你好。 你當然可以用 split(" "),但我們今天刻意先不走這條路:第一,這會牽涉到更完整的「字串解析」主題;第二,split 很容易帶出許多細節(空片段、多個空白等等),而我們現在要的是可控、手工也能完全理解的解析方式。

想法很簡單:找第一個空白。左邊是指令;右邊是參數(可能是空)。如果沒有空白——就沒有參數。

用圖示可以這樣表示:

flowchart TD
    A["raw input: '  add  10  '"] --> B["trim(): 'add  10'"]
    B --> C["lowercase(): 'add  10'"]
    C --> D["indexOf(' ') -> 3"]
    D --> E["command = substring(0, 3) -> 'add'"]
    D --> F["arg = substring(4).trim() -> '10'"]
    E --> G["when(command) {...}"]

接著是實作程式碼。我們會把 command 與 arg 的萃取寫得很直白,讓你清楚看到每一步在做什麼。

fun main() {
    val raw = readln().trim()
    val spaceIndex = raw.indexOf(' ')

    val command = if (spaceIndex == -1) raw.lowercase() else raw.substring(0, spaceIndex).lowercase()
    val arg = if (spaceIndex == -1) "" else raw.substring(spaceIndex + 1).trim()

    println("command='$command', arg='$arg'") // e.g.: command='add', arg='10'
}

幾個通常能救你免於奇怪 bug 的小細節:

  • 第一,參數再多做一次 trim(),確保 add 10 仍然得到 10,而不是 " 10"。
  • 第二,指令要用 lowercase()(通常也已經在 trim() 後)正規化,因為它會被用來比較。

3. 專案:「Wallet v0」

現在我們把全部內容組成一個小型主控台程式:它在迴圈中運作、接受指令並改變一個數字——餘額。這不是「夢想中的最終產品」,而是一個訓練器:它教你寫出可預期的指令處理、避免在字串處理上搞混。接下來課程的其他日子會逐步強化這個做法(也會出現更強的工具),但今天的目標是把「分派」寫乾淨。

迴圈骨架:讀指令直到要求退出

先從最基本開始:無限迴圈、讀一行、指令 exit 就退出。

fun main() {
    while (true) {
        val cmd = readln().trim().lowercase()

        if (cmd == "exit") break
        println("You entered: $cmd") // e.g.: You entered: help
    }

    println("Bye!") // Bye!
}

這還不是 when,但它是命令式程式的最小「心跳」。

用 when 加入指令表

現在把 if (cmd == "exit") 換成 when。 重點:我們希望 exit 真的能結束迴圈,因此在分支中使用 break。

fun main() {
    while (true) {
        val cmd = readln().trim().lowercase()

        when (cmd) {
            "help" -> println("Commands: help, show, exit") // Commands: help, show, exit
            "show" -> println("Nothing to show yet")        // Nothing to show yet
            "exit" -> break
            else -> println("Unknown command: $cmd")
        }
    }
}

請注意:這裡的 when 是 statement(敘述)用法。statement 形式不要求你覆蓋所有可能分支,但在 CLI 中 else 幾乎一定要有——否則「什麼都沒發生」會像程式壞了。

切換到「指令 + 參數」,並引入狀態 balance

接著加入變數 balance,以及會改變它的指令。我們再次使用「讀取 → 前處理 → 解析」的模式,避免對隨機文字直接做 toInt()。

fun main() {
    var balance = 0
    val raw = readln().trim()
    val spaceIndex = raw.indexOf(' ')

    val command = if (spaceIndex == -1) raw.lowercase() else raw.substring(0, spaceIndex).lowercase()
    val arg = if (spaceIndex == -1) "" else raw.substring(spaceIndex + 1).trim()

    println("Balance=$balance, command=$command, arg=$arg") // Balance=0, command=add, arg=10
}

目前我們只是在「拆解字串」。接下來才是重點:指令決定動作,參數提供動作所需資料。

需要數字的指令:toIntOrNull() 與清楚的訊息

數字參數要用安全方式解析。我們已經知道 toIntOrNull() 會回傳 Int?,也就是要嘛是一個數字,要嘛是 null。 接著有兩種好做法:用 if (value == null), 或用 when (value) 並加上 null 分支。 這能避免堆出一堆巢狀檢查。

做一小段邏輯示範:指令 add,參數是數字。

fun main() {
    val arg = "10"
    val delta: Int? = arg.toIntOrNull()

    when (delta) {
        null -> println("Need a number, not '$arg'") // if arg="abc"
        else -> println("OK, we'll add $delta")      // OK, we'll add 10
    }
}

現在把這個做法搬到指令處理中。

「Wallet v0」的幾乎最終版本

下面是一個可以完整貼上就跑的精簡版本。它依然很短,但已經很適合作為模板。訊息寫得偏「人類可讀」:CLI 不是考你心電感應。

fun main() {
    var balance = 0

    while (true) {
        val raw = readln().trim()
        if (raw.isEmpty()) continue

        val spaceIndex = raw.indexOf(' ')
        val command = if (spaceIndex == -1) raw.lowercase() else raw.substring(0, spaceIndex).lowercase()
        val arg = if (spaceIndex == -1) "" else raw.substring(spaceIndex + 1).trim()

        when (command) {
            "help" -> println("Commands: help, show, set N, add N, sub N, exit")
            "show" -> println("Balance: $balance")            // Balance: 0
            "exit" -> break
            "set", "add", "sub" -> {
                val value = arg.toIntOrNull()
                if (value == null) {
                    println("Need a number after '$command'") // Need a number after 'add'
                } else {
                    balance = when (command) {
                        "set" -> value
                        "add" -> balance + value
                        else -> balance - value               // sub
                    }
                    println("OK. Balance: $balance")
                }
            }
            else -> println("Unknown command: $command")
        }
    }

    println("Exit. Final balance: $balance")
}

在 "set", "add", "sub" 這個分支裡,我們又用了第二個 when 作為運算式:回傳新的餘額值。 when 作為 expression 時必須是可窮盡的(必須保證一定回傳值),因此我們需要 else(在這裡它在邏輯上代表 sub)。

是的,這是「雙層 when」,但讀起來仍然合理:外層 when 選指令,內層決定更新餘額的公式。更重要的是:我們沒有把「辨識指令」與「數學運算」攪在一起——先拆出 command/arg,再處理。

4. when (value) 與 when { ... } 在指令程式中的使用時機

到這裡很多人會開始困惑:「我能不能永遠都用 when { ... },因為它看起來像 if?」可以,但不一定必要。核心規則是:選擇能讓程式碼更一目了然的形式。

如果你是在把同一個值跟一組候選值比較(例如把 command 與字串 "help"、"exit" 比對), 那 when (command) 讀起來就像指令手冊。 如果你的邏輯是由條件構成(例如「指令為空」、「太短」、「以 # 開頭」),那用 when { ... } 會更順。

迷你例子:把「輸入品質」視為一組條件。

fun main() {
    val input = readln().trim()

    val status = when {
        input.isEmpty() -> "empty"
        input.length < 3 -> "too short"
        else -> "looks like a command"
    }

    println(status) // e.g.: looks like a command
}

而 when (command) 則用於分派:

fun main() {
    val command = readln().trim().lowercase()

    when (command) {
        "help" -> println("Help")              // Help
        "exit" -> println("Exit")              // Exit
        else -> println("I don't understand: $command")
    }
}

在 CLI 中,你通常會把兩者結合:先用 when { ... } 做「檢查與過濾雜訊」,再用 when (command) 做「指令對照表」。

5. 常見錯誤

錯誤 1:不做正規化就直接比對指令。
如果你寫 when (readln()) { "help" -> ... },那麼指令 " help"(前面有空白)或 "HELP"(大寫)就會突然變成「未知指令」。 形式上程式是對的,但使用者會覺得自己被判錯——然後就不再信任程式了。 更好的做法是先 trim().lowercase(),再比較。

錯誤 2:在確認是否需要參數前就先解析參數。
新手常見寫法是:讀完一行立刻做 val n = arg.toInt(),然後才去判斷輸入的是哪個指令。 結果就是:輸入 help 也會試圖解析不存在的「參數」,然後程式直接崩掉。 流程應該是:先判斷指令,再決定是否需要參數,最後才解析。

錯誤 3:不處理空字串。
若不把空輸入獨立成一種情況,你會得到沒有解釋的「未知指令」,或在後續程式假設指令非空時出現奇怪錯誤。 加上幾行 if (raw.isEmpty()) continue,就能大幅提升友善度與穩定性。

錯誤 4:把 when 的分支寫得太大、太「多層樓」。
when 很適合當分派器,但如果每個分支都有 20 行邏輯,它很快就會變成「程式裡面再長出一個小程式」。 今天我們刻意讓分支保持短小:辨識指令是一步,計算餘額是另一步。 Kotlin 也強調分支可以是區塊(block),這很方便,但不代表你要在單一分支裡寫一整部小說。

錯誤 5:在語意需要時忘記寫 else。
即使 when 作為 statement 時可以不寫 else 也能編譯,在 CLI 裡「什麼都沒發生」看起來就是 bug。 而當你把 when 當作 expression 使用時,編譯器會要求可窮盡處理(通常就是透過 else)。

1
任務
Kotlin SELF, 等級 13, 課堂 4
上鎖
命令控制台
命令控制台
1
任務
Kotlin SELF, 等級 13, 課堂 4
上鎖
控制台靜默
控制台靜默
1
任務
Kotlin SELF, 等級 13, 課堂 4
上鎖
指令與參數
指令與參數
1
任務
Kotlin SELF, 等級 13, 課堂 4
上鎖
值班終端
值班終端
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION