第 3章 异常及错误处理
健壮的程序来自于正确的错误处理。
相信我,总会有意外的……Delphi 高手突破
正如同现实生活中我们不可能事事如意,你所写的代码也不可能每一行都能得到正确的执行。生活中遇到不如意的事情,处理好了,雨过天晴;处理不好,情况会越变越糟,甚至一发而不可收拾,后果难料。程序设计中同样如此,所谓健壮的程序,并非不出错的程序,而是在出错的情况下能很好地处理的程序。因此,错误处理一直是程序设计领域的一个重要课题。而异常就是面向对象编程提供的错误处理解决方案。它是一个非常好的工具,如果你选择了 OOP,选择了 Delphi,那么异常也就成为你的惟一选择了。要让你信服地选择异常,需要给出一些理由。在本章中会让你清楚明白地了解异常所带来的好处。3.1 异常的本质什么是异常?为什么要用它?在基于函数的结构中,一般使用函数返回值来标明函数是否成功执行,并给出错误类型等信息。于是就会产生如下形式的代码: nRetVal := SomeFunctionToOpenFile(); if nRetVal = E_SUCCESSED then // 成功打开begin ……endelse if nRetVal = E_FILE_NOT_FOUND then // 没有找到文件begin ……endelse if nRetVal = E_FILE_FORMAT_ERR then // 文件格式错begin ……endelse thenbegin ……end 使用返回错误代码的方法是非常普遍的,但是使用这样的方法存在两个问题:(1)造成冗长、繁杂的分支结构(大量的 if 或 case 语句),使得程序流程控制变得复杂,同时造成测试工作的复杂,因为测试需要走遍每个分支。 ·50·异常及错误处理
(2)可能会存在没有被处理的错误(函数调用者如果不判断返回值的话)。异常可以很好地解决以上两个问题。所谓“异常”是指一个异常类的对象。Delphi 的 VCL 中,所有异常类都派生于 Exception类。该类声明了异常的一般行为、性质。最重要的是,它有一个 Message 属性可以报告异常发生的原因。抛出一个异常即标志一个错误的发生。使用 raise 保留字来抛出一个异常对象,如:3raise Exception.Create(′An error occurred!′);但需要强调的是,异常用来标志错误发生,却并不因为错误发生而产生异常。产生异常仅仅是因为遇到了 raise,在任何时候,即使没有错误发生,raise 都将会导致异常的发生。 注意:异常的发生,仅仅是因为 raise,而非其他!一旦抛出异常,函数的代码就从异常抛出处立刻返回,从而保护其下面的敏感代码不会得到执行。对于抛出异常的函数本身来说,通过异常从函数返回和正常从函数返回(执行到函数末尾或遇到了 Exit)是没有什么区别的,函数代码同样会从堆栈弹出,局部简单对象(数组、记录等)会自动被清理、回收。采用抛出异常以处理意外情况,则可以保证程序主流程中的所有代码可用,而不必加入繁杂的判断语句。例如,函数 A抛出异常: function A() : Integer;vat pFile : textfile;begin …… // 一些代码 pFile := SomeFunctionToOpenAnFile(); if pFile = nil thenraise 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 …… endfinally ……end; 来取代,但显然不如所希望的那样结构美观和优雅。这不能不说是一种遗憾,让我们寄希望于下一个 Delphi 版本吧! ·58·异常及错误处理
3.4 构造函数与异常这个话题在 C++社区中经常会被提起,而在 Delphi 社区中似乎从来没有人注意过,也许由于语言的特性而使得 Delphi 程序员不必关心这个问题。但我想,Delphi 程序员也应该3对该问题有所了解,知道语言为我们提供了什么而使得我们如此轻松,不必理会它。正所谓“身在福中须知福”。我们知道,类的构造函数是没有返回值的,因此如果构造函数构造对象失败,则不可能依靠返回错误代码来解决。那么,在程序中如何标识构造函数的失败呢?最“标准”的方法就是:抛出一个异常。构造函数失败,意味着对象的构造失败。那么抛出异常之后,这个“半死不活”的对象会被如何处理呢?在此,读者有必要先对 C++对这种情况的处理方式有一个了解。在 C++中,构造函数抛出异常后,析构函数不会被调用。这种做法是合理的,因为此时对象并没有被完整构造。如果构造函数已经做了一些诸如分配内存、打开文件等操作,那么 C++类需要有自己的成员来记住做过哪些动作。当然,这样做对于类的实现者来说非常麻烦。因此,一般 C++类的实现者都避免在构造函数中抛出异常(可以提供一个诸如 Init 和 UnInit 的成员函数,由构造函数或类的客户去调用它们,以处理初始化失败的情况)。而每一本 C++的经典著作所提供的方案都是使用智能指针(STL 的标准类 auto_ptr)。在 Object Pascal 中,这个问题变得非常简单,程序员不必为此大费周折。如果 ObjectPascal 的类在构造函数中抛出异常,则编译器会自动调用类的析构函数(由于析构函数不允许被重载,可以保证只有惟一一个析构函数,因此编译器不会迷惑于多个析构函数之中)。析构函数中一般会析构成员对象,而 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 ResourceFailed 出现了“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 块。但是要试着让自己对这种技术的热情不要太过分”。