Learning Objectives
- 說明為什麼需要例外:把「發生錯誤」和「正常結果」分開處理
- 用 throw 丟出例外,用 try/catch 接住,追蹤程式跳躍的路線
- 依型別安排多個 catch,知道 catch (...) 與「子類別先、父類別後」的順序
- 使用標準例外類別與 what(),處理 vector::at 與 stoi 丟出的例外
- 自訂例外類別,說明例外往外傳遞時區域物件會被解構,以及沒有被接住時程式會結束
為什麼需要例外
程式執行時總會遇到「不該發生、但真的發生了」的情況:除數是 0、使用者輸入的不是數字、索引超出範圍、檔案打不開。
傳統的做法是讓函式回傳一個特別的值代表失敗,例如找不到就回傳 -1:
int pos = findStudent("Amy");
if (pos == -1) { /* 處理找不到 */ }
這個做法有兩個麻煩:呼叫端很容易忘了檢查,錯誤就被默默忽略;而且有些函式的每個回傳值都是合法結果(例如除法), 根本挑不出一個值來代表錯誤。
例外(exception)是 C++ 提供的另一條路:發生錯誤的地方用 throw 丟出一個例外物件,函式立刻停止;
程式沿著呼叫的路線往外找,找到能處理它的 catch 就跳過去執行。錯誤處理的程式集中在 catch,正常流程的程式就不會被一堆 if 打斷。
try {
// 可能出錯的程式
} catch (const runtime_error& e) {
// 出錯時要做的事,e.what() 是錯誤訊息
}
本課的動畫會把例外發生的那一步標示出來,接著你會看到程式「跳」到 catch,中間沒執行的程式碼全部被跳過。
throw 與 try/catch
divide(10, 4) 正常回傳;divide(5, 0) 在第 8 行丟出例外。注意例外發生之後,divide 的框架立刻消失,第 10 行的 return 和第 17 行都沒有執行,下一步直接到第 18 行的 catch。
例外的流程規則
| 寫法 | 意思 |
|---|---|
throw 運算式; |
丟出例外。可以丟任何型別:throw 404;、throw string("...");、throw runtime_error("..."); |
try { ... } |
把可能丟出例外的程式包起來;沒有包在 try 裡的例外無法在這裡被接住 |
catch (型別 名稱) { ... } |
例外的型別相符時執行;一個 try 後面可以接好幾個 catch |
catch (...) |
三個點:接住任何型別的例外,一定要放在最後 |
流程有三個重點:
throw執行後,同一個區塊裡後面的程式全部跳過,函式也不會回傳值。- 程式從 try 後面的第一個 catch 開始由上往下比對型別,只會進入第一個相符的 catch,其他 catch 不執行。
- catch 執行完,程式接著執行整個 try/catch 之後的程式,不會回到 throw 的地方。
catch 比對型別時很嚴格:throw 'x';(char)不會被 catch (int n) 接住,throw 3.14; 也不會;一般的型別轉換在這裡不算數。
主要的例外是繼承:子類別的例外物件可以被父類別的 catch 接住,後面會用到。
習慣寫法:丟出時用值(
throw runtime_error("...")),接住時用 const 參考(catch (const runtime_error& e)), 避免複製例外物件,也能正確處理子類別。
多個 catch:依型別比對
迴圈讓 code 從 0 跑到 3,每一輪 check 丟出不同型別的例外(0 不丟)。觀察每次例外發生之後,程式跳到哪一個 catch:int 跳到第 17 行,string 跳到第 19 行,double 沒有對應的 catch,由第 21 行的 catch (...) 接住。
想一想:例外的流程
下面的程式會印出什麼?
try {
cout << "A";
throw 1;
cout << "B";
} catch (int n) {
cout << "C";
}
cout << "D";
下面的程式會印出什麼?
try {
throw 'x';
} catch (int n) {
cout << "int";
} catch (...) {
cout << "其他";
}
out_of_range 是 exception 的子類別。下面的程式會印出什麼?
try {
throw out_of_range("x");
} catch (const exception& e) {
cout << "A";
} catch (const out_of_range& e) {
cout << "B";
}
cout << "C";
標準例外類別
標準函式庫定義了一組例外類別,全部繼承自 std::exception(標頭檔 <exception>);常用的幾個在 <stdexcept>:
| 類別 | 用途 | 誰會丟出 |
|---|---|---|
exception |
所有標準例外的父類別 | |
logic_error |
程式邏輯上就能避免的錯誤 | |
invalid_argument |
引數不合法 | stoi("abc") |
out_of_range |
超出範圍 | vector::at、string::at、stoi("99999999999") |
runtime_error |
執行時才會發現的錯誤 | 自己丟出時最常用 |
bad_alloc |
new 要不到記憶體(在 <new>) |
new |
invalid_argument、out_of_range 是 logic_error 的子類別。每個例外物件都有成員函式 what(),回傳一段描述錯誤的文字;
建立時傳進去的字串就是 what() 的內容,例如 runtime_error("除數不能是 0")。
[] 和 at() 的差別:v[5] 不檢查範圍,超出範圍是未定義行為;v.at(5) 會檢查,超出範圍時丟出 out_of_range,可以用 catch 處理。
stoi(<string>,C++11)把字串轉成 int:開頭不是數字時丟出 invalid_argument,數字超出 int 能表示的範圍時丟出 out_of_range。
注意 stoi 只看開頭,stoi("12abc") 會得到 12,不會丟出例外。
vector::at 與 stoi 丟出的例外
第 12 行的 scores.at(5) 超出範圍,例外訊息是 g++ 的標準函式庫(libstdc++)產生的。後半段的迴圈把三個字串交給 stoi:第一個成功,第二個丟出 invalid_argument,第三個丟出 out_of_range,各自跳到對應的 catch。
自訂例外與堆疊解開
自己的程式也可以定義例外類別,讓 catch 依錯誤種類分開處理。最常見的寫法是繼承標準例外:
// 寫法一:繼承 exception,覆寫 what()
class NotEnoughMoney : public exception {
public:
const char* what() const noexcept override { return "餘額不足"; }
};
// 寫法二:繼承 runtime_error,把訊息交給父類別的建構子
class NotEnoughMoney : public runtime_error {
public:
NotEnoughMoney() : runtime_error("餘額不足") {}
};
noexcept(C++11)表示這個函式保證不會丟出例外;what() 在標準例外裡就是這樣宣告的,覆寫時要寫一樣。
繼承之後,catch (const exception& e) 也能接住它,e.what() 會呼叫覆寫後的版本。
例外丟出之後,如果目前的函式沒有相符的 catch,例外會往外傳遞給呼叫它的函式,一路找下去。
被經過的每個函式都會結束,它們的區域物件會依建立的相反順序被解構,這個過程叫做堆疊解開(stack unwinding)。
所以 vector、string、ofstream 這些會在解構子裡釋放資源的物件,即使發生例外也不會漏掉清理。
例外往外傳遞,沿路解構區域物件
main 呼叫 shop,shop 呼叫 withdraw,右邊會疊出三層框架。第 23 行丟出例外後,withdraw 和 shop 都沒有 catch,注意框架一層層消失之前,各自的 Tracker 解構子都被呼叫了(先 withdraw 的、再 shop 的)。第 24、30 行都沒有執行。
沒有被接住的例外
如果例外一路往外傳到 main 都沒有相符的 catch,程式會呼叫 std::terminate() 結束,預設的做法是呼叫 abort() 立刻中止。
用 g++ 編譯的程式在 Linux 上會印出類似這樣的訊息:
terminate called after throwing an instance of 'std::out_of_range'
what(): vector::_M_range_check: __n (which is 10) >= this->size() (which is 3)
Aborted (core dumped)
第一行告訴你例外的型別,第二行是 what() 的內容。這種情況下區域物件會不會被解構,C++ 標準交給各家實作決定,不要依賴它。
沒有 catch:程式結束
這段程式沒有 try/catch。播到最後一步,看輸出區的錯誤訊息,第 10 行的「結束」不會印出來。
想一想:標準例外與傳遞
下面的程式會印出什麼?
try {
cout << stoi("12abc") + 1;
} catch (const invalid_argument& e) {
cout << "錯誤";
}
下面的程式會印出什麼?
struct Box {
char tag;
Box(char t) : tag(t) {}
~Box() { cout << tag; }
};
void inner() { Box b('2'); throw 0; }
void outer() { Box a('1'); inner(); }
int main() {
try { outer(); } catch (int) { cout << "C"; }
}
vector<int> v(3); 之後,v[5] = 1; 和 v.at(5) = 1; 有什麼不同?
動手寫:檢查年齡輸入
完成 parseAge 與 main 迴圈:stoi 遇到不是數字的字串會自己丟出 invalid_argument;你要在年齡小於 0 或大於 150 時丟出 out_of_range("年齡不合理")。main 要分別接住兩種例外,輸出要和預期相同。
重點回顧
throw丟出例外,所在的區塊和函式立刻停止;try包住可能出錯的程式,catch依型別接住例外- catch 由上往下比對,只進入第一個相符的;
catch (...)接住任何型別,放在最後;子類別的 catch 要放在父類別前面 - 丟出時用值,接住時用 const 參考:
catch (const runtime_error& e),e.what()取得錯誤訊息 - 標準例外都繼承
std::exception;vector::at超出範圍丟出out_of_range,stoi會丟出invalid_argument或out_of_range - 自訂例外可以繼承
exception覆寫what(),或繼承runtime_error傳入訊息 - 例外往外傳遞時,經過的函式依序結束,區域物件被解構(堆疊解開);一直沒有被接住時,程式呼叫
std::terminate()結束