1. 空白、token 與引號

如果你之前寫過互動式程式,可能遇過這種情況:「我輸入的商品名稱是 milk chocolate,但程式只讀到 milk。」這不是編譯器的錯,也不是什麼黑魔法。這是 operator>> 的行為:預設情況下它會以空白字元(空格、tab、換行)來分割串流。對數字通常沒問題,但對「名稱」「訊息」「地址」等含空白的欄位就成問題了。

想像我們在做一個迷你 CLI(命令列選單),使用者輸入命令:

  • add milk 2
  • add milk chocolate 2

在第二種情況下格式會變得模糊:milk 是名稱嗎?還是名稱的第一部分?或是另一個命令?串流解析器不會「猜測」——它就是照 token 讀。

處理文本格式的經典解法是:如果欄位可能包含空白,就把它用引號包起來。這樣合約就清楚了:

  • 命令:不含空白的單字
  • 名稱:用引號包起的字串,內部可以有空白
  • 數量:數字

也就是:add "milk chocolate" 2。

接下來要學會如何方便地讀取它。可以自己手動切字串、找第一個與最後一個引號、擷取子串⋯⋯但今天我們選更簡單的:請標準函式庫幫忙做這件事。

2. std::quoted:是什麼、在哪裡包含

當你在 C++ 中看到像 std::fixed、std::setprecision、std::setw 之類的東西時,這些都是串流操控器(manipulators):小物件用來改變輸入/輸出的行為。std::quoted 就是同一類的工具。

技術上 std::quoted 是一個可以放在 << 或 >> 鏈中的操控器。

  • 輸出時它會把字串以引號包起並轉譯特殊字元(通常是引號與反斜線)。
  • 輸入時它能把引號包起來的字串當作一個邏輯 token 來讀,移除外層引號並正確處理轉譯序列。

要使用 std::quoted,需要包含標頭:

#include <iomanip>   // std::quoted

當然還要有串流:

#include <iostream>
#include <sstream>   // 如果從字串讀,使用 istringstream
#include <string>

簡單檢查「它確實存在」:

#include <iomanip>
#include <iostream>
#include <string>

int main() {
    std::string s = "hello world";
    std::cout << std::quoted(s) << '\n'; // "hello world"
}

注意:即使字串沒有空白,std::quoted 仍會加上引號。這是正常的:我們在明確要求「以 quoted 格式輸出」。

3. 輸出:讓字串安全

設計文本格式時,我們不只要會讀,也要會產生相同格式的字串。這時 std::quoted 特別好用:它提供「寫入 ↔ 讀取」的對稱性。你以引號寫出字串,之後再讀回來——在合理的限制下會得到相同值。

先從簡單的情況:包含空白的字串。

#include <iomanip>
#include <iostream>
#include <string>

int main() {
    std::string item = "milk chocolate";
    std::cout << std::quoted(item) << '\n'; // "milk chocolate"
}

更重要的是:字串內含引號。例如名稱是 Bob said "hi",若不轉譯格式會崩掉:哪些引號是欄位的開關,哪些是欄位內的字元?

std::quoted 在輸出時會用反斜線來轉譯引號。

#include <iomanip>
#include <iostream>
#include <string>

int main() {
    std::string s = "Bob said \"hi\"";
    std::cout << std::quoted(s) << '\n'; // "Bob said \"hi\""
}

這裡有個重要觀念(稍後會再回到):在此範例中有兩個不同層次的轉譯。在 C++ 原始碼中你使用 \",因為字面值語法如此;而 std::quoted 輸出的 \" 則是資料格式內的轉譯。符號看起來一樣,但語意不同。

若你要把「命令」組成字串用於日誌或暫存,可以用 std::ostringstream:

#include <iomanip>
#include <sstream>
#include <string>

int main() {
    std::string name = "milk chocolate";
    int count = 3;

    std::ostringstream oss;
    oss << "add " << std::quoted(name) << ' ' << count;

    // oss.str() == add "milk chocolate" 3
}

目前我們還沒把東西「寫入檔案」(那是另一堂),但已經學會如何構造正確的格式化字串。

4. 輸入:把 quoted 字串當作一個 token 讀取

現在最實用的部份:如何讓 "milk chocolate" 被視為一個欄位,而不是兩個單字 milk 和 chocolate。

做法是:像平常一樣從串流讀取,但對字串欄位使用 std::quoted(str)。

#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::string line = "add \"milk chocolate\" 3";
    std::istringstream iss(line);

    std::string cmd;
    std::string name;
    int count{};

    if (iss >> cmd >> std::quoted(name) >> count) {
        std::cout << cmd << " | " << name << " | " << count << '\n';
        // add | milk chocolate | 3
    } else {
        std::cout << "bad format\n";
    }
}

