C++ 程式設計/異常處理
異常處理 是一種旨在處理異常發生的構造,異常是指改變程式執行正常流程的特殊情況。在設計程式設計任務(類甚至函式)時,不能總是假設應用程式/任務會正常執行或完成(退出並獲得預期結果)。它可能是在給定任務中,報告錯誤訊息(返回錯誤程式碼)或僅僅退出是不合適的。為了處理這些型別的情況,C++ 支援使用語言構造來將錯誤處理和報告程式碼與普通程式碼分離,也就是說,可以處理這些異常(錯誤和異常)的構造,因此我們稱這種為程式設計新增統一性的全域性方法為異常處理。
在某個地方檢測到錯誤或異常情況時,就會說丟擲異常。丟擲將導致正常的程式流程中止,進入異常狀態。丟擲異常是程式性的,程式設計師指定了丟擲的條件。
在已處理異常中,程式的執行將在一個指定的程式碼塊(稱為catch 塊)處恢復,該塊在程式執行方面包含丟擲點。catch 塊可以(通常是)位於與丟擲點不同的函式/方法中。透過這種方式,C++ 支援非區域性錯誤處理。除了改變程式流程之外,丟擲異常還會將一個物件傳遞給 catch 塊。這個物件可以提供處理程式碼所需的資料,以便處理程式碼能夠決定如何對異常做出反應。
請考慮以下程式碼示例,該示例是try 和catch 塊組合以供說明。
void AFunction()
{
// This function does not return normally,
// instead execution will resume at a catch block.
// The thrown object is in this case of the type char const*,
// i.e. it is a C-style string. More usually, exception
// objects are of class type.
throw "This is an exception!";
}
void AnotherFunction()
{
// To catch exceptions, you first have to introduce
// a try block via " try { ... } ". Then multiple catch
// blocks can follow the try block.
// " try { ... } catch(type 1) { ... } catch(type 2) { ... }"
try
{
AFunction();
// Because the function throws an exception,
// the rest of the code in this block will not
// be executed
}
catch(char const* pch) // This catch block
// will react on exceptions
// of type char const*
{
// Execution will resume here.
// You can handle the exception here.
}
// As can be seen
catch(...) // The ellipsis indicates that this
// block will catch exceptions of any type.
{
// In this example, this block will not be executed,
// because the preceding catch block is chosen to
// handle the exception.
}
}
另一方面,未處理異常將導致函式終止,並且堆疊將展開(堆疊分配的物件將呼叫解構函式),因為它正在尋找異常處理程式。如果找不到,最終會導致程式終止。
從程式設計師的角度來看,引發異常是通知例程無法正常執行的一種有用方法。例如,當輸入引數無效時(例如,除法中的零分母),或者當它依賴的資源不可用時(例如,缺少檔案或硬碟錯誤)。在沒有異常的系統中,例程需要返回一些特殊的錯誤程式碼。然而,這有時會因半謂詞問題而變得複雜,在這種問題中,例程的使用者需要編寫額外的程式碼來區分正常的返回值和錯誤的返回值。
由於很難編寫異常安全的程式碼,因此只有在必須使用時才應該使用異常,即當發生無法處理的錯誤時。不要將異常用於程式的正常流程。
此示例是錯誤的,它演示了要避免的內容。
void sum(int iA, int iB)
{
throw iA + iB;
}
int main()
{
int iResult;
try
{
sum(2, 3);
}
catch(int iTmpResult)
{
// Here the exception is used instead of a return value!
// This is wrong!
iResult = iTmpResult;
}
return 0;
}
考慮以下程式碼。
void g()
{
throw std::exception();
}
void f()
{
std::string str = "Hello"; // This string is newly allocated
g();
}
int main()
{
try
{
f();
}
catch(...)
{ }
}
程式流程
main()呼叫f()f()建立一個名為str的區域性變數。str建構函式分配一個記憶體塊來儲存字串"Hello"。f()呼叫g()g()丟擲異常f()沒有捕獲異常。
- 由於未捕獲到異常,因此我們需要以乾淨的方式退出
f()。 - 此時,將呼叫丟擲之前的所有區域性變數的解構函式。
- 這稱為“堆疊展開”。
- 將呼叫
str的解構函式,它將釋放其佔用的記憶體。
- 如您所見,“堆疊展開”機制對於防止資源洩漏至關重要,如果沒有它,
str永遠不會被銷燬,它使用的記憶體將一直保留到程式結束(甚至直到下次斷電或冷啟動,具體取決於作業系統記憶體管理)。
main()捕獲異常- 程式繼續執行。
“堆疊展開”保證了在離開其範圍時將呼叫區域性變數(堆疊變數)的解構函式。
有幾種方法可以丟擲異常物件。
丟擲指向物件的指標
void foo()
{
throw new MyApplicationException();
}
void bar()
{
try
{
foo();
}
catch(MyApplicationException* e)
{
// Handle exception
}
}
但現在,誰負責刪除異常?處理程式?這使得程式碼更難看。必須有更好的方法!
怎麼樣?
void foo()
{
throw MyApplicationException();
}
void bar()
{
try
{
foo();
}
catch(MyApplicationException e)
{
// Handle exception
}
}
看起來好多了!但是現在,捕獲異常的 catch 處理程式,它是按值捕獲的,這意味著將呼叫複製建構函式。如果捕獲的異常是由於記憶體不足而導致的 bad_alloc,這會導致程式崩潰。在這種情況下,似乎安全的程式碼被假定為處理記憶體分配問題,但會導致程式因異常處理程式的失敗而崩潰。此外,按值捕獲可能會導致複製行為不同,因為物件被切片了。
正確的方法是
void foo()
{
throw MyApplicationException();
}
void bar()
{
try
{
foo();
}
catch(MyApplicationException const& e)
{
// Handle exception
}
}
這種方法具有所有優點,編譯器負責銷燬物件,並且在捕獲時不會進行任何複製!
結論是,異常應該按值丟擲,並按(通常是 const)引用捕獲。
考慮以下程式碼片段。
try
{
void x()
{
throw m();
}
}
catch n();
{
std:cout << "Exception caught\n";
}
執行此程式碼時,程式將查詢異常的 catch 塊,但不存在。因此它會崩潰,不會繼續執行。finally 關鍵字允許在崩潰之前執行一些最終程式碼。
finally
{
// residual code that will execute in any case
}
請注意,如果捕獲了異常,則 try-catch 塊後的行將照常執行。因此,finally 塊只在沒有匹配的 catch 塊時才會生效。
當從建構函式中丟擲異常時,該物件不視為已例項化,因此其解構函式不會被呼叫。但是,相同主物件的已成功構造的基類和成員物件的所有解構函式將被呼叫。相同主物件的尚未構造的基類或成員物件的解構函式將不會執行。例如
class A : public B, public C
{
public:
D sD;
E sE;
A(void)
:B(), C(), sD(), sE()
{
}
};
假設基類 C 的建構函式丟擲異常。然後執行順序為
BC(丟擲異常)~B
假設成員物件 sE 的建構函式丟擲異常。然後執行順序為
BCsDsE(丟擲異常)~sD~C~B
因此,如果執行了某個建構函式,那麼可以依賴於在該建構函式之前執行的相同主物件的另外所有建構函式都已成功完成。這使得能夠使用已構造的成員或基類物件作為相同主物件的後續成員或基類物件建構函式的引數。
如果使用new分配此物件,會發生什麼?
- 為物件分配記憶體
- 物件的建構函式丟擲異常
- 由於異常,物件未例項化
- 刪除物件佔用的記憶體
- 異常被傳播,直到被捕獲
從建構函式中丟擲異常的主要目的是通知程式/使用者物件建立和初始化未正確完成。這是一種提供此重要資訊的非常乾淨的方式,因為建構函式不返回包含錯誤程式碼的單獨值(如初始化函式可能那樣)。
相反,強烈建議不要在解構函式內部丟擲異常。重要的是要注意解構函式何時被呼叫
- 作為正常釋放(退出作用域,刪除)的一部分。
- 作為處理先前丟擲的異常的堆疊展開的一部分。
在第一種情況下,在解構函式內部丟擲異常可能會導致記憶體洩漏,因為物件被錯誤地釋放了。在第二種情況下,程式碼必須更聰明。如果在另一個異常引起的堆疊展開過程中丟擲了異常,則無法選擇首先處理哪個異常。這被解釋為異常處理機制的失敗,並導致程式呼叫 terminate 函式。
為了解決這個問題,可以測試解構函式是否作為異常處理過程的一部分被呼叫。為此,應該使用標準庫函式 uncaught_exception,如果丟擲了異常但尚未捕獲,它將返回 true。在這種情況下執行的所有程式碼都不得丟擲其他異常。
需要這種小心編碼的情況非常罕見。如果程式碼以解構函式根本不丟擲異常的方式編寫,則更安全、更容易除錯。
編寫異常安全程式碼
[edit | edit source]- 異常安全性
如果程式碼中的執行時故障不會產生不良影響(例如記憶體洩漏、儲存資料的混亂或無效的輸出),則稱該程式碼是異常安全的。即使出現異常,異常安全程式碼也必須滿足程式碼上的不變數。異常安全有幾個級別。
- 故障透明,也稱為不丟擲保證:即使在出現異常的情況下,操作也保證能夠成功並滿足所有要求。如果發生異常,它不會將異常進一步丟擲。(最佳的異常安全性級別。)
- 提交或回滾語義,也稱為強異常安全性或無變化保證:操作可能會失敗,但失敗的操作保證不會產生副作用,因此所有資料保留原始值。
- 基本異常安全性:失敗操作的部分執行可能會導致副作用,但狀態上的不變數將被保留。即使資料現在的值與異常之前不同,任何儲存的資料都將包含有效值。
- 最小異常安全性也稱為無洩漏保證:失敗操作的部分執行可能會儲存無效資料,但不會導致崩潰,並且不會洩漏任何資源。
- 無異常安全性:不作任何保證。(最差的異常安全性級別)
部分處理
[edit | edit source]考慮以下情況
void g()
{
throw "Exception";
}
void f()
{
int* pI = new int(0);
g();
delete pI;
}
int main()
{
f();
return 0;
}
你能看到這段程式碼中的問題嗎?如果 g() 丟擲異常,變數 pI 永遠不會被刪除,我們會發生記憶體洩漏。
為了防止記憶體洩漏,f() 必須捕獲異常並刪除 pI。但 f() 無法處理異常,它不知道如何處理!
那麼解決方案是什麼呢?f() 應該捕獲異常,然後重新丟擲它。
void g()
{
throw "Exception";
}
void f()
{
int* pI = new int(0);
try
{
g();
}
catch (...)
{
delete pI;
throw; // This empty throw re-throws the exception we caught
// An empty throw can only exist in a catch block
}
delete pI;
}
int main()
{
f();
return 0;
}
不過,有一個更好的方法;使用 RAII 類來避免使用異常處理的需要。
保護
[edit | edit source]如果你打算在程式碼中使用異常,你必須始終嘗試以異常安全的方式編寫程式碼。讓我們看看可能會出現的一些問題。
考慮以下程式碼。
void g()
{
throw std::exception();
}
void f()
{
int* pI = new int(2);
*pI = 3;
g();
// Oops, if an exception is thrown, pI is never deleted
// and we have a memory leak
delete pI;
}
int main()
{
try
{
f();
}
catch(...)
{ }
return 0;
}
你能看到這段程式碼中的問題嗎?當丟擲異常時,我們永遠不會執行刪除 pI 的那行程式碼!
對此的解決方案是什麼?前面我們看到了基於 f() 捕獲和重新丟擲異常的能力的解決方案。但有一個更簡潔的解決方案,它使用“堆疊展開”機制。但“堆疊展開”只適用於物件的解構函式,那麼我們如何使用它呢?
我們可以編寫一個簡單的包裝類
// Note: This type of class is best implemented using templates, discussed in the next chapter.
class IntDeleter {
public:
IntDeleter(int* piValue)
{
m_piValue = piValue;
}
~IntDeleter()
{
delete m_piValue;
}
// operator *, enables us to dereference the object and use it
// like a regular pointer.
int& operator *()
{
return *m_piValue;
}
private:
int* m_piValue;
};
f() 的新版本
void f()
{
IntDeleter pI(new int(2));
*pI = 3;
g();
// No need to delete pI, this will be done in destruction.
// This code is also exception safe.
}
這裡展示的模式被稱為保護。保護在其他情況下非常有用,它還可以幫助我們使程式碼更加異常安全。保護模式類似於其他語言中的finally塊。
請注意,C++ 標準庫提供了名為 unique_ptr 的模板保護。
異常層次結構
[edit | edit source]你可以將物件(如類或字串)、指標(如 char*)或基本型別(如 int)作為異常丟擲。那麼,你應該選擇哪一個?你應該丟擲物件,因為它們使程式設計師更容易處理異常。建立一個異常類層次結構很常見。
- class MyApplicationException {};
- class MathematicalException : public MyApplicationException {};
- class DivisionByZeroException : public MathematicalException {};
- class InvalidArgumentException : public MyApplicationException {};
- class MathematicalException : public MyApplicationException {};
一個例子
float divide(float fNumerator, float fDenominator)
{
if (fDenominator == 0.0)
{
throw DivisionByZeroException();
}
return fNumerator/fDenominator;
}
enum MathOperators {DIVISION, PRODUCT};
float operate(int iAction, float fArgLeft, float fArgRight)
{
if (iAction == DIVISION)
{
return divide(fArgLeft, fArgRight);
}
else if (iAction == PRODUCT))
{
// call the product function
// ...
}
// No match for the action! iAction is an invalid agument
throw InvalidArgumentException();
}
int main(int iArgc, char* a_pchArgv[])
{
try
{
operate(atoi(a_pchArgv[0]), atof(a_pchArgv[1]), atof(a_pchArgv[2]));
}
catch(MathematicalException& )
{
// Handle Error
}
catch(MyApplicationException& )
{
// This will catch in InvalidArgumentException too.
// Display help to the user, and explain about the arguments.
}
return 0;
}
異常說明
[edit | edit source]函式可以丟擲的異常範圍是該函式公共介面的重要組成部分。沒有這些資訊,你將不得不假設任何函式呼叫時都可能發生任何異常,因此編寫極具防禦性的程式碼。瞭解可以丟擲的異常列表,你可以簡化程式碼,因為它不需要處理所有情況。
此異常資訊是公共介面的特定部分。類的使用者不需要了解它的實現方式,但他們需要了解可以丟擲的異常,就像他們需要了解成員函式的引數數量和型別一樣。向庫的客戶端提供此資訊的其中一種方法是透過程式碼文件,但這需要非常小心地手動更新。不正確的異常資訊比完全沒有資訊更糟糕,因為你最終可能會編寫比你想要編寫的程式碼更不安全的程式碼。
C++ 透過異常說明提供另一種記錄異常介面的方法。異常說明由編譯器解析,它提供一定程度的自動化檢查。異常說明可以應用於任何函式,並且看起來像這樣。
double divide(double dNumerator, double dDenominator) throw (DivideByZeroException);
你可以使用空異常說明指定函式不能丟擲任何異常。
void safeFunction(int iFoo) throw();
異常說明的缺點
[edit | edit source]C++ 在編譯時不會以程式設計方式強制執行異常說明。例如,以下程式碼是合法的。
void DubiousFunction(int iFoo) throw()
{
if (iFoo < 0)
{
throw RangeException();
}
}
C++ 不是在編譯時檢查異常說明,而是在執行時檢查異常說明,這意味著你可能直到測試時才意識到你的異常說明不準確,或者,如果你運氣不好,程式碼已經在生產環境中執行時才意識到。
如果在執行時丟擲了異常,該異常從不允許其異常說明中出現該異常的函式傳播出去,則該異常不會進一步傳播,而是函式 RangeException() 將被呼叫。RangeException() 函式不會返回,但可以丟擲可能(也可能不)滿足異常說明並允許異常處理正常進行的不同型別的異常。如果這仍然無法恢復情況,則程式將被終止。
許多人認為在執行時嘗試轉換異常的行為比簡單地允許異常向上傳播到可能能夠處理它的呼叫者更糟糕。違反異常說明的事實並不意味著呼叫者不能處理這種情況,只是程式碼作者沒有預料到這種情況。通常,堆疊上會存在一個 catch (...) 塊,它可以處理任何異常。
