例外處理提供一種結構化的錯誤處理機制:把「正常邏輯」與「錯誤處理」分離,讓錯誤無法被默默忽略,並與 RAII 結合保證資源在任何路徑下都被釋放。本章不只教你語法,更教你何時該用、何時不該用例外。


學習目標

讀完本章後,你應該能夠:

  1. 說明例外的本質、執行成本與「零成本(zero-cost)」模型
  2. 掌握 throw / try / catch 的機制與重載解析順序
  3. 用圖解說明堆疊回溯(stack unwinding)如何配合 RAII 釋放資源
  4. 解釋為何要以 const 參考捕捉,避免物件切割(slicing)
  5. 畫出 std::exception 階層,並選用正確的標準例外
  6. 設計自訂例外(繼承 std::exception / std::runtime_error、覆寫 what()
  7. 正確重新拋出(throw;)並保留型別資訊
  8. 區分三種例外安全保證(no-throw / strong / basic)並寫出對應實作
  9. 理解 noexcept 的語意與「違約即 std::terminate」的後果
  10. 說明「解構子不可拋例外」與「何時不該用例外」

一、例外是什麼?它的成本如何?

例外是一種非區域的控制轉移:當錯誤發生,throw 會立刻中止當前流程,沿著呼叫堆疊往回找能處理它的 catch,途中自動解構所有區域物件。

WHY:為什麼不用回傳錯誤碼就好? 1. 建構子無法回傳值 —— 建構失敗只能靠例外通知。 2. 錯誤碼容易被忽略fclose(f); 沒人看回傳值);例外未被捕捉就會終止程式,不會被默默吞掉。 3. 深層呼叫鏈中,錯誤碼要一層層手動往上傳;例外能「跳過」中間層,直達能處理它的地方。 4. 正常邏輯更乾淨 —— 不必每行都包 if (error) ...

成本模型(zero-cost exceptions)

現代編譯器(GCC/Clang)採用「表格驅動」實作: - 沒有拋出例外時,幾乎零額外成本 —— try 區塊不需要任何執行期設定。 - 拋出時成本較高 —— 需查表、解構區域物件、轉移控制流(相對昂貴,可能數百奈秒到微秒級)。

結論:例外適合「罕見」的錯誤路徑。若一個情況每秒發生數百萬次(如查表 miss),用例外會成為效能瓶頸 —— 那種情況用回傳值/std::optional 更合適。


二、基本機制:throw / try / catch

double divide(int a, int b) {
    if (b == 0)
        throw std::runtime_error("除以零");   // 拋出
    return static_cast<double>(a) / b;
}

int main() {
    try {                                       // 監控區塊
        std::cout << divide(10, 0) << "\n";
    }
    catch (const std::runtime_error& e) {       // 處理特定型別
        std::cerr << "錯誤:" << e.what() << "\n";
    }
    catch (...) {                               // 捕捉所有其他例外
        std::cerr << "未知錯誤\n";
    }
}

預期輸出

錯誤:除以零

2.1 可以拋出什麼?

技術上任何型別都能拋(throw 42;throw "msg";),但最佳實踐是拋出繼承自 std::exception 的物件,這樣呼叫端能用統一介面 what() 取得訊息,並能用 catch (const std::exception&) 一網打盡。

throw 42;                            // ✗ 可行但不建議
throw std::runtime_error("描述");    // ✓ 建議
throw MyException("自訂", 404);      // ✓ 自訂例外

2.2 catch 的匹配順序

catch 由上而下逐一比對,第一個型別相容的就勝出。因此 衍生類別的 catch 必須放在基底類別之前,否則衍生版本永遠選不到:

try { riskyOp(); }
catch (const std::out_of_range& e)   { /* 最具體 */ }
catch (const std::logic_error& e)    { /* 中間 */ }
catch (const std::exception& e)      { /* 最一般,放後面 */ }
catch (...)                          { /* 非 std::exception 的,最後 */ }

三、以 const 參考捕捉(為什麼很重要)

// ✓ 正確:以 const 參考捕捉
catch (const std::exception& e) {
    std::cerr << e.what() << "\n";   // 多型正確:呼叫實際型別的 what()
}

// ✗ 錯誤:以值捕捉 → 物件切割(slicing)
catch (std::exception e) {
    // 若實際拋的是 runtime_error,會被「切」成 exception,
    // 衍生類別的額外資料與覆寫的 what() 全部遺失!
}
拋出 runtime_error 物件:
  以 const& 捕捉:  e ──► [完整的 runtime_error]   ← what() 正確
  以值捕捉:        e = [exception 部分](切掉衍生部分)← 資訊遺失

規則永遠以 const T& 捕捉例外。 既避免切割、又避免不必要的複製。


四、堆疊回溯(Stack Unwinding)

當例外被拋出,從 throw 點到對應 catch 之間,所有已完整建構的區域物件,會依建構的相反順序被解構。這就是 RAII 能在例外路徑自動釋放資源的原因。

void level3() {
    TrackedResource r3("L3");
    throw std::runtime_error("boom");   // 拋出!
}
void level2() { TrackedResource r2("L2"); level3(); }
void level1() { TrackedResource r1("L1"); level2(); }

int main() {
    try { level1(); }
    catch (const std::exception& e) { std::cout << "caught: " << e.what() << "\n"; }
}

預期輸出

TrackedResource(L1) 建構
TrackedResource(L2) 建構
TrackedResource(L3) 建構
~TrackedResource(L3)   ← 堆疊回溯:反向解構
~TrackedResource(L2)
~TrackedResource(L1)
caught: boom

圖解:

   呼叫堆疊(拋出瞬間)         回溯方向
   ┌──────────────┐
   │ level3  r3   │ ← throw   ─┐ 解構 r3
   ├──────────────┤            │ 解構 r2
   │ level2  r2   │            │ 解構 r1
   ├──────────────┤            │
   │ level1  r1   │            ▼
   ├──────────────┤
   │ main  try{}  │ ← 找到匹配的 catch,停止回溯
   └──────────────┘

關鍵:只有用 RAII 管理的資源才會被自動釋放。裸 new 在回溯時不會被自動 delete —— 這又一次說明為何要用智慧指標。


五、標準例外階層

std::exception
├── std::logic_error          (程式邏輯錯誤,理應可預防)
│   ├── std::invalid_argument
│   ├── std::domain_error
│   ├── std::length_error
│   └── std::out_of_range
├── std::runtime_error        (執行期才能偵測的錯誤)
│   ├── std::range_error
│   ├── std::overflow_error
│   └── std::underflow_error
├── std::bad_alloc            (new 配置失敗)
├── std::bad_cast             (dynamic_cast 參考轉型失敗)
├── std::bad_typeid           (對空多型指標用 typeid)
└── std::bad_optional_access  (存取空的 std::optional)

該用哪一個?

情境 建議例外
函式收到無效引數 std::invalid_argument
索引/值超出合法範圍 std::out_of_range
容器長度超過上限 std::length_error
程式邏輯/前置條件被違反 std::logic_error 系列
執行期環境因素(檔案、網路) std::runtime_error 系列
數值溢位 std::overflow_error
記憶體配置失敗 std::bad_alloc(由 new 拋出)

logic_error vs runtime_error 的哲學差異logic_error 代表「程式設計師本可在寫程式時避免的錯誤」(如傳了負數給只接受正數的函式)—— 它往往該用 assert 或修 bug 解決;runtime_error 代表「執行時才知道、無法事先避免的外部因素」(如檔案不存在)。


六、自訂例外類別

6.1 繼承 std::exception(覆寫 what()

class FileNotFoundException : public std::exception {
public:
    explicit FileNotFoundException(const std::string& filename)
        : message_("找不到檔案:" + filename) {}

    const char* what() const noexcept override {   // 必須 noexcept
        return message_.c_str();
    }
private:
    std::string message_;
};

注意 what() 的簽名是 const char* what() const noexcept一定要 noexcept(基底宣告如此)。回傳的指標必須在例外存活期間有效,所以把訊息存成成員 std::string

6.2 繼承 std::runtime_error(更簡潔,推薦)

繼承 std::runtime_error 可直接複用它對訊息字串的管理,不必自己寫 what()

class NetworkException : public std::runtime_error {
public:
    NetworkException(const std::string& msg, int code)
        : std::runtime_error(msg), code_(code) {}   // 基底負責保存訊息
    int code() const { return code_; }
private:
    int code_;
};

6.3 設計例外階層

class AppException : public std::runtime_error {
    using std::runtime_error::runtime_error;        // 繼承建構子
};
class DatabaseException     : public AppException     { using AppException::AppException; };
class ConnectionFailed      : public DatabaseException { using DatabaseException::DatabaseException; };
class QueryFailed           : public DatabaseException { using DatabaseException::DatabaseException; };

呼叫端可選擇捕捉的「粒度」:catch (const QueryFailed&) 處理最具體的,或 catch (const AppException&) 一次處理整個應用層例外。

Core Guidelines E.14Use purpose-designed user-defined types as exceptions (not built-in types).(用為此目的設計的自訂型別當例外,別拋內建型別。)


七、重新拋出(Rethrowing)

在中間層記錄日誌後,把例外原封不動往上傳:

try { riskyOp(); }
catch (const std::exception& e) {
    log(e.what());
    throw;          // ✓ 重新拋出「原始」例外,保留動態型別
    // throw e;     // ✗ 拋出 e 的副本 → 物件切割,型別退化成 std::exception
}

throw;(不接物件)才是「重新拋出當前例外」;throw e; 是「拋出一個新的、被切割過的副本」。兩者天差地別。


八、例外安全保證(Exception Safety Guarantees)

任何「可能拋例外」的操作,都應該明確它提供哪一級保證:

層級 保證 典型實作
不拋出(No-throw) 絕不拋例外 noexcept 函式、解構子、swap
強烈(Strong) 失敗則狀態完全回復到操作前(commit-or-rollback) copy-and-swap
基本(Basic) 失敗後物件仍有效、無資源洩漏,但內容可能已改變 多數操作的最低要求
(無保證) 失敗後可能洩漏或處於損毀狀態 應極力避免

8.1 強烈保證:copy-and-swap

class Widget {
public:
    Widget& operator=(const Widget& other) {
        Widget temp(other);   // 1. 先複製(可能拋 → 此時 *this 尚未被動)
        swap(*this, temp);    // 2. 交換(noexcept,不可能失敗)
        return *this;         // 3. temp 解構,帶走舊資源(noexcept)
    }
    friend void swap(Widget& a, Widget& b) noexcept { /* 交換成員 */ }
};

WHY 有效:所有「可能失敗」的工作(複製)都在修改 *this 之前完成。一旦進入不會失敗的 swap,操作就保證成功。若複製階段拋例外,*this 完全沒被碰過 → 自動達成強烈保證。

8.2 基本保證範例

void append(std::vector<int>& v, int x) {
    v.push_back(x);   // 若擴容時拋 bad_alloc:v 仍是有效的(內容不變)→ 基本/強烈保證
}

九、noexcept 規範

noexcept 是給編譯器與呼叫端的承諾:「此函式不會拋出例外」。

void safe()  noexcept       { /* 保證不拋 */ }
void maybe() noexcept(false) { /* 可能拋(預設) */ }

// 條件式 noexcept:依 T 的移動是否 noexcept 決定
template <typename T>
void mySwap(T& a, T& b) noexcept(std::is_nothrow_move_constructible_v<T>
                              && std::is_nothrow_move_assignable_v<T>) {
    T t = std::move(a); a = std::move(b); b = std::move(t);
}

9.1 違約的後果

若標了 noexcept 的函式實際拋出例外,程式直接呼叫 std::terminate(不會被任何 catch 攔截)。所以只在你確信不會拋時才標。

9.2 哪些該標 noexcept

函式 是否標 noexcept
移動建構子 / 移動賦值 應該(否則容器擴容退回複製,見 Ch17)
解構子 預設就是 noexcept(不要讓它拋)
swap 應該
簡單 getter / setter 可以
可能配置記憶體或呼叫可能拋的操作 通常

Core Guidelines E.12 / E.16:解構子、釋放函式、swap 絕不可拋;移動操作應該 noexcept


十、解構子不可拋例外

~MyClass() {            // 隱含 noexcept
    closeConnection();  // 若它 throw → 在堆疊回溯期間就是「兩個例外同時在場」
}

WHY:若一個例外正在進行堆疊回溯,途中某個解構子又拋出第二個例外,C++ 無法決定該處理哪一個,於是直接 std::terminate。因此: - 解構子內部若有可能拋的操作,自行 try/catch 吞掉(最多記錄日誌)。 - 把「可能失敗且呼叫端需知道結果」的清理動作(如 file.close() 回報寫入錯誤)做成獨立的成員函式,讓使用者顯式呼叫。

~FileWriter() {
    try { flushAndClose(); }
    catch (...) { /* 記錄但不外拋 */ }
}

十一、例外 vs 錯誤碼 vs optional / expected

機制 適用情境 優點 缺點
例外 罕見、非預期的錯誤;建構子失敗 不可忽略、正常路徑零成本、可跨層 錯誤路徑較慢、控制流較隱晦
錯誤碼 / 回傳值 頻繁、可預期的失敗 快、明確、可在熱路徑使用 容易被忽略、汙染正常邏輯
std::optional<T> 「可能沒有值」(查無資料) 型別表達「有/無」、輕量 無法攜帶錯誤原因
std::expected<T,E>(C++23) 「成功給值,失敗給錯誤」 兼具值與錯誤、不需例外 C++23 才標準化
std::optional<int> findIndex(const std::vector<int>& v, int target) {
    for (size_t i = 0; i < v.size(); ++i)
        if (v[i] == target) return static_cast<int>(i);
    return std::nullopt;   // 「找不到」是可預期結果,不該拋例外
}

經驗法則

  • 例外:檔案不存在、網路斷線、記憶體不足、建構子失敗等「異常」狀況。
  • 錯誤碼 / 回傳值:高頻、可預期、屬於正常流程一部分的失敗。
  • std::optional:單純「可能查不到/沒有值」。
  • std::expected(或 optional + 錯誤碼):要同時回報「值」與「失敗原因」又不想用例外時。

十二、何時「不該」用例外

  1. 可預期的、高頻的控制流(如迴圈結束、查表 miss)—— 改用回傳值/optional
  2. 效能極度敏感的熱路徑,且該錯誤並不罕見。
  3. 不能容忍非確定性延遲的硬即時系統(拋出成本不易預測)。
  4. 與不支援例外的環境/ABI 互動(部分嵌入式、-fno-exceptions 的程式碼庫、跨語言 C 介面邊界)—— 例外不可跨越 C 邊界,須在邊界轉成錯誤碼。
  5. 建構/解構之外、單純表達「沒有結果」 —— 用 optional 更貼切。

十三、最佳實踐(Best Practices)

  1. 拋出繼承自 std::exception 的型別,別拋內建型別或字串。
  2. 永遠以 const& 捕捉,避免切割與多餘複製。
  3. catch 由具體到一般排序;必要時以 catch (...) 收尾。
  4. 重新拋出用 throw;,不要 throw e;
  5. 移動操作、swap、解構子標 noexcept(且名副其實)。
  6. 解構子絕不外拋例外
  7. 用 RAII 管理一切資源 —— 這是寫出例外安全程式的根本手段。
  8. 明確每個函式的例外安全等級,至少做到基本保證,熱點處考慮強烈保證。
  9. 例外只用於「異常」,可預期的結果用回傳值 / optional / expected
  10. 自訂例外攜帶足夠上下文(錯誤碼、檔名、行號等),方便診斷。

Core Guidelines 速查:E.2(用例外回報無法區域處理的錯誤)、E.6(用 RAII 防止洩漏)、E.14(用自訂型別當例外)、E.15(以參考捕捉階層中的例外)、E.16/E.17(解構子/釋放/swap 不可拋)。


重點整理

概念 要點
例外本質 非區域控制轉移;正常路徑零成本、錯誤路徑較慢
try/catch/throw 分離正常邏輯與錯誤處理
以 const& 捕捉 避免物件切割、保留多型
堆疊回溯 反向解構區域物件 → RAII 自動釋放資源
標準例外 logic_error(可預防)vs runtime_error(執行期)
自訂例外 繼承 std::exception/runtime_error、覆寫 what() noexcept
重新拋出 throw; 保留型別;throw e; 會切割
例外安全 no-throw / strong / basic 三級;copy-and-swap 達成強烈
noexcept 承諾不拋;違約 → std::terminate
解構子 絕不外拋例外
例外 vs 其他 罕見錯誤用例外;可預期結果用回傳值/optional/expected

常見錯誤與陷阱

  1. 以值捕捉例外catch (std::exception e) 造成切割,遺失衍生資訊。
  2. catch 順序錯誤:基底放在衍生之前,衍生版本永遠選不到(部分編譯器會警告)。
  3. 解構子拋例外:堆疊回溯中再拋 → std::terminate
  4. throw e; 當重新拋出:實為拋副本,會切割;應用 throw;
  5. 過度使用例外:把例外當正常控制流(迴圈、查表)→ 嚴重拖慢。
  6. noexcept 違約:標了卻拋出 → 直接 std::terminatecatch 攔不到。
  7. 用裸 new 而非 RAII:例外回溯時裸指標不會被釋放 → 洩漏。
  8. 拋出非 std::exception 型別:呼叫端難以用統一介面處理,catch (const std::exception&) 也接不到。

練習題

全部可用 g++ -std=c++17 -Wall 編譯。建議在資源類別的建構/解構子印訊息,觀察堆疊回溯。

練習 1(基礎):安全除法

double safeDivide(double a, double b),當 b == 0 時拋出 std::invalid_argument("除數不能為零")。在 main 中用 try/catch 測試 {10,3}{5,0} 兩組,正確印出結果或錯誤訊息。

  • 提示:以 catch (const std::invalid_argument& e) 捕捉,用 e.what() 取得訊息。

練習 2(基礎):安全陣列存取

int at(const std::vector<int>& v, int i),越界時拋 std::out_of_range,訊息包含索引與合法範圍。測試合法與越界(含負數)索引。

  • 提示:注意 i 為負或 >= v.size() 都算越界;轉型時小心 size_t 的無號運算。

練習 3(中級):自訂例外階層

設計檔案處理系統的例外階層:FileException(繼承 std::runtime_error)→ FileNotFoundExceptionPermissionDeniedExceptionFileCorruptedException。寫一個 openFile(name, mode) 依情況拋不同子類別,並示範分別用「具體子類別」與「基底 FileException」兩種粒度捕捉。

  • 提示:用「繼承建構子」using FileException::FileException; 省去樣板碼;具體 catch 要放基底 catch 之前。

練習 4(中級):堆疊回溯與 RAII

寫一個 Guard 類別在建構/解構印訊息,再寫 a() → b() → c() 三層呼叫,每層各建立一個 Guard,在 c() 拋例外。於最外層捕捉,觀察並驗證三個 Guard反向順序解構。

  • 提示:解構順序應為 c 層 → b 層 → a 層;catch 在最外層。

練習 5(中級):強烈例外保證的 Stack<T>

實作一個 Stack<T>,使 push 具有強烈例外保證(若內部擴容/複製失敗,stack 維持原狀)。寫一個複製建構會「第 N 次拋例外」的測試型別來驗證失敗後 size() 不變。

  • 提示:先在「不影響現有資料」的暫存空間完成可能失敗的工作,最後再以 noexcept 的步驟提交(copy-and-swap 思路)。

練習 6(挑戰):noexceptvector 的影響

寫兩個類別 AB,移動建構子分別「有」與「沒有」noexcept,各在複製/移動建構印訊息。把它們放進不 reservevector 並多次 push_back 觸發擴容,觀察 A/B 在擴容時分別用了移動還是複製,並解釋原因。

  • 提示:可用 std::is_nothrow_move_constructible_v<T> 印出兩者差異佐證。

練習 7(挑戰):例外 vs optional vs 錯誤碼

針對「解析字串為整數」,分別實作三個版本:(a) 失敗拋例外、(b) 回傳 std::optional<int>、(c) 回傳 bool 並用輸出參數帶回結果。比較三者的呼叫端寫法,並說明各自適合的情境。

  • 提示:可內部用 std::stoi 並攔截其 std::invalid_argument / std::out_of_range,再依版本決定如何回報。

對應程式碼檔案

檔案 說明
exceptions.cpp try/catch/throw、標準例外、以參考捕捉、多個 catch、堆疊回溯、重新拋出、RAII、例外 vs 錯誤碼
custom_exceptions.cpp 自訂例外、例外階層、計算器/銀行帳戶案例、noexcept、例外安全保證(強烈保證的轉帳)