1. CLI — 也是 UX
當你撰寫命令列程式時,沒有漂亮的按鈕、提示或彈出視窗,但使用者依然在與介面「互動」。如果介面是「應付了事」,使用者會受苦,然後你也會受苦(因為你會收到「為什麼不會用?」之類的問題)。因此應把 CLI 視為一個小型的契約:可以傳入什麼、程式做什麼、會輸出什麼,以及如何判斷是否成功。
核心概念:CLI 是一個契約。這個契約通常包含四個必備部分:--help、預設值(如果沒有提供選項會怎樣)、對使用者友善的錯誤訊息,以及清楚的退出碼。今天我們將這些組合成一個圍繞教學應用的骨架。
作為示範,我們取一個簡單工具 FocusTimer:目前它不會「等待」的計時器(那牽涉到執行緒與等待,今日不討論),但會誠實地計算專注階段計畫:工作分鐘數、休息分鐘數、週期數。我們關心的不是商業邏輯,而是介面設計。
2. --help:如何讓程式自我文件化
參數 --help 不是「額外功能」,而是任何 CLI 工具的正常行為。使用者不應該以讀原始碼的方式來獲取說明。好的 --help 應回答兩個問題:「如何執行」和「有哪些參數」。還有一個重要細節:幫助請求不是錯誤,程式應以成功(退出碼 0)結束,而不是「被冒犯」地返回錯誤碼。
Usage 訊息:應該包含什麼
在寫程式碼前,先約定格式是有幫助的。我們採用簡單且健壯的版本:
- 第一行 — 簡短的執行範例,
- 接著 — 選項與預設說明,
- 最後 — 一到兩個執行範例。
建議不只在 std::cout 輸出 usage,有時在 std::cerr(例如參數錯誤時)也會印出。因此最好透過參數 std::ostream& 來列印 usage。
#include <iostream>
void print_usage(std::ostream& out) {
out << "FocusTimer - 專注時段計畫\n";
out << "Usage:\n";
out << " focustimer [--work MIN] [--break MIN] [--cycles N] [--help]\n";
}
注意:我們不使用任何平台的「魔法」或複雜化,這只是一段純文字。
處理 --help 與 -h:早期退出
好習慣是儘早處理 --help 並立即結束程序。這稱為「早期退出」:減少巢狀 if,更易閱讀。
#include <iostream>
#include <string_view>
int main(int argc, char* argv[]) {
for (int i = 1; i < argc; ++i) {
std::string_view arg = argv[i];
if (arg == "--help" || arg == "-h") {
print_usage(std::cout);
return 0;
}
}
// ... 主要程式碼
}
如果使用者明確請求幫助,沒有必要繼續解析其他參數。他們不是來「執行任務」——而是來瞭解如何使用。
3. 預設值:讓程式在沒有參數時也能工作
預設值讓 CLI 更友善。使用者可以直接執行程式並得到合理結果。這在教學或工具程式中尤其重要:若無參數執行就報錯,介面會給人「門被鎖住且無把手」的感覺。
在 FocusTimer 我們設定預設值:work = 25 分鐘、break = 5 分鐘、cycles = 4。這是番茄鐘(Pomodoro)樣式,但不談心理學——只是數字。
設定結構化的 config:用一個物件取代散落的變數
當參數超過兩個時,將它們放在結構中相當有用。這樣你不會在程式各處傳三個獨立變數,也較不會忘記哪個是什麼。
struct Config {
int work_minutes = 25;
int break_minutes = 5;
int cycles = 4;
};
這樣就讀起來像「設定」,而不是「隨機的 int」。
預設值應讓使用者看得見
常見 UX 錯誤之一是預設值只存在於程式碼中,但沒有顯示在 --help 中。使用者查看 --help 時,若看不到沒有指定時會發生什麼,就無從得知。因此應在 usage 中直接展示預設值。
#include <iostream>
void print_usage(std::ostream& out) {
out << "FocusTimer - 專注時段計畫\n";
out << "Usage:\n";
out << " focustimer [--work MIN] [--break MIN] [--cycles N] [--help]\n\n";
out << "Options (defaults):\n";
out << " --work MIN minutes of work (default 25)\n";
out << " --break MIN minutes of break (default 5)\n";
out << " --cycles N number of cycles (default 4)\n";
out << " -h, --help show this help\n";
}
文字雖多,但可以節省大量解釋時間。
4. 使用者錯誤:簡短、切中要點並提供提示
錯誤有很多種。可能是「使用者犯錯」(輸入未知選項、忘記值、提供負數)。也可能是「程式錯誤」(例如你在程式中出錯)。在 CLI UX 範疇,我們關注前者:使用者錯誤應該清楚易懂,不要用誇張語氣或輸出「Segmentation fault (core dumped)」那種東西。
基本規則很簡單:將錯誤輸出到 std::cerr,以一、兩行表述,並加上「Use --help」的提示。就像有禮貌的警衛:「這裡不能進,但這邊有門可以走」。
std::cout 與 std::cerr:分離正常輸出與問題
有個不成文的約定:程式的正常輸出打印到 std::cout,而錯誤或診斷資訊打印到 std::cerr。這很重要,因為 stdout 常被重定向到檔案或另一個程式,使用者不希望你的錯誤訊息進到那個檔案中。
「未知選項」範例:
#include <iostream>
#include <string_view>
void print_unknown_option(std::string_view opt) {
std::cerr << "Error: unknown option: " << opt << '\n';
std::cerr << "Use --help to see available options.\n";
}
這訊息短且清楚,並指明下一步。
「缺少值」錯誤 — argv 中最常見的一種
若選項格式是 --key value,最常見的錯誤是使用者只寫了 --key 而未給值。許多新手會在未檢查邊界情況下直接讀取 argv[i + 1],導致未定義行為。我們的做法是先檢查邊界,然後給出合理錯誤。
#include <iostream>
#include <string_view>
bool require_value(int i, int argc, std::string_view opt) {
if (i + 1 >= argc) {
std::cerr << "Error: " << opt << " requires a value\n";
std::cerr << "Use --help to see usage.\n";
return false;
}
return true;
}
這是一個小函式,但能讓程式更有紀律:不靠直覺而是檢查。
5. 退出碼:讓腳本知道發生了什麼
流程的退出碼就是 main 返回的值。對於人類可能不顯眼,但對自動化(腳本、CI、其他程式)來說,它是主要的「成功/錯誤」信號。慣例是:0 表示成功,任何非零表示錯誤。接著就是設計問題:如何區分不同錯誤。
我們採用最小且合理的政策:0 — 成功,2 — 參數錯誤。為何選 2?因為 1 常用於「一般錯誤」,而 2 很適合表示 bad args。這不是鐵律,但是一個有用的約定。
為何不直接寫 return 2;
因為一週後你看到 return 2; 會想:「2 是什麼意思?兩隻鸚鵡?兩個週期?兩個錯誤?」所以我們宣告一個 enum class ExitCode。
enum class ExitCode : int {
ok = 0,
bad_args = 2
};
然後這樣返回:
return static_cast<int>(ExitCode::bad_args);
雖然寫起來稍長,但程式可讀性像自然語言一樣好。
退出碼表格
有時候表格是說明策略最簡潔的方式。我們不需要十個碼,但即便只有兩個也值得說明清楚。
| 碼 | 代稱 | 意義 |
|---|---|---|
|
|
一切成功,結果正確 |
|
|
使用者傳入了不正確的參數 |
如果你將工具交由其他程式呼叫,這張表會比你的審美感更重要。
6. 組裝 FocusTimer 骨架:parse_args、預設與錯誤處理
現在我們把之前的概念組合成小架構:main 保持精簡,print_usage 為獨立函式,parse_args 也是獨立函式。是的,今天仍然放在「單一檔案」,因為重點是學習 UX,而不是專案目錄結構。
首先實作一個穩健的數字解析。我們想解析 int,不要例外,也不要「噢掉了」。使用 std::from_chars 很合適:它回傳錯誤碼,而不是擲出例外。
#include <charconv>
#include <optional>
#include <string_view>
std::optional<int> parse_int(std::string_view s) {
int value = 0;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec != std::errc{} || ptr != s.data() + s.size()) return std::nullopt;
return value;
}
這裡一個重要的 UX 考量是:我們把「非數字」和「有尾巴的數字」視為錯誤(例如 "10min")。對 CLI 而言,這通常更合理:使用者應看到格式錯誤。
接著檢查「正整數」(分鐘與週期數應 > 0)。
#include <optional>
#include <string_view>
std::optional<int> parse_positive_int(std::string_view s) {
auto v = parse_int(s);
if (!v || *v <= 0) return std::nullopt;
return v;
}
下一步是 parse_args。我們返回 ExitCode,並通過參考填充 Config。若遇到錯誤,輸出錯誤並返回 bad_args。
#include <iostream>
#include <string_view>
ExitCode parse_args(int argc, char* argv[], Config& cfg) {
for (int i = 1; i < argc; ++i) {
std::string_view arg = argv[i];
if (arg == "--work") {
if (!require_value(i, argc, arg)) return ExitCode::bad_args;
auto v = parse_positive_int(argv[i + 1]);
if (!v) { std::cerr << "Error: --work must be a positive integer\n"; return ExitCode::bad_args; }
cfg.work_minutes = *v;
++i;
}
}
return ExitCode::ok;
}
片段雖短,但已展示風格:檢查邊界、檢查數值、清楚錯誤。注意在「吃掉值」後的 ++i —— 這不是裝飾,而是必要邏輯。
我們按相同範本加入其他參數(是的,有點重複,但這種形式的 CLI 解析就是「可控的枯燥」)。
ExitCode parse_args(int argc, char* argv[], Config& cfg) {
for (int i = 1; i < argc; ++i) {
std::string_view arg = argv[i];
if (arg == "--work" || arg == "--break" || arg == "--cycles") {
if (!require_value(i, argc, arg)) return ExitCode::bad_args;
auto v = parse_positive_int(argv[i + 1]);
if (!v) { std::cerr << "Error: " << arg << " must be a positive integer\n"; return ExitCode::bad_args; }
if (arg == "--work") cfg.work_minutes = *v;
if (arg == "--break") cfg.break_minutes = *v;
if (arg == "--cycles") cfg.cycles = *v;
++i;
continue;
}
if (arg == "--help" || arg == "-h") {
print_usage(std::cout);
return ExitCode::ok;
}
if (arg.starts_with("-")) {
print_unknown_option(arg);
return ExitCode::bad_args;
}
}
return ExitCode::ok;
}
這裡我們做了一個重要的 UX 約定:所有以 - 開頭且未知的參數都視為選項並報錯。至於位置參數(例如檔名)如何處理,屬於後續架構設計與下一堂課要討論的內容;今天我們有意把契約保持簡單。
剩下的是 main:預設 config、解析、處理結果、以及正常輸出。
#include <iostream>
int main(int argc, char* argv[]) {
Config cfg{};
ExitCode code = parse_args(argc, argv, cfg);
if (code != ExitCode::ok) return static_cast<int>(code);
std::cout << "Plan: " << cfg.cycles << " cycles\n";
std::cout << "Work " << cfg.work_minutes << " min, break " << cfg.break_minutes << " min\n";
return static_cast<int>(ExitCode::ok);
// Example output:
// Plan: 4 cycles
// Work 25 min, break 5 min
}
從 UX 角度來看,這已經是個友善的程式:有 help、有預設、有清楚錯誤,還有明確的退出碼。
7. 使用者情境示例
實際或心裡跑過典型案例很有幫助:「無參數執行」、「請求 help」、「選項名稱錯誤」、「值錯誤」。在這個階段你會開始看出介面是友善還是「就是這樣寫的」。
情境 1:使用者直接執行程式且不帶參數。這應該可以正常工作。
$ focustimer
Plan: 4 cycles
Work 25 min, break 5 min
情境 2:使用者請求 help。這不是錯誤,退出碼應為 0。
$ focustimer --help
FocusTimer - 專注時段計畫
Usage:
focustimer [--work MIN] [--break MIN] [--cycles N] [--help]
...
情境 3:使用者輸入了錯誤的選項名稱。我們不會「崩潰」,而是解釋給他聽。
$ focustimer --works 30
Error: unknown option: --works
Use --help to see available options.
情境 4:使用者忘記給值。這是典型錯誤,重要的是不越界讀取 argv。
$ focustimer --work
Error: --work requires a value
Use --help to see usage.
情境 5:提供了值,但格式不對。
$ focustimer --cycles 0
Error: --cycles must be a positive integer
注意風格:簡短且不居高臨下。程式不該給使用者上道德或教訓課——那是老師的事(例如我),不是工具的。
8. CLI UX 的常見錯誤
錯誤 №1:把 --help 當作錯誤並返回非零碼。
看似小事,但會破壞自動化:腳本可能用 tool --help 來檢查命令是否存在,並預期得到 0。若返回 1 或 2,系統會誤判「出問題了」,儘管使用者只是請求幫助。在正確的 CLI 中,幫助是成功案例。
錯誤 №2:usage 只在一處印出,發生錯誤時使用者拿不到提示。
當程式只輸出「Error: bad input」卻不說明該怎麼做時,使用者會猜測哪些參數需要提供。正確做法至少輸出一行「Use --help」。在某些情況下,於錯誤時把 usage 印到 std::cerr 也是合理的,但最低限度應提供提示。
錯誤 №3:把使用者錯誤輸出到 std::cout。
手動測試時看起來不礙事,但一旦使用者執行 focustimer > plan.txt,他期望檔案裡只有計畫,而不是「Error: unknown option」。這就是為什麼要使用 std::cerr:把結果與問題分開。
錯誤 №4:在 return 中使用魔法數字。
在簡單程式裡寫 return 2; 看似沒問題,但久了你會忘記 2 代表什麼。用 enum class ExitCode 可以讓行為在碼面上自說自話,對未來的你有保護作用。
錯誤 №5:未把預設值契約化。
若預設只在程式碼中存在而未寫入 --help,使用者會自行猜測。有的人會認為 --cycles 預設是 1,有人認為是 10,有人認為程式會自動決定。結果介面顯得難以預測。預設是介面的一部分,因此應在 usage 清楚列出。
GO TO FULL VERSION