第 3章 異常及錯誤處理
健壯的程序來自於正確的錯誤處理。
相信我,總會有意外的……
Delphi 高手突破
正如同現實生活中我們不可能事事如意,你所寫的代碼也不可能每一行都能得到正確
的執行。生活中遇到不如意的事情,處理好了,雨過天晴;處理不好,情況會越變越糟,
甚至一發而不可收拾,後果難料。程序設計中同樣如此,所謂健壯的程序,並非不出錯的
程序,而是在出錯的情況下能很好地處理的程序。
因此,錯誤處理一直是程序設計領域的一個重要課題。而異常就是面向對象編程提供
的錯誤處理解決方案。它是一個非常好的工具,如果你選擇了 OOP,選擇了 Delphi,那麼
異常也就成為你的惟一選擇了。
要讓你信服地選擇異常,需要給出一些理由。在本章中會讓你清楚明白地了解異常所
帶來的好處。
3.1 異常的本質
什麼是異常?為什麼要用它?
在基於函數的結構中,一般使用函數返回值來標明函數是否成功執行,並給出錯誤類
型等信息。於是就會產生如下形式的代碼:
nRetVal := SomeFunctionToOpenFile();
if nRetVal = E_SUCCESSED then // 成功打開
begin
……
end
else if nRetVal = E_FILE_NOT_FOUND then // 沒有找到文件
begin
……
end
else if nRetVal = E_FILE_FORMAT_ERR then // 文件格式錯
begin
……
end
else then
begin
……
end
使用返回錯誤代碼的方法是非常普遍的,但是使用這樣的方法存在兩個問題:
(1)造成冗長、繁雜的分支結構(大量的 if 或 case 語句),使得程序流程控制變得
復雜,同時造成測試工作的復雜,因為測試需要走遍每個分支。
·50·
異常及錯誤處理
(2)可能會存在沒有被處理的錯誤(函數調用者如果不判斷返回值的話)。
異常可以很好地解決以上兩個問題。
所謂“異常”是指一個異常類的對象。Delphi 的 VCL 中,所有異常類都派生於 Exception
類。該類聲明了異常的一般行為、性質。最重要的是,它有一個 Message 屬性可以報告異
常發生的原因。
拋出一個異常即標志一個錯誤的發生。使用 raise 保留字來拋出一個異常對象,如:
3
raise Exception.Create(′An error occurred!′);
但需要強調的是,異常用來標志錯誤發生,卻並不因為錯誤發生而產生異常。產生異
常僅僅是因為遇到了 raise,在任何時候,即使沒有錯誤發生,raise 都將會導致異常的發生。 注意:異常的發生,僅僅是因為 raise,而非其他!
一旦拋出異常,函數的代碼就從異常拋出處立刻返回,從而保護其下面的敏感代碼不
會得到執行。對於拋出異常的函數本身來說,通過異常從函數返回和正常從函數返回(執
行到函數末尾或遇到了 Exit)是沒有什麼區別的,函數代碼同樣會從堆棧彈出,局部簡單
對象(數組、記錄等)會自動被清理、回收。
采用拋出異常以處理意外情況,則可以保證程序主流程中的所有代碼可用,而不必加
入繁雜的判斷語句。
例如,函數 A拋出異常:
function A() : Integer;
vat
pFile : textfile;
begin
…… // 一些代碼
pFile := SomeFunctionToOpenAnFile();
if pFile = nil then
raise Exception.Create(′Open file failed!′); // 文件打開失敗拋出異常
Read(pFile, ……); // 讀文件
…… // 其他一些對文件的操作,此時可以保證文件指針有效
end;
函數 A的代碼使得對文件打開的出錯處理非常簡單。如果打開文件失敗,則拋出一個
Exception 類的異常對象,函數立刻返回,從而保護了以下對文件指針的操作不被執行。而
之後的代碼可以假設文件指針肯定有效,從而令代碼更加美觀。
生活中,我們每天扔掉的垃圾都會有清潔工人收拾、處理,否則生活環境中豈不到處
充斥著垃圾?同樣,拋出的異常也需要被捕獲和處理。假設函數 B 調用了函數 A,要捕獲
這個文件打開失敗的異常,就需要在調用 A 之前先預設一個陷阱,這個陷阱就是所謂的
“try…except 塊”。
·51·
Delphi 高手突破
先看一下函數 B 的代碼:
procedure B();
begin
…… // 一些代碼
try
A(); // 調用A
SomeFunctionDependOnA(); // 依賴於A的結果的函數
Except
ShowMessage(′some error occured′); // 嘿嘿,掉進來了,發生異常
End;
…… // 繼續的代碼
end;
A拋出的異常,會被 B所設的 try…except 所捕獲。一旦捕獲到異常,就不再執行之後
的敏感代碼,而是立刻跳至 except 塊執行錯誤處理,處理完成後再繼續執行整個 try 塊之
後的代碼。程序流程的控制權被留在了函數 B。
如果不喜歡自己收拾垃圾,因而在 B 中並沒有預設 try…except 塊的話,則異常會被繼
續拋給 B 的調用者,而如果 B 的調用者同樣不負責任,則異常會被繼續像踢足球一樣被踢
給更上層的調用者,依此類推。不過,不用擔心,我們有一個大管家,大家都不要的燙手
山芋,它會幫我們收拾,那就是——VCL(Delphi 的應用程序框架)。
因為 VCL 的框架使得所編寫的整個應用程序被包在一個大的 try…except 中,無論什
麼沒有被處理的異常,最終都會被它所捕獲,並將程序流程返回到最外層的消息循環中,
決無遺漏!這也就是為什麼會看到很多用 Delphi 所編寫的但並不專業的小軟件有時會跳出
一個報告錯誤的對話框(如圖 3.1 所示)。發生這樣的情況應該責怪軟件的編寫者沒有很
好地處理錯誤,但有些不明白異常機制的程序員常常會責怪 Delphi 編寫的程序怎能會有這
樣的情況發生。其實出現這個提示,應該感謝 VCL的異常機制讓程序可以繼續運行而不是
“非法終止”。
圖3.1 異常被VCL所捕獲 注意:VCL 用一個大的 try…except 將代碼包裹起來!
因此,在 VCL 框架中不會有不被處理的異常,換句話說,也就是不會有不被處理的錯
誤(雖然筆者說過異常並不等於錯誤)。對異常的捕獲也非常簡單,不見了一大堆的 if 或
·52·
異常及錯誤處理
case,程序控制流程的走向也就十分清晰明了了,這是給測試人員帶來的好消息。
3.2 創建自己的異常類
異常機制是完全融入面向對象的體系的,所以異常類和一般類一樣具有繼承和多態的
3
性質。其實,異常類和普通類並沒有什麼區別。
Object Pascal的運行時異常基類是 Exception,VCL中所有異常類都應該從它派生。當
然,Object Pascal 語言並不規定如此,可以用 raise 拋出任何除簡單類型之外的類類型的對
象,try…except 同樣可以捕獲它,在異常處理後同樣會自動析構、回收它,只是 Exception
定義了異常的大多數特征。既然別人已經為我們准備了一個好用的、完備的 Exception,當
然沒有理由不用它。
也許讀者也已經注意到,所有 VCL 的異常發生時,彈出的警告對話框都帶有一段有價
值的對於異常的發生原因的描述(正如圖 3.1 中的“"is not a valid integer value”)。這段
描述對於 debug 工作是非常有用的。它正是來自於 Exception 類的 Message屬性,所有異常
類被創建時都必須給出一個出錯描述。因此,在定義、使用自己的異常類時,也要給出一
個不會令人迷惑的、明白說出錯誤原因的 Message 屬性。 注意:從 Exception派生自己的異常類!
下面以一個示例程序來演示如何定義、使用自己的異常類,其代碼及可執行文件可在
配書光盤的 exception 目錄下找到。
程序運行後的界面如圖 3.2 所示。
圖3.2 自定義異常類演示程序界面
該程序的運行界面十分充分地體現了第 1 章所說的“簡單性”原則。界面上只有 3 個
按鈕,先看上面兩個(另一個“try…finally”按鈕先不說明,留待 3.3 節講解)。一個模擬
打開文件時發生“找不到文件”的錯誤,一個模擬發生“文件格式錯”的錯誤。所謂模擬
發生錯誤,就是在並沒有真正發生錯誤的情況下拋出異常,使得編譯器認為發生了錯誤,
即單擊這兩個按鈕後,程序會分別拋出相應的異常。
首先要定義兩種錯誤所對應的異常類。它們的定義和實現在 ExceptionClass.pas 單元
中。該單元代碼清單如下:
·53·
Delphi 高手突破
unit ExceptionClass;
interface
uses SysUtils, Dialogs;
Type
EFileOpenFailed = class(Exception) // 定義一個文件打開失敗的通用異常類
public
procedure Warning(); virtual; abstract;
end;
EFileNotFound = class(EFileOpenFailed) // 細化文件打開失敗的異常
public
procedure Warning(); override;
end;
EFileFormatErr = class(EFileOpenFailed) // 細化文件打開失敗的異常
public
procedure Warning(); override;
end;
implementation
{ EFileNotFound }
procedure EFileNotFound.Warning;
begin
ShowMessage('真是不可思議,竟然找不到文件!');
end;
{ EFileFormatErr }
procedure EFileFormatErr.Warning;
begin
ShowMessage('更不可思議的是,文件格式不對!');
end;
end.
我們先定義了一個標志打開文件失敗的異常基類 EFileOpenFailed,並給它聲明了一個
·54·
異常及錯誤處理
抽象方法 Warning。然後又細化了錯誤的原因,從而派生出兩個異常類——EFileNotFound、
EFileFormatErr,它們都具體實現了 Warning 方法。
在應用程序的主Form(Form1)中,定義一個模擬發生錯誤並拋出異常的SimulateError()
方法來模擬發生錯誤、拋出異常。
然後定義一個 ToDo()方法來調用會引發異常的 SimulateError(),並且用 Try 將其捕獲
進行異常處理。
3
最後在兩個按鈕的 OnClick()事件中,調用 ToDo()方法。
其代碼清單如下:
unit Unit1;
interface
uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls,
Forms, Dialogs, StdCtrls;
type
TForm1 = class(TForm)
Button1: TButton;
Button2: TButton;
Label1: TLabel;
Button3: TButton;
procedure Button1Click(Sender: TObject);
procedure Button2Click(Sender: TObject);
procedure Button3Click(Sender: TObject);
private
{ Private declarations }
public
{ Public declarations }
procedure SimulateError(Button : TObject);
procedure ToDo(Button : TObject);
end;
var
Form1: TForm1;
implementation
uses ExceptionClass;
·55·
Delphi 高手突破
{$R *.dfm}
procedure TForm1.SimulateError(Button : TObject);
begin
if Button = Button1 then
raise EFileNotFound.Create('File Not Found')
else if Button = Button2 then
raise EFileFormatErr.Create('File Format Error')
else // Button = Button3
raise Exception.Create('Unknonw Error');
end;
procedure TForm1.Button1Click(Sender: TObject);
begin
ToDo(Sender);
end;
procedure TForm1.Button2Click(Sender: TObject);
begin
ToDo(Sender);
end;
procedure TForm1.ToDo(Button : TObject);
begin
try
SimulateError(Button)
except
on E : EFileOpenFailed do
E.Warning();
on E : Exception do
ShowMessage(E.Message);
end;
end;
procedure TForm1.Button3Click(Sender: TObject);
var
AStream : TMemoryStream;
begin
AStream := TMemoryStream.Create();
try
SimulateError(Sender);
·56·
異常及錯誤處理
finally
AStream.Free();
end;
end;
end.
3
程序運行後,當單擊界面上方的兩個按鈕之一時,都會調用 ToDo 方法。而在 ToDo
方法中,由於 SimulateError 被調用而引發一個異常,雖然並沒有真的發生打開文件錯誤,
但確實拋出了異常。這再次說明了,異常只是用來標志錯誤,而並不等同於錯誤。
程序中,我們定義了一個標志打開文件失敗的異常基類 EFileOpenFailed,以及兩個派
生的異常類——EFileNotFound、EfileFormatErr。這樣定義異常類框架,給錯誤處理部分帶
來了更多的靈活性。這是多態性給我們的又一個恩惠。可以自由選擇需要捕獲的異常的“精
度”。也就是說,如果用戶非常關心發生錯誤的具體原因,則可以捕獲每個最底層的異常
類;而如果只關心是否發生了打開文件的錯誤,那麼可以只捕獲 EFileOpenFailed類;若關
心的只是是否有錯誤發生,則只需捕獲 Exception 就行了。
在 SimulateError 的調用之外,設置了 try…except,那麼它所引發的異常都會被捕獲。
將“精度”更“細”的異常類的處理代碼放在前面,而把“精度”較“粗”的異常類的處
理代碼放在後面。如果相反,則所有異常都會被 Exception的處理代碼捕獲,而其他的異常
類的處理代碼則永遠都沒有機會執行了。
Exception 程序演示了一個很小的、自定義的異常類框架的定義、實現及使用。“麻雀
雖小,五髒俱全”,它給出了一種在自己程序中錯誤的捕獲、處理的思路。
3.3 try…finally
現在已經知道,在函數中引發異常將導致函數的正常返回,因此函數棧中的局部簡單
對象(數組、記錄等)會得到釋放。同時也知道了,在 Object Pascal 中所有的類對象都在
堆中被構造,編譯器不會在退出函數時自動調用它們的析構函數,那麼如何保證所有的局
部類對象也能被釋放呢?
Object Pascal引入了獨特的 try...finally 來解決這個問題。
try…finally 塊幫你保證一些重要的代碼在無論是否發生異常的情況下都能被執行,這
些代碼位於 finally和 end之間。
再次打開 Exception 程序,現在來看一下沒用過的第 3 個按鈕。為它的 Click 事件添加
如下的代碼:
procedure TForm1.Button3Click(Sender: TObject);
var
AStream : TMemoryStream;
·57·
Delphi 高手突破
begin
AStream := TMemoryStream.Create();
try
SimulateError(Self);
finally
AStream.Free();
end;
end;
它首先創建了一個內存流對象,以模擬該函數申請了一些系統資源。然後還是調用
了 SimulateError 方法,不過這次 SimulateError 拋出的是一個 Exception 異常。但在此把
內存流對象的銷毀工作放在了 finally 保護之中,由此保證該對象的釋放。可以自己單步
跟蹤試一下,無論在發生異常(即調用了 SimulateError)的情況下,還是正常退出(不
調用 SimulateError 或將 SimulateError 的調用改為 Exit)的情況下,AStream.Free()都會得
到執行。
同時擁有 try…except 和 try…finally,應該說是 Delphi 程序員的一種幸運,值得慶幸。
只是,我們想得到的會更多,會希望擁有
try
……
except
……
finally
這樣的結構,只是目前還得不到滿足。雖然可以用
try
try
……
except
……
end
finally
……
end;
來取代,但顯然不如所希望的那樣結構美觀和優雅。這不能不說是一種遺憾,讓我們寄希
望於下一個 Delphi 版本吧!
·58·
異常及錯誤處理
3.4 構造函數與異常
這個話題在 C++社區中經常會被提起,而在 Delphi 社區中似乎從來沒有人注意過,也
許由於語言的特性而使得 Delphi 程序員不必關心這個問題。但我想,Delphi 程序員也應該
3
對該問題有所了解,知道語言為我們提供了什麼而使得我們如此輕松,不必理會它。正所
謂“身在福中須知福”。
我們知道,類的構造函數是沒有返回值的,因此如果構造函數構造對象失敗,則不可
能依靠返回錯誤代碼來解決。那麼,在程序中如何標識構造函數的失敗呢?最“標准”的
方法就是:拋出一個異常。
構造函數失敗,意味著對象的構造失敗。那麼拋出異常之後,這個“半死不活”的對
象會被如何處理呢?
在此,讀者有必要先對 C++對這種情況的處理方式有一個了解。
在 C++中,構造函數拋出異常後,析構函數不會被調用。這種做法是合理的,因為此
時對象並沒有被完整構造。
如果構造函數已經做了一些諸如分配內存、打開文件等操作,那麼 C++類需要有自己
的成員來記住做過哪些動作。當然,這樣做對於類的實現者來說非常麻煩。因此,一般 C++
類的實現者都避免在構造函數中拋出異常(可以提供一個諸如 Init 和 UnInit 的成員函數,
由構造函數或類的客戶去調用它們,以處理初始化失敗的情況)。而每一本 C++的經典著
作所提供的方案都是使用智能指針(STL 的標准類 auto_ptr)。
在 Object Pascal 中,這個問題變得非常簡單,程序員不必為此大費周折。如果 Object
Pascal 的類在構造函數中拋出異常,則編譯器會自動調用類的析構函數(由於析構函數不
允許被重載,可以保證只有惟一一個析構函數,因此編譯器不會迷惑於多個析構函數之中)。
析構函數中一般會析構成員對象,而 Free()方法保證了不會對 nil 對象(即尚未被創建的成
員對象)調用析構函數,因此在使得代碼簡潔優美的前提下,又保證了安全。
以下的程序演示了構造函數中拋出異常後,Object Pascal 編譯器所作的處理方法。
首先定義 TMyClass:
type
TMyClass = class
private
FStr : PChar; // 字符串指針
public
constructor Create();
destructor Destroy(); override;
end;
然後實現 TMyClass,並讓它的構造函數中拋出異常:
·59·
Delphi 高手突破
constructor TMyClass.Create();
begin
FStr := StrAlloc(10); // 構造函數中為字符串指針分配內存
StrCopy(FStr, 'ABCDEFGHI');
raise Exception.Create('error'); // 拋出異常,沒有理由
end;
destructor TMyClass.Destroy();
begin
StrDispose(FStr); // 析構函數中釋放內存
WriteLn('Free Resource');
end;
最後,編寫程序主流程的代碼。主流程中首先創建 TMyClass 類的實例:
var
Obj : TMyClass;
i : integer;
begin
try
Obj := TMyClass.Create();
// Obj.Free(); // 不調用析構函數,但發生異常時,編譯器自動調用了析構函數
WriteLn('Succeeded');
except
Obj := nil;
WriteLn('Failed');
end;
Read(i); // 暫停屏幕,以便觀察運行結果
end.
這段代碼中,創建 TMyClass 類的實例時遇到了麻煩,因為 TMyClass 的構造函數拋出
了異常,但這段代碼執行結果卻是:
Free Resource
Failed
出現了“Free Resource”,說明發生異常後,析構函數被調用了。而這正是在構造函
數拋出異常之後,編譯器自動調用析構函數的結果。
因此,如果類的說明文檔或類的作者告知你,類的構造函數可能會拋出異常,那就要
記得用 try…except 包住它!
·60·
異常及錯誤處理
C++與 Object Pascal 對於構造函數拋出異常後的不同處理方式,其實正是兩種語言的
設計思想的體現。C++秉承 C 語言的風格,注重效率,一切交給程序員來掌握,編譯器不
做多余動作;Object Pascal 繼承 Pascal 的風格,注重程序的美學意義,編譯器幫助程序員
完成復雜的工作。
3.5 小 結 3
異常是面向對象編程帶來的非常好的工具,不加以利用是很可惜的。但是,正如萬事
都有個“度”,濫用異常也是不可取的。使用異常不是沒有代價,它會增加程序的負擔,
編寫若干 try...except 和編寫數以千計的 try...except 之間是有很大區別的。
同時,也不必過分害怕由它所帶來的負擔。其實,既然已經使用了 Delphi,其實就已
經在使用異常了,也許只是自己還不知道。聽聽 Chalie Calverts 的忠告:“在似乎有用的
時候,就應該使用 try...except 塊。但是要試著讓自己對這種技術的熱情不要太過分”。