用C++寫代碼的時候總是避免不了處理錯誤,一般來說有兩種方式,通過函數的返回值或者拋出異常。C語言的錯誤處理一律是通過函數的返回值來判斷的,一般是返回0
、NULL
或者-1
表示錯誤,或者直接返回錯誤代碼,具體是哪種方式沒有統一的規定,各種API也各有各的偏好。譬如fopen
函數,當成功時返回文件指針,失敗時返回NULL
,而POSIX標準的open
函數則在成功時返回0
或者正數,失敗時返回-1
,然後需要再通過全局變量errno
來判斷具體錯誤是什麼,配套的還有一系列perror
、strerror
這樣的函數。
C++的錯誤處理方式
C++號稱向下兼容C語言,於是就將C語言通過返回值的錯誤處理方式也搬了進來。但C++最大的不同是引入了異常機制,可以用throw
產生一個異常,並通過try
和catch
來捕獲。於是就混亂了,到底是什麼時候使用返回值表示錯誤,什麼時候使用異常呢?首先簡單談論一下異常和返回值的特點。
異常的優點
錯誤信息豐富,便於獲得錯誤現場
代碼相對簡短,不需要判斷每個函數的返回值
異常的缺點
使控制流變得複雜,難以追蹤
開銷相對較大
返回值的優點
性能開銷相對小
避免定義異常類
返回值的缺點
程序員經常「忘記」處理錯誤返回值
每個可能產生錯誤的函數在調用後都需要判斷是否有錯誤
與「真正的」返回值混用,需要規定一個錯誤代碼(通常是
0
、-1
或NULL
)
使用異常還是返回值
我的觀點是,用異常來表示真正的、而且不太可能發生的錯誤。所謂不太可能發生的錯誤,指的是真正難以預料,但發生了卻又不得不單獨處理的,譬如內存耗盡、讀文件發生故障。而在一個字符串中查找一個子串,如果沒有找到顯然應該是用一個特殊的返回值(如-1
),而不應該拋出一個異常。
一句話來概況就是不要用異常代替正常的控制流,只有當程序真的「不正常」的時候,纔使用異常。反過來說,當程序真正發生錯誤了,一定要使用異常而不是返回一個錯誤代碼,因爲錯誤代碼總是傾向於被忽略。如果要保證一個以返回值來表示錯誤代碼的函數的錯誤正確地向上傳遞,需要在每個調用了可能產生錯誤的函數後面都判斷一下是否發生了錯誤,一旦發生了不可解決的錯誤,就要終止當前函數(並釋放當前函數申請的資源),然後向上傳遞錯誤。這樣一來錯誤處理代碼會被重複地寫好幾遍,十分冗雜,譬如下面代碼:
int func(int n) { int fd = open("path/to/file", O_RDONLY); if (fd == -1) { return ERROR_OPEN;
} int* array = new[n]; int err;
err = do_something(fd, array); if (err != SUCCESS) { delete[] array; return err;
}
err = do_other_thing(); if (err != SUCCESS) { delete[] array; return err;
}
err = do_more_thing(); if (err != SUCCESS) { delete[] array; return err;
} delete[] array; return SUCCESS;
}
對使用異常容易增加函數出口的指控其實是不成立的,因爲即使使用返回值,這些出口也是免不了的,除非程序員有意或無意忽略掉,但異常是不可忽略的。如果你認爲可以把判斷錯誤的if
語句縮寫到一行使代碼變得「更清晰」,那麼我只能說是自欺欺人。
有些錯誤幾乎總是可以被立即恢復(譬如前面所說的查找一個字符串不存在的子串,甚至都不能說這是一個「錯誤」),而且返回值本身就傳遞一定信息,就不需要使用異常了。
鑑於C++沒有統一的ABI,並不建議在模塊的接口上使用異常。如果要使用,就要把可能曝露給用戶的異常全部聲明出來,不要把其他類型的異常丟給用戶去處理,尤其是內部狀態——模塊的使用者通常也不會關心模塊內部具體是哪條語句發生錯誤了。
構造函數中的錯誤
有一個相當實際的問題是,如何處理構造函數的錯誤?我們都知道構造函數是沒有返回值的,怎麼辦呢?通常有三種常見的處理方法,標記錯誤狀態、使用一個額外的initialize
函數來初始化,或者直接拋出異常。
合格的C++程序員都知道C++的析構函數中不應該拋出異常,一旦析構函數中的異常沒有被捕獲,整個程序都要被中止掉。於是許多人就對在構造函數中拋出異常也產生了對等的恐懼,寧可使用一個額外的初始化函數在裏面初始化對象的狀態並拋出異常(或者返回錯誤代碼)。這樣做違背了對象產生和初始化要在一起的原則,強迫用戶記住調用一個額外的初始化函數,一旦沒有調用直接使用了其他函數,其行爲很可能是未定義的。
使用初始化函數的惟一好處可能是避免了手動釋放資源(釋放資源的操作交給析構函數來做),因爲C++的一個特點是構造函數拋出異常以後析構函數是不會被調用的,所以如果你在構造函數裏面申請了內存或者打開了資源,需要在異常產生時關閉。但想想看其實並不能完全避免,因爲有些資源可能是要在可能產生錯誤的函數調用過後纔被申請的,還是無法完全避免手工的釋放。
標記錯誤狀態也是一種常見的形式,譬如STL中的ifstream
類,當構造時傳入一個無法訪問的文件作爲參數,它不會返回任何錯誤,而是標記的內部狀態爲不可用,用戶需要手工通過is_open()
函數來判斷是否打開成功了。同時它還有good()
、fail()
兩個函數,同時也重載了bool
類型轉換運算符用於在if
語句中判斷。標記狀態的方法在實踐中相當醜陋,因爲在使用前總是需要判斷它是否「真的創建成功了」。
最直接的方法還是在構造函數中拋出異常,它並不會向析構函數中拋出異常那樣有嚴重的後果,只是需要注意的是拋出異常以後對象沒有被創建成功,析構函數也不會被調用,所以應該自行把申請的資源全部都釋放掉。
如何在構造函數中捕獲異常
構造函數與普通函數有一個很不一樣特性,就是構造函數可以有初始化列表,例如下面的代碼:
class B { public:
B(int val) : val_(val * val) {
} private: int val_;
};class A { public:
A(int val) : b_(val) {
a_ = val;
} private: int a_;
B b_;
};
以上的代碼中A
的構造函數的函數體的語句在執行之前會先調用B
的構造函數,這時候問題在於,如果B
的構造函數拋出了異常,A
該如何捕獲呢?一個迂迴的做法是在A
中把B
的實例聲明爲指針,在構造函數和析構函數中分別創建和刪除,這樣就能捕獲到異常了。不過,實際上是有更簡單的做法的。下面我要介紹一個C++的很不常見的語法:函數作用域級別的異常捕獲。
class B { public:
B(int val) : val_(val * val) { throw runtime_error("wtf from B");
} private: int val_;
};class A { public:
A(int val) try : b_(val) {
a_ = val;
} catch (runtime_error& e) { cerr << e.what() << endl; throw runtime_error("wtf from A");
} private: int a_;
B b_;
};
注意上面A
的構造函數,在參數列表後和初始化列表前增加了try
關鍵字,然後構造函數就被分割爲了兩部分,前面是初始化,後面是初始化時的錯誤處理。需要指出的是,catch
塊裏面捕獲到的異常不能被忽略,即catch
塊中必須有一個throw
語句重新拋出異常,如果沒有,則默認會將原來捕獲到的異常重新拋出,這和一般的行爲是不同的。例如下面代碼運行可以發現A
會將捕獲到的異常原封不動拋出:
class A { public:
A(int val) try : b_(val) {
a_ = val;
} catch (runtime_error& e) { cerr << e.what() << endl;
} private: int a_;
B b_;
};
這種語法是C++的標準,而且目前已經被所有的主流C++編譯器支持(VS2010、g++ 4.2、clang 3.1),所以幾乎不存在兼容性問題,大可放心使用。