任何一种流行的高级语言编译器都提供了较易使用的调试与异常处理的方法。当然不同的编译器因为其编译的原理不同导致其调试的具体方法和异常处理的具体技巧也有所不同。比如说VB,因为它是解释型的语言,所以象单步跟踪这样的方式可以一边编译一边查看执行结果,因为它的每一条语句本来就是一边运行一边解释再返回结果的;而象VC,相对起来则要等待“很长”的编译过程,这时候写几行代码就调试结果的习惯就会变得非常痛苦。
正 因为这些原因,所以我觉得调试技巧和异常处理的具体写法跟一个程序员的编码风格有关,也与开发工具的实现方式有关,大家再随着编码经验的深入,将会形成自 己的习惯,如同算法形形色色,不必拘泥于某种成法。但是有三点我认为是比较基本的,特别对于初级的程序员来说,养成良好的习惯往往比追求哪种语言/开发工具好用更有意义。
一、必须认识到一个软件是不可能100%完全可正确的、按常规执行的。不要人为的在浅意识里存在这样的思想:“(假设)理想的情况下,应该得到什么结果,所以我接下来的代码是…”“这里感觉不好…算了无所谓,反正一般情况下不会出错”。为了保证一个健壮的软件,不能无视任何错误出现的可能性!根据经验要尽可能的推断将会产生的问题,尤其是运行期的问题,哪怕只有1%的可能,也要考虑异常情况;
二、不要为了解决错误而写简单添加解决该错误的代码,这样代码会越来越多,错误本身并没有消失,还可能产生更多的错误。最好的方法是考虑某个错误为什么会出现,是否是自己考虑不周,假如是这样原因,与其添加更多的判断,不如重新思考算法;
三、形成对象(无论是小至一个变 量,还是大至整个程序)“有始有终”的习惯。即保证初始化对象时有值,避免引用不可靠或错误的产生。同时要保证无论发生什么情况(运行时错误),都能正确 的执行下去,并且回收资源。这个步骤在开始建立一个模块的时候就应该做,以避免因为自己的忘记而导致不稳定的因素。
接下来我将谈谈Delphi调试代码的一般方法。同时结合上面提到的几点,讲述Delphi中异常处理的常规写法。
调 试 篇
Delphi没有提供专门的调试工具软件,而是在IDE环境中集成了一个调试器,对那些习惯跟踪地址的人来说可能显得简单了些。但是就我的使用感受来说,其提供的多种调试手段还是非常有用的,普通的软件开发完全可以胜任。Delphi是编译后执行的(每次只编译改动后的单元),所以感觉(编译)速度上会比VB慢一些,但比VC就显得快多了。利用Delphi调试是一件比较轻松的事情,大家可以选择适合自己习惯的调试方法。
文法检查、编译、运行
“Project”菜单上的“Syntax Check Project”命令用于检查程序的语法错误,不产生目标文件,不链接可执行程序,所以速度很快,一般用于代码的早期检查;
“Project”菜单上的“Compile”命令将编译上次编译后改动的文件,并为每个单元生成DCU文件。假如单元的Interface部分改变,则引用该单元的单元也会被编译;
“Project”菜单上的“Build”命令除了编译外,还将链接生成目标文件;
执行“Run” 菜单或按钮除了生成目标文件外,还将直接执行该文件(dll不会执行)。
错误类型及正确判断
Delphi在设计期间可以简单的判断错误。比如忘记引用某个对象、函数的单元等,在编码的时候不会自动弹出声明菜单,而且会在下部消息区显示“Unable to invoke Code Parameters due to errors in source code”错误。早期版本的Delphi该方法并不是非常稳定,在6、7这些较新的版本里面判断准确率得到提高,不失为一个快速检查错误的方法;
编译期间错误也叫语法错误。Delphi的语法检查是非常严格和准确的,一般通过编译错误信息可以准确判断错误的原因。例如以下是常见的两个
';' not allowed before 'ELSE' ElSE前不允许有“;”
Variable '' might not have been initialized 变量名可能没有初始化
但是象Statement expected, but expression of type '' found (要求语句,但出现类型的表达式)这类编译错误信息,常常是由于begin … end不配对造成的,这样判断出错的地点和原因只能根据经验了。
跟踪调试、断点
Delphi的跟踪调试方法有如下几种
逐行单步跟踪(Step Over):一次运行一行代码,如果程序行含多条语句、函数或过程也一次执行;
逐条单步跟踪(Trace Into):一次运行一条代码,如果包含函数或过程,则跟进函数或过程;
跟踪至光标位置(Run To Cursor):程序自动运行停留在光标所在行,就象光标处设置了断点一样;
暂停执行(Program Pause):即使未设置断点,也可以在运行时中断程序,激活调试器;
程序复位(Program Reset):强制关闭运行中的程序,回到编辑状态。假如遇到死循环就非得靠它了J
在某行有效语句上可以设置断点(最简单的是在程序行最左边点击,该行会改变颜色)。程序运行到该行时将自动暂停,这时将鼠标移动到变量名称上可以显示变量此时的值。也可以打开Watch List、Evaluate/Modify、View CPU等窗口,下面将简单介绍它们的作用。
运行时状态:值、堆栈、线程、CPU视图
在程序中断状态下,通过菜单可以打开几种状态查看器(图以Delphi7为例,其他版本大同小异)。
虽然中断时用鼠标指向当前过程内的变量可以显示值,但是对于数组中的某个具体变量,或者对象的某个属性就不容易看到值了。这时候可以使用Watch List可以添加一批当前过程内变量/对象并查看其值。
Watch List只能查看变量,而Evaluate/Modify还允许快速查看并修改某个变量的值,这对激活某些代码很有用,因为通过修改运行中变量的值,能够轻易模仿特殊状态(例如测试某个临界值)。
Call Stack窗口可以查看堆栈状态,一般用于有效的检查递归过程;
Threads窗口可以获得线程信息,并改变线程等级,查看当前线程代码;
刚开始使用View CPU的人可能会觉得难以明白,其实View CPU显示的是当前存储状态,同时用汇编语言表达当前语句的执行情况。假如你有汇编的知识,可以很容易的看出地址转移、赋值的情况,这对于猜测指针、对象引用错误的具体原因相当有效。经常会遇到这样的情况,一个对象偶尔会在free的时候出错,你不清楚这是为何导致的,通过View CPU发现,原来是其中某个成员类的临时父类的指针不正确了,原来是被意外释放了。对于复杂的程序,常常遇到这种“奇怪、不应该”的错误,这时候就可能需要详细的了解代码执行过程;
WinSight32是一个调试工具,用于查看跟踪对象的消息。
外部调试DLL
调试器允许使用任何应用程序作为宿主来调试DLL。打开Dll项目,从主菜单中使用Run|Parameters命令,在Run Parameters对话框中指定一个宿主程序。宿主程序是个可执行文件,它加载并调用该DLL。然后就可以在DLL工程中设置断点、跟踪等等。
提醒大家Delphi中用上述方法调试DLL(特别是服务器COM、DCOM等用接口调用的DLL)并不是一定有效。由于操作系统或其他软件的影响,可能会无法正常跟踪、中断。对于这种情况,一般是建立一个调用DLL单元的执行程序直接调试,通过后最终再编译成DLL文件。
异常处理篇
异常处理是编程人员都应该熟悉的一章。程序执行时可能会因为各种情况产生完全不可预测的问题。或许你可以说“我的程序没有BUG”,但是你能保证硬件、操作系统完全稳定吗?没有异常保护,比如多层体系的数据库程序,小小的网络错误或者数据库错误就能导致崩溃;再比如一个3D游戏,显卡、CPU、内存任何一处稍微不稳定,小则退出程序,大则死机…类似的情况非常的普遍,千万不能坚信“理想的运行”状态!
除了考虑上述运行错误,还有两种情况也得进行异常处理。一种是你自己无法100%保证代码没有错误,或者说特殊情况下没有错误:内存是否完全回收干净?指针是否绝对指向正确?小数据量、正常操作下可能从未出现的问题,一旦放在一个频繁使用的陌生环境中,可能来出错在哪里你都无法猜测,这时你只能尽可能的考虑异常,并进行相应的“善后”工作。
还有一种异常是完全在你掌握中的情况,甚至有可能是“故意”抛出的异常。比如数据库连接的意外失败,用户经常出现的某个误操作,或者某种极少见的可能导致错误的执行顺序。解决这类情况的方法中,往往最简单的一种就是 “故意”的让它进入异常,处理甚至忽略掉。比如检查一个字符串是否可以转化成整数的简单做法就是:
function CheckStrIsInt(out I : integer; const s : string):Boolean;
begin
try
I := strtoint(s); //I是整型,s是字符串
Result := true;
except
Result := false;
end;
end;
Delhpi异常处理机制及异常类
Delphi的异常处理机制采用的是Protected Blocks(保护块)的概念。保护块是用Try…end封装的一段代码,其作用是当该部分的代码发生错误是自动创建一个异常类Exception,它是允许嵌套的。程序可以捕获并处理该异常类,以确保数据不受破坏。Delphi中所有异常类都是Exception的子类,所以也可以建立自己的异常类。
Delphi将所有异常基本分为运行时间库(RTL)异常、对象异常、部件异常三大部分。大部分异常被定义在SysUtils单元中。其中运行时间库异常是最常见的异常,它分为七类:
I/O异常、堆异常、整数异常、浮点异常、类型匹配异常、类型转换异常、硬件异常。比如来自文件或外设的操作错误将引发I/O异常;除零(c := 0;a := b / c;),值溢出将导致整数异常;类型转换错误(strtoint(‘123a’))引发类型转换异常;未初始化指针或对象的引用会导致硬件异常。
对象异常类有四种:流异常、打印异常、图形异常、字符串链表异常。
部件异常则分为通用及专用部件异常。
资源保护(try … finally)
这里我先例举两个错误的例子。第一个例子显示了为什么内存会丢失:
var
i : integer;
procedure TForm1.Button1Click(Sender: TObject);
var
p : Pointer;
a : integer;
begin
GetMem(p, 1024);
a := SizeOf(p);
try
a := a div i;
showmessage(floattostr(a));
FreeMem(p, 1024);
except
showmessage('先点击Button2');
end;
end;
procedure TForm1.Button2Click(Sender: TObject);
begin
try
i := strtoint(edit1.Text);
except
i := 1;
end;
end;
这里i是一个全局的变量,作者的思路是先点击了Button2,将i赋值,注意这里他已经考虑到类型转换可能会失败而进行了异常处理。但是他疏忽的考虑了另一种情况:用户没有点击Button2而先点击Button1,这时因为未进行初始化,i为0,结果导致除零的错误。他仅仅又用了一个异常保护。注意,这时候程序可以正常的执行下去,但是因为直接跳转到except部分,而没有执行FreeMem,结果将导致1K的内存被丢失了。
第二个例子是显示为什么引用一个对象出错
var
tab : TTable;
begin
tab.Create(nil);
tab.DatabaseName := './';
tab.Free;
end;
注意,这里要执行到第二句时才会产生错误。初学者常犯的毛病,一是忘记创建对象的实例就使用它,二是直接用实例名Create(应该用类创建实例,返回的指针赋与该实例名,实际是个指向对象地址的指针),三是没有释放或不可靠的释放对象。
第一个例子的正确写法应该改成(请原谅我仅仅为了举例,判断是否除零直接用if I <> 0 then a := a div I else当然更好):
try
try
a := a div i;
showmessage(floattostr(a));
except
showmessage('先点击Button2');
end;
finally
FreeMem(p, 1024);
end;
而第二个例子则是:
tab := TTable.Create(nil);
try
//正确代码
finally
tab.Free;
end;
try … finally … end就形成一个资源保护块,通常在一个对象创建或内存分配之后,然后在Finally部分释放内存。无论正确执行还是出现异常情况,Finally都保证绝对可被执行。对于对象的创建一定要习惯这样的写法,以保证无论程序执行中是否产生错误,都可以回收分配的内存。
未知异常(try … except)
try … finally … end与try … except … end的区别是,无论正常与否,都会执行finally的部分,而正常情况下则不会执行except的部分。所以finally用于必须回收资源的情况,而except用于处理错误的情况。
通常除了前面提到的“故意”利用异常来判断某种“不规范”情况外,有几种错误往往是有经验的程序员也难以避免的:1、大量使用指针、动态数组、对象关联的复杂情况,很难保证不会指针引用错误、数组越界、对象关联失效;2、网络类程序,无法考察千变万化的网络状况;3、多层、远程的系统,无法保证某个层次运行在绝对正常的状态;4、多线程的程序,尤其是公用数据的时候,难以保证线程安全;5、来自操作系统、硬件、其他软件的影响。
这时候只有尽可能的多考虑什么时候“有可能”出错,然后try掉。很多情况下不用考虑太多的解决方式,仅仅提示用户操作失败,重新执行或者重新配置系统就可以了。比如进一步完善上面数据库表格操作的例子:
var
tab : TTable;
begin
tab := TTable.Create(nil);
try
try
tab.DatabaseName := './';
tab.TableName := 'tmp.db';
tab.Active := true;
//添加表格操作代码
except
showmessage('表格打开失败,请检查表格文件是否存在');
end;
finally
tab.Free;
end;
end;
判断响应异常(on … do)
异常的响应并非只能有一种执行顺序。保护块可能产生好几种异常的情况,这时可以通过on..do语句来判断并分别处理。On … do是与try … except连用的,举例如下:
var
a, b, c, d : single;
i : byte;
begin
a := 99;
b := a * a;
c := b * b;
try
a := a/b + b/c + c/d; //无法判断是否会除0
i := round(a * b * c * d); //无法判断i是否越界
except
on ERangeError do showmessage('越界');
on EIntError do showmessage('除0');
else
showmessage('未知错误');
end;
end;
实际上我在执行它的时候,出现的错误是浮点溢出J。
注意,由于异常再处理后既被清除,所以on … do是依赖于执行顺序的。假如希望对异常多次处理,则需要利用raise在异常响应结束时重新引发一个当前异常。
异常的自定义及自引发 (raise)
定义异常类与定义普通类一致,比如我们来看哑异常的定义:EAbort = class(Exception);具体实现可参考SysUtils单元中的异常定义。
前面已经提到,raise可以重新引发一个异常。除了实现多次异常处理的作用外,还可以根据任何条件来创造一个“异常”情况。比如:
type
EpasswordError = class(Exception);
if Password <> str then
raise EpasswordError.Create(‘Passwrod is error’);
通过异常自定义及控制引发,可以拥有自己软件的一套错误信息库。
哑异常及异常处理事件
哑异常是通过Abort产生的。调用Abort将产生Eabort异常,然后退出当前例程。由于Eabort没有任何异常消息,所以常把Abort当作例程终止过程来用(这又是个个人习惯问题,比如我喜欢人为考虑不正常的情况,然后exit例程,但有些人喜欢直接Abort)。
对于一个普通的Application应用程序,可以为Application对象的OnException事件编码,进行自己的异常处理,这和接管对象的其他事件是一样的。也可以接收一个未处理的异常事件,通过消息处理机制,再转到Application. OnException。假如未编写该事件的处理代码,则调用标准的HandleException例程,此例程会调用ShowException显示异常提示信息。
尾记
开篇已经说过,随着经验的增加,减少错误的产生是完全有可能的。比如记住赋初值并绝对保证资源的回收。再很多情况下变量的初值是不可靠的,常见的错误是简单的认为整/实型的初值为0,实际上局部的变量是没有初值的,它的值根据当时内存分配情况来定;也不要认为从数据库或文件中读出来的NULL就等于0,NULL是绝对不等于0的!所以养成除法一定要判断除零错误之类的习惯是相当必要的。
引用对象时记得检查是否为nil,动态数组先判断边界,检查指针正确性,转换对象用as、is判断等等…初学者往往学会try后就喜欢用try,记住,try不是万能的解决方法,可预知的错误尽量的解决,而无法预知的错误才考虑异常。异常处理只是解决方法中的一种,虽然它是必须的,但不能滥用。