例外處理提供一種結構化的錯誤處理機制:把「正常邏輯」與「錯誤處理」分離,讓錯誤無法被默默忽略,並與 RAII 結合保證資源在任何路徑下都被釋放。本章不只教你語法,更教你何時該用、何時不該用例外。
學習目標
讀完本章後,你應該能夠:
- 說明例外的本質、執行成本與「零成本(zero-cost)」模型
- 掌握
throw/try/catch的機制與重載解析順序 - 用圖解說明堆疊回溯(stack unwinding)如何配合 RAII 釋放資源
- 解釋為何要以 const 參考捕捉,避免物件切割(slicing)
- 畫出
std::exception階層,並選用正確的標準例外 - 設計自訂例外(繼承
std::exception/std::runtime_error、覆寫what()) - 正確重新拋出(
throw;)並保留型別資訊 - 區分三種例外安全保證(no-throw / strong / basic)並寫出對應實作
- 理解
noexcept的語意與「違約即std::terminate」的後果 - 說明「解構子不可拋例外」與「何時不該用例外」
一、例外是什麼?它的成本如何?
例外是一種非區域的控制轉移:當錯誤發生,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.14:Use 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+ 錯誤碼):要同時回報「值」與「失敗原因」又不想用例外時。
十二、何時「不該」用例外
- 可預期的、高頻的控制流(如迴圈結束、查表 miss)—— 改用回傳值/
optional。 - 效能極度敏感的熱路徑,且該錯誤並不罕見。
- 不能容忍非確定性延遲的硬即時系統(拋出成本不易預測)。
- 與不支援例外的環境/ABI 互動(部分嵌入式、
-fno-exceptions的程式碼庫、跨語言 C 介面邊界)—— 例外不可跨越 C 邊界,須在邊界轉成錯誤碼。 - 建構/解構之外、單純表達「沒有結果」 —— 用
optional更貼切。
十三、最佳實踐(Best Practices)
- 拋出繼承自
std::exception的型別,別拋內建型別或字串。 - 永遠以
const&捕捉,避免切割與多餘複製。 catch由具體到一般排序;必要時以catch (...)收尾。- 重新拋出用
throw;,不要throw e;。 - 移動操作、
swap、解構子標noexcept(且名副其實)。 - 解構子絕不外拋例外。
- 用 RAII 管理一切資源 —— 這是寫出例外安全程式的根本手段。
- 明確每個函式的例外安全等級,至少做到基本保證,熱點處考慮強烈保證。
- 例外只用於「異常」,可預期的結果用回傳值 /
optional/expected。 - 自訂例外攜帶足夠上下文(錯誤碼、檔名、行號等),方便診斷。
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 |
常見錯誤與陷阱
- 以值捕捉例外:
catch (std::exception e)造成切割,遺失衍生資訊。 catch順序錯誤:基底放在衍生之前,衍生版本永遠選不到(部分編譯器會警告)。- 解構子拋例外:堆疊回溯中再拋 →
std::terminate。 throw e;當重新拋出:實為拋副本,會切割;應用throw;。- 過度使用例外:把例外當正常控制流(迴圈、查表)→ 嚴重拖慢。
noexcept違約:標了卻拋出 → 直接std::terminate,catch攔不到。- 用裸
new而非 RAII:例外回溯時裸指標不會被釋放 → 洩漏。 - 拋出非
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)→ FileNotFoundException、PermissionDeniedException、FileCorruptedException。寫一個 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(挑戰):noexcept 對 vector 的影響
寫兩個類別 A、B,移動建構子分別「有」與「沒有」noexcept,各在複製/移動建構印訊息。把它們放進不 reserve 的 vector 並多次 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、例外安全保證(強烈保證的轉帳) |