這裡要養成的習慣是:在確認讀取成功之前不要使用變數的值。新手常犯的錯誤是寫了 iss >> cmd >> std::quoted(name) >> count;,然後不檢查就印出 name。一旦讀取失敗,串流會進入 fail 狀態,變數可能保留舊值或部分填入——於是就出現「有時候工作有時候不工作」的狀況。

另一個重點:std::quoted(name) 預期在串流中會出現用引號包起來的字串(預設是雙引號 ")。如果沒有引號,讀取通常不會依合約表現。這是好事:格式被破壞就應該回報錯誤(例如印出 "bad format"),而不是去猜測。

5. 兩層轉譯:資料與 C++ 原始碼

這部分常讓人有「恍然大悟但又霧裡看花」的表情,因為實際上有兩個層次,腦袋會試圖把它們混為一談。

第一層是 C++ 字串字面值內的轉譯。也就是你在原始碼中寫的:

  • "\n" — 一個換行字元
  • "\"" — 一個雙引號
  • "\\ " — 一個反斜線(後面有個空格)

第二層是你要解析的資料格式內的轉譯。例如在 quoted 格式中,欄位內的引號必須寫成 \"

看一個資料中包含引號的例子。

資料(使用者在 CLI 可能輸入的內容):

add "milk \"dark\"" 1

也就是商品名稱是:milk "dark"。

但如果我們要把這個字串放到 C++ 字面值(用來測試),就必須按照 C++ 的規則再轉譯一次:

#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::string line = "add \"milk \\\"dark\\\"\" 1";
    std::istringstream iss(line);

    std::string cmd;
    std::string name;
    int count{};

    if (iss >> cmd >> std::quoted(name) >> count) {
        std::cout << name << '\n';  // milk "dark"
    }
}

請注意:在 C++ 原始碼中出現的是 \\\"。這表示「在資料字串中會有 \"」。而 \" 在 quoted 字串內代表「欄位內的引號」。

好消息是,真實情況下這些字串通常不是寫在原始碼中,而是來自使用者、檔案或網路——此時只有一個轉譯層(資料格式的層次)。

同時要理解,std::quoted 在讀取時會「吃掉」外層引號並「把內部的轉譯序列還原」。換句話說,放到 name 的會是已經解開轉譯的乾淨字串,便於後續處理。

6. 迷你 CLI「購物清單」

為了讓 std::quoted 不只是理論上的「很酷的東西」,我們把它整合到一個簡單的控制台應用。暫時不使用 struct(日後課程會介紹),改用平行儲存方式:用 vector<string> 存名稱,用 vector<int> 存數量。這不是完美設計,但我們的目標是練習解析,而不是資料建模。

先實作儲存與列印清單:

#include <iostream>
#include <string>
#include <vector>

void printList(const std::vector<std::string>& names,
               const std::vector<int>& counts) {
    for (std::size_t i = 0; i < names.size(); ++i) {
        std::cout << i << ": " << names[i] << " x" << counts[i] << '\n';
    }
}

現在實作解析 add 命令。它接受整行字串,嘗試擷取命令、quoted 名稱與數量。

#include <iomanip>
#include <sstream>
#include <string>

bool parseAdd(const std::string& line, std::string& name, int& count) {
    std::istringstream iss(line);
    std::string cmd;

    if (!(iss >> cmd >> std::quoted(name) >> count)) return false;
    if (cmd != "add") return false;

    std::string extra;
    return !(iss >> extra); // no extra tokens
}

注意這最後一個檢查。它讓合約更嚴格:如果使用者輸入 add "milk" 2 blah,我們會認定為格式錯誤,而不是默默忽略後面的內容。有時候做「best effort」有用,但在教學用的解析器中嚴格模式更容易除錯。

接下來是命令處理器。如果商品不存在就加入新項目;若已存在則累加數量。為了搜尋,我們寫一個小函式:

#include <string>
#include <vector>

int findIndex(const std::vector<std::string>& names, const std::string& name) {
    for (std::size_t i = 0; i < names.size(); ++i) {
        if (names[i] == name) return static_cast<int>(i);
    }
    return -1;
}

然後套用它:

#include <string>
#include <vector>

void addItem(std::vector<std::string>& names, std::vector<int>& counts,
             const std::string& name, int count) {
    int idx = findIndex(names, name);
    if (idx == -1) {
        names.push_back(name);
        counts.push_back(count);
    } else {
        counts[static_cast<std::size_t>(idx)] += count;
    }
}

接著組成「讀取命令的迴圈」。我們使用 std::getline 讀整行,因為命令內的引號可能包含空白(這正是我們關注的)。

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> names;
    std::vector<int> counts;

    std::string line;
    while (std::getline(std::cin, line)) {
        if (line == "exit") break;

        std::string name;
        int count{};

        if (parseAdd(line, name, count)) {
            addItem(names, counts, name, count);
            std::cout << "ok\n";
        } else if (line == "list") {
            printList(names, counts);
        } else {
            std::cout << "unknown or bad format\n";
        }
    }
}

現在格式真正可用:

  • add "milk chocolate" 3
  • add "milk \"dark\"" 1(如果使用者知道如何在資料中轉譯引號)
  • list
  • exit

最重要的是:我們把名稱當作單一欄位解析,即使其中含有空白。

7. 格式即合約

此處重點是不去「猜」使用者想要什麼,而要明確定義什麼是有效。因為 std::quoted 是一個嚴格的工具:它不會修復混亂,而是幫你維持格式上的約定。

我們把 add 命令的合約列成小表格:

欄位 範例 如何讀取 備註
命令
add
std::string cmd; iss >> cmd;
不含空白,單字
名稱
"milk chocolate"
iss >> std::quoted(name);
必須以引號包起,可包含空白
數量
3
iss >> count;
整數

由此可以推出:字串 add milk chocolate 3 對我們來說是無效的,即使語意上可能可理解。因為合約規定「名稱必須用引號」。這是好習慣:程式不該當心靈傳感師。

為便於理解,可以把解析流程想像成一個小流程圖:

flowchart TD
    A[取得輸入字串 line] --> B[iss >> cmd]
    B --> C{cmd == 'add'?}
    C -- 否 --> X[這不是 add:其他命令]
    C -- 是 --> D["iss >> quoted(name)"]
    D --> E[iss >> count]
    E --> F{讀取成功?}
    F -- 否 --> Y[bad format]
    F -- 是 --> G{還有多餘的 token?}
    G -- 有 --> Y
    G -- 無 --> H["addItem(name, count)"]

當你能在腦中「畫」出這樣的流程,除錯解析器會快很多。

其它引號與 escape 字元

有時資料格式不是用雙引號,而是像單引號;或你想用不同的字元作為 escape。std::quoted 支援這些情況:它有接受「引號字元」與「escape 字元」的重載。

語法如下:

std::quoted(str, quote_char, escape_char)

範例:讀取以單引號 ' 包起來的字串:

#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::string line = "add 'milk chocolate' 2";
    std::istringstream iss(line);

    std::string cmd;
    std::string name;
    int count{};

    if (iss >> cmd >> std::quoted(name, '\'', '\\') >> count) {
        std::cout << name << '\n'; // milk chocolate
    }
}

為什麼有用?有時資料已經以某種格式存在(例如你從某系統複製輸入),接受既有規則比強迫使用者改習慣要簡單。但要注意:支持越多格式,就越多邊緣情況需要處理。

另一個細節是:當你改變 escape 字元時,就要知道資料內的引號會如何被轉譯。換言之,你是在改變迷你格式的「語言」。

8. 與 std::quoted 有關的常見錯誤

錯誤 1:忘記包含 <iomanip>。
最簡單也最惱人的情況:你寫了 std::quoted(name),但是編譯器說找不到。直覺會怪 <sstream> 或 <iostream>,但 quoted 就是在 <iomanip> 裡面,因為它是個輸入/輸出操控器。

錯誤 2:以為 std::quoted 會「自動猜」沒有引號的情況。
如果合約要求欄位可以有空白,引號就是格式的一部分,不是裝飾。std::quoted 不會在沒有引號時猜測欄位結束位置。因此輸入 add milk chocolate 3 並不是「幾乎正確」,而是另一種格式。

錯誤 3:混淆資料內轉譯與 C++ 字面值的轉譯。
當你用 C++ 字串字面值測試解析器(std::string line = "...";),你必須依 C++ 規則轉譯反斜線與引號——這會讓人覺得 std::quoted 要求奇怪的序列。實際上真實使用中(來自使用者的輸入)只有資料格式的一層轉譯。

錯誤 4:不檢查讀取結果就使用變數。
常見心態是:「我輸入對了,程式就會讀對」。但一旦多了空格、空行、遺漏引號或數字位置輸入了字母,串流就會進入 fail,變數可能只有部分被填入。規則很簡單:先 if (iss >> ...),再處理結果。

錯誤 5:不檢查字串尾部,誤接受多餘輸入。
若你不確認讀完所有欄位後串流已結束,那麼 add "milk" 2 blah 也會被視為有效,且 blah 會被悄悄忽略。有時候這可以接受,但多數情況會導致「使用者以為多傳的參數被處理了,但程式沒這麼做」的奇怪行為。