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() 後 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
請注意:"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)。
GO TO FULL VERSION