CodeGym /課程 /C++ SELF /std::exception 與 what(): 基礎實務

std::exception 與 what(): 基礎實務

C++ SELF
等級 53, 課堂 1
開放

1. 為什麼需要 std::exception,如果可以丟出任何東西

如果從嚴格形式上看 C++,幾乎可以丟出任何東西:數字、字串、結構,甚至是一隻貓(只要貓是可複製的)。問題在於,捕捉「任何東西」很不方便:你不知道該如何列印它、如何分類它、以及如何撰寫通用的處理器。

std::exception 正是為了解決這類日常痛點:它提供了一個統一的最小介面「我是錯誤,這是我的文字說明」。

std::exception 是標準例外的基底類別。它帶到我們程式中的最重要成員是方法:

const char* what() const noexcept;

也就是說,若例外是與 std::exception 相容的話,它能告訴你一個文本描述。不是完美、不一定詳盡,但至少有「一點東西」,而不是「喔,完了」。

另外一個實務上的重要點:絕大多數標準庫的例外都繼承自 std::exception,因此「通用處理器」通常長這樣: catch (const std::exception& e)。

下面用一個小圖示來鞏固 std::exception 的角色:

flowchart TD
    A["throw (某個錯誤物件)"] --> B{尋找合適的 catch}
    B -->|"catch (const SomeType&)"| C[特定處理]
    B -->|"catch (const std::exception& e)"| D["通用處理器
可以列印 e.what()"] B -->|"catch (...)"| E["備援方案
型別未知,沒有 what()"]

2. 基本規則:如何捕捉例外

用 const& 捕捉 std::exception

當例外從你函式深處拋出並被 catch 接住時,你通常想要兩件事:第一,不要丟失錯誤資訊;第二,不要造成多餘的複製。

因此「黃金法則」在一般情況下是:用 const 參考來捕捉。這是普遍接受的做法:多型的例外用參考捕捉,以免丟失型別與行為。

下面是最小的「捕捉並列印」骨架:

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::runtime_error("something went wrong");
    } catch (const std::exception& e) {
        std::cout << "error: " << e.what() << '\n'; // error: something went wrong
    }
}

注意:這裡我們暫不討論「該選擇哪種例外類型」——那是下一堂課的內容。現在我們關注的是通用介面:如果例外繼承自 std::exception,它就有 what()。

為何要用 const?因為在處理器中你通常不應該「修理」例外。你應該讀它、輸出訊息、或許結束操作。而 &(參考)能避免複製並保留動態型別(關於這點稍後會更詳細說明)。

catch 的順序:何處放 std::exception,何處放 catch (...)

當程式中有多個處理器時,編譯器會選擇「第一個型別匹配的處理器」。因此 catch 的順序不是「看起來好看」的問題,而是語意的一部分。

實務上 std::exception 是一張「寬網」:它能捕捉很多東西。如果把它放在最前面,下面所有更具體的處理器都會變成不可達。

要記住的正確階梯是:

  1. 先放最具體的型別(如果你要對它們做特殊處理)。
  2. 接著放通用處理器 const std::exception&。
  3. 最後放 catch (...),作為最後防線,當型別完全未知時使用。

一個最小範例,純粹為了展示結構(不展開型別細節——那會在後面):

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::runtime_error("boom");
    } catch (const std::runtime_error& e) {
        std::cout << "runtime_error: " << e.what() << '\n'; // runtime_error: boom
    } catch (const std::exception& e) {
        std::cout << "std::exception: " << e.what() << '\n';
    } catch (...) {
        std::cout << "unknown error\n";
    }
}

請注意:catch (...) 無法提供 what(),因為在那裡沒有一個具體型別的例外物件可供你操作。這是「什麼都捕捉,但什麼都不知道」。所以 catch (...) 不是替代正常診斷的手段,而是防止程式無聲失敗的保險網。

3. what():回傳什麼以及如何列印

初學者看到 what() 時常會期待 std::string:畢竟是「文字」。但基於歷史與相容性考量,what() 回傳的是 const char*。這代表:你得到一個指向 C 字串的指標,也就是以 '\0' 結尾的字元序列。

最簡單(也是正確)的情境是直接在 catch 中輸出 what():

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::runtime_error("division by zero");
    } catch (const std::exception& e) {
        std::cout << e.what() << '\n'; // division by zero
    }
}

為何這麼方便?因為你至少能在日誌或主控台看到一行字,而不是神祕的「程式已終止」。

關於生命週期的一點小事

這裡有個細節:what() 回傳的指標指向的是屬於例外物件的記憶體。只要你還在 catch 區塊內,例外物件仍然存在,字串通常是有效的。但一旦離開 catch,保存這個指標以備之後使用是個壞主意。

不好的範例(不要這麼做):

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    const char* msg = nullptr;

    try {
        throw std::runtime_error("bad input");
    } catch (const std::exception& e) {
        msg = e.what();
    }

    std::cout << msg << '\n'; // ⚠ 可能已經無效(懸掛指標)
}

較佳且「人性化」的做法是將文字複製到 std::string,如果你需要在 catch 之外使用該訊息:

#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

int main() {
    std::string msg;

    try {
        throw std::runtime_error("bad input");
    } catch (const std::exception& e) {
        msg = e.what(); // 複製文字
    }

    std::cout << msg << '\n'; // bad input
}

記住這個簡單的比喻:what() 就像貼在例外物件上的一張紙條。只要物件在你手上——紙條就可以讀。如果你想把紙條帶回家,就抄到你的筆記本(std::string)。

4. 為何不能以值捕捉 std::exception

接下來有個觀念會讓你一開始感到不順,但理解一次之後能省下大量除錯時間。

例外常常是多型的:你拋出的是一個派生型物件,例如某種具體的 runtime error,但你以基底型 std::exception 來捕捉。這本身是預期的行為。

但如果你以值方式捕捉:

catch (std::exception e) { ... }

就會發生物件切割(object slicing):派生部分會被「切掉」,變數 e 中只剩基底的部分。這表示你可能會失去派生類別的 what() 的正確實作,而得到非常無用的文字。

我們來直接比較。

糟糕的範例——以值捕捉:

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::runtime_error("file not found");
    } catch (std::exception e) { // ⚠ 以值捕捉
        std::cout << e.what() << '\n'; // 通常會是像 "std::exception" 之類的東西
    }
}

正確的做法——以參考捕捉:

#include <exception>
#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::runtime_error("file not found");
    } catch (const std::exception& e) { // ✅ 以參考捕捉
        std::cout << e.what() << '\n'; // file not found
    }
}

這就是為何「以 const& 捕捉」不是美學或教條,而是避免遺失錯誤訊息的手段。

可以這樣想:以值捕捉就像有人交給你一個標註著「易碎玻璃」的盒子,你把內容重新包裝到一個普通的盒子裡,然後驚訝地發現沒有「別摔」的標示。

5. 實作範例:簡易計算機與透過 what() 的診斷

現在我們來做一個小的主控台「簡易計算機」。它會讀取像 12 / 3 這樣的行並輸出結果。如果發生問題——程式不會無聲崩潰,而會透過 what() 顯示錯誤訊息。

步驟 1:帶除以零檢查的除法函式

從一個小函式開始。如果除以零,我們不想回傳某個「魔術值」例如 0 並假裝一切正常。我們要用例外來表達錯誤。

#include <stdexcept>

double safe_div(double a, double b) {
    if (b == 0.0) {
        throw std::runtime_error("division by zero");
    }
    return a / b;
}

這裡使用了 std::runtime_error。今天重點不是「為什麼選他」,而是它可以被當成 std::exception 捕捉並讀取 what()。

步驟 2:根據運算子計算表達式

加入一個簡單的運算分派器。沒什麼花招,只有四則運算。

#include <stdexcept>

double eval(double a, char op, double b) {
    if (op == '+') return a + b;
    if (op == '-') return a - b;
    if (op == '*') return a * b;
    if (op == '/') return safe_div(a, b);
    throw std::runtime_error("unknown operator");
}

注意風格:若運算子未知,我們不嘗試「猜測」。我們透過例外終止操作。

步驟 3:使用 std::stringstream 解析行

現在讀取一行並取出 a、op、b。我們以前做過串流解析,所以這裡沒什麼魔法。

#include <sstringstream>
#include <stdexcept>
#include <string>

struct Expr {
    double a{};
    char op{};
    double b{};
};

Expr parse_expr(const std::string& line) {
    std::stringstream ss(line);
    Expr e{};
    if (!(ss >> e.a >> e.op >> e.b)) {
        throw std::runtime_error("bad expression format");
    }
    return e;
}

如果字串為空或格式錯誤,我們拋出例外。誰來決定如何處理,通常是負責輸入輸出的那端:通常是 main() 或某個「run」等級的函式。

步驟 4:處理邊界 — 在 main() 中用 try/catch

現在組合一個「精簡」的 main,它能:讀行、解析、計算、輸出。重要的是在 一處 捕捉作為 std::exception 的錯誤並列印 what()。

#include <exception>
#include <iostream>
#include <string>

int main() {
    try {
        std::string line;
        std::getline(std::cin, line);

        const Expr ex = parse_expr(line);
        const double result = eval(ex.a, ex.op, ex.b);

        std::cout << result << '\n'; // 例如: 4
    } catch (const std::exception& e) {
        std::cout << "error: " << e.what() << '\n';
    }
}

我們得到什麼好處?如果輸入是 12 / 0,程式不會無聲崩潰或印出 0。它會誠實地輸出 error: division by zero。如果輸入是 hello,會顯示 error: bad expression format。

最重要的是——這個風格具可擴展性:你可以擴充功能、新增函式,仍然能維持一個通用的處理器,不會讓除錯成為占卜咖啡渣的工作。

責任邊界

注意角色如何分配。函式 parse_expr 與 eval 不會列印錯誤或問使用者「你確定是這個意思嗎?」。它們只報告:「我無法完成工作」。而 main() 決定如何處理這個訊息:顯示訊息並結束(或在更複雜的應用中請求重新輸入)。

這對初學者來說已經是良好的設計:減少專案中四處散落的 std::cout。

6. 與 std::exception 和 what() 相關的常見錯誤

開始在真實程式碼中使用例外時,常見錯誤多半不是來自「深奧理論」,而是壞習慣。許多錯誤看起來無害,因為程式有時仍然能「運作」,但診斷會變差、行為會怪異。現在列出最常踩到的陷阱,讓你最多只踩一次(而且僅限於學習目的)。

錯誤一:以值捕捉 std::exception(catch (std::exception e))。
這會導致 object slicing:你會失去例外的動態型別與其有用的 what() 訊息,有時候結果只會印出類似「std::exception」的字樣。正確做法:catch (const std::exception& e)。

錯誤二:只使用 catch (...) 並以為那就夠了。
catch (...) 確實能捕捉「所有東西」,但無法提供 what(),因為缺乏型別化的例外物件。結果是你捕到錯誤卻不知道是什麼。最低限度應該有一個 catch (const std::exception& e) 並輸出 e.what(),而把 catch (...) 留作最後防線。

錯誤三:保存來自 what() 的 const char* 並在離開 catch 後使用。
在 catch 內通常可行,但離開後可能變成「垃圾」或導致未定義行為。如果你需要之後再使用訊息,就複製到 std::string:std::string msg = e.what();。這個小步驟能省下很多麻煩。

錯誤四:寫空的 catch,把錯誤「吞掉」。
有時初學者會寫 catch (...) {} 或 catch (const std::exception&) {}「這樣就不會崩潰了」,但程式會在壞掉的狀態下繼續運作。這就像用黑色膠帶把「Check Engine」燈蓋住:看起來好看了,但裡面還是一樣有問題。捕捉例外時,你應該有一個策略:輸出訊息、結束操作或將控制權傳遞上層。

錯誤五:把 catch (const std::exception& e) 放在更具體處理器之前。
如果通用處理器放得太前面,特定的 catch 就永遠不會被執行。這不僅「不整潔」,還可能破壞你的錯誤處理邏輯。記得順序:具體型別在上,std::exception 在下,catch (...) 最後。

1
任務
C++ SELF, 等級 53, 課堂 1
上鎖
警示按鈕
警示按鈕
1
任務
C++ SELF, 等級 53, 課堂 1
上鎖
守衛隊列
守衛隊列
1
任務
C++ SELF, 等級 53, 課堂 1
上鎖
誠實通行證
誠實通行證
1
任務
C++ SELF, 等級 53, 課堂 1
上鎖
帶備註的除法
帶備註的除法
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION