知错“易”改──程序设计异常处理
软件世界
编程是一项严密的工作,一个小小的错误就会造成程序不能正常运行,甚至引起程序、系统崩溃。因此,良好的编程习惯,熟悉编程语言的异常处理机制,掌握异常处理方法就成为一个程序员的必修课。
程序中的异常处理应该遵循以下几个原则:
尽量少的使用全局变量;尽可能的用过程或函数约束自定义变量;调用函数时返回值类型尽可能一致;不使用与系统中重命的函数名称;建立良好的编程习惯。
对于入门级的程序员,良好的编程习惯往往比苦练开发工具更有意义。
本文将重点介绍VB、C#、Java等几种语言的异常处理机制及方法。
一、Visual Basic篇
作为一种传统的编程语言,VB的错误处理机制不算健全,对于潜在异常的捕捉能力不是太强。但是,作为一种比较容易上手的编程语言,VB在异常处理的方便性上还是相当不错的。
1.程序代码检查
检查代码的正确性是程序运行前必需执行的步骤,在VB中提供了自动检查的选项,它们集中在“编辑器”选项卡中,该选项卡可通过点击“工具→选项”菜单命令来打开。
其中,“自动语法检查”选项可以设置输入代码后,VB是否自动校验语法的正确性。“要求变量声明”选项可以设置模块中是否需明确的变量说明,选择后将会添加“Option Explicit”语句至任何新建模块的代码前。
2.程序代码调试工具
VB的集成开发环境中提供了较完整的调试工具,主要集中在“调试”菜单中,该菜单包含了运行时调试、监视调试和断点调试等类别。相关内容请参阅VB帮助系统。
3.代码处理
前述内容主要用于“演练”,即在程序还未真正发布时使用,其实更为重要的异常处理在于编制代码时已考虑到正式发布后可能遇到的种种错误,并及时在代码中加以判断和操作。VB在错误代码处理方面提供了较强大的功能,可在运行错误产生时跳转或继续执行、获取错误信息、判断是否出错等。
(1)运行时错误处理
VB提供了“On Error”系列语句来进行运行时的错误处理,该系列语句可启动错误处理程序,也可用于禁止错误处理程序。如果不使用“On Error”系列语句,那么任何运行时错误都可能使程序崩溃,即显示错误信息并中止运行。其语法如下所示:
On Error GoTo line
On Error Resume Next
On Error GoTo 0
On Error GoTo line语句:该语句用于启动错误处理程序,且程序需从必要的“line”参数中指定的位置开始。“line”参数可为任何行标或行号。如发生运行时错误,则程序会跳转至“line”处并激活错误处理程序。需注意,指定的“line”参数必须和On Error语句在一个过程中,否则会发生编译错误。
On Error Resume Next语句:该语句说明当运行时错误发生时,程序跳转至紧接着发生错误的语句之后继续运行。该语句程序在发生错误时仍继续执行,多用于要求进行嵌入错误处理的需求。
On Error GoTo 0语句:该语句禁止当前过程中任何已启动的错误处理程序。如不使用该语句,那么在退出过程时错误处理程序会自动关闭。
一个比较完善的错误处理代码如下所示:
Sub TestError()
On Error GoTo ErrorHandler
'. . .相关代码
Exit Sub
ErrorHandler:
'. . .错误处理代码
'继续执行
Resume Next
End Sub
注意:错误处理程序代码应在“Exit Sub”语句之后,而在“End Sub”语句之前。
(2)获取错误信息
在程序中处理了错误流程后,即可进一步获取错误的信息,使程序可即时处理或直接反馈给用户。在VB中可通过Err对象来捕获运行时的错误信息,该对象是程序全局范围内的固有对象,因此在代码中不需建立对象实例即可直接引用(类似“APP”对象),其常用方法和属性如下表所示:
方法或属性 |
说明 |
|
Clear方法 |
用于清除 Err 对象中的所有属性设置。在处理错误后应使用该方法来清除 Err 对象,例如,“On Error Resume Next”语句使用拖延错误处理时即可使用该方法 |
|
Description属性 |
用于返回或设置错误的描述性字符串,可对错误进行简短描述 |
|
HelpContext属性 |
返回或设置字符串表达式,代表帮助文件中的主题的上下文ID |
|
HelpFile属性 |
返回或设置字符串表达式,代表帮助文件的完整路径 |
|
LastDLLError属性 |
返回最后一次调用动态链接库而产生的系统错误号,且仅适用于由 VB代码进行的动态链接库的调用 |
|
Number属性 |
返回或设置表示错误的数值,是 Err 对象的缺省属性。当从对象返回用户自定义的错误时,将被错误代码和 vbObjectError 常数相加,并由此来设置 Err.Number |
|
Raise方法 |
用于通过错误号来模拟产生运行时错误 |
(3)恢复程序运行
在错误处理程序结束后应即时恢复程序原有的运行流程,通过Resume语句即可实现,该语句在错误处理程序之外的任何地方使用均会导致错误发生。其语法如下所示:
Resume [0]
Resume Next
Resume line
Resume语句:该语句当错误和错误处理程序出现在同一个过程中,则从产生错误的语句处恢复运行。如果错误出现在被调用的过程中,则从最近一次调用包含错误处理程序的过程语句处恢复运行。
Resume Next语句:该语句当错误和错误处理程序出现在同一个程序中,则从紧随产生错误的语句的下个语句恢复运行。如错误发生在被调用的过程中,则对最后一次调用包含错误处理程序过程的语句处恢复运行。
Resume line语句:该语句在“line”参数指定的位置处恢复运行,“line”参数必须为行标签或行号且必须和错误处理程序在同一个过程中。
4.错误处理
一般的VB程序往往使用了大量的模块,此时的错误处理代码可能会变得相当复杂,笔者总结了一些处理复杂错误的经验,具体如下所述:
调试代码时,对于一些可能发生错误的代码,均应通过“On Error”语句来编制错误处理程序。
处理错误后一般应即时通过Err对象的Clear方法清除错误,以便继续捕获下一个错误,尤其在使用“On Error Resume Next”语句时更为必要。
建议编制一个“万能”的“错误处理子程序”,即该子程序一般可在任何严重或难以预计的错误发生时,能避免程序数据丢失或系统崩溃等恶性后果的发生。
5.实例代码
下面的程序可在软件运行读取A驱时,自动对A驱进行检测。如发生未插入磁盘、写保护等错误时,程序将自动捕获并提示用户,代码如下所示:
Sub TestDrive()
'设置错误的处理流程为跳转至指定的语句
On Error GoTo DiskErr
'任意读取或写入A盘的代码,本实例使用了读取A盘的“Dir "A:\"”命令
Dir "A:\"
'防止程序直接进入错误处理子程序
Exit Sub
DiskErr:
Select Case Err.Number
'A驱无盘
Case 52, 71
If MsgBox("A驱无盘!" & vbCrLf & "请插入盘后再试!" & vbCrLf, vbOKCancel, "无盘错误!") = vbOK Then
'返回出错的语句重新处理
Resume
Else
'忽略错误
On Error Resume Next
End If
'A盘写保护
Case 70
If MsgBox("请打开A盘写保护!", vbOKCancel, "写入错误!") = vbOK Then
Resume
Else
On Error Resume Next
End If
Case Else
MsgBox "错误号:" & Err.Number & vbCrLf & "错误内容:" & Error, , "其他错误!"
End Select
End Sub
二、C#篇
结构化异常处理是现代分布式环境下组件设计的一个必要的环节,.NET通用语言运行时从底层构造给予异常处理以坚实的支持。在C#中,异常对象被设计为封装了各种异常信息的类(System.Exception及其继承子类,和接口类似,它被推荐命名时加上后缀“Exception”,但这并非必须)。
除了.NET内部封装的底层异常信息类外,C#处理异常最直观的方法就是利用“try-catch-finally”语句和异常对象来完成异常侦测、异常捕捉和处理等一揽子服务。下面,我们就重点讨论C#的异常处理语句。
1.使用 try 和 catch捕获异常
对于程序设计者而言,都希望在异常出现时首先被捕获而不是自动把异常信息提示给用户,以便程序继续执行。在C#中利用try和catch就能实现,try包含可能会产生异常的语句,而catch处理一个异常。请看下面的代码:
1: using System;
2:
3: class Factorial
4: {
5: public static void Main(string[] args)
6: {
7: long nFactorial = 1, nCurDig=1;
8: long nComputeTo = Int64.Parse(args[0]);
9:
10: try
11: {
12: checked
13: {
14: for (;nCurDig <= nComputeTo; nCurDig++)
15: nFactorial *= nCurDig;
16: }
17: }
18: catch (OverflowException oe)
19: {
20: Console.WriteLine("Computing {0} caused an overflow exception", nComputeTo);
21: return;
22: }
23:
24: Console.WriteLine("{0}! is {1}",nComputeTo, nFactorial);
25: }
26: }
正如你所见,异常处理并不麻烦。你所有要做的是在try语句中包含容易产生异常的代码,接着捕获异常,该异常在这个例子中是OverflowException类型。无论一个异常什么时候被引发,在catch段里的代码会进行适当的处理。
如果你不事先知道哪一种异常会被预期,而仍然想处于安全状态,简单地忽略异常的类型。代码如下:
try
{
...
}
catch
{
...
}
但是,通过这个途径,你不能获得对异常对象的访问,而该对象含有重要的出错信息。一般异常处理代码是这样的:
try
{
...
}
catch(System.Exception e)
{
...
}
注意:你只能获取e对象中的值。
2.使用try和finally清除异常
如果你更关心清除错误而不是对错误的处理,try和finally会获得你的喜欢。它不仅抑制了出错消息,而且所有包含在finally块中的代码在异常被引发后仍然会被执行。
1: using System;
2:
3: class Factorial
4: {
5: public static void Main(string[] args)
6: {
7: long nFactorial = 1, nCurDig=1;
8: long nComputeTo = Int64.Parse(args[0]);
9: bool bAllFine = false;
10:
11: try
12: {
13: checked
14: {
15: for (;nCurDig <= nComputeTo; nCurDig++)
16: nFactorial *= nCurDig;
17: }
18: bAllFine = true;
19: }
20: finally
21: {
22: if (!bAllFine)
23: Console.WriteLine("Computing {0} caused an overflow exception", nComputeTo);
24: else
25: Console.WriteLine("{0}! is {1}",nComputeTo, nFactorial);
26: }
27: }
28: }
通过检测该代码,你会发现,即使没有引发异常处理,finally也会被执行。在finally中的代码总是会被执行的,不管是否具有异常条件。
3.使用try-catch-finally处理所有异常
应用程序最有可能的途径是合并前面两种错误处理技术──捕获错误、清除并继续执行应用程序。所有你要做的是在出错处理代码中使用try、catch和finally语句。请看下面的实例:
1: using System;
2:
3: class CatchIT
4: {
5: public static void Main()
6: {
7: try
8: {
9: int nTheZero = 0;
10: int nResult = 10 / nTheZero;
11: }
12: catch(DivideByZeroException divEx)
13: {
14: Console.WriteLine("divide by zero occurred!");
15: }
16: catch(Exception Ex)
17: {
18: Console.WriteLine("some other exception");
19: }
20: finally
21: {
22: }
23: }
24: }
这个例子的技巧是,它包含了多个catch语句。第一个捕获了可能出现的DivideByZeroException异常,而第二个catch语句通过捕获普通异常处理了所有剩下来的异常。
我们肯定总是首先捕获特定的异常,接着是普通的异常。如果不按这个顺序捕获异常,会发生什么事呢?看完下面这段代码你也许就明白了。
1: try
2: {
3: int nTheZero = 0;
4: int nResult = 10 / nTheZero;
5: }
6: catch(Exception Ex)
7: {
8: Console.WriteLine("exception " + Ex.ToString());
9: }
10: catch(DivideByZeroException divEx)
11: {
12: Console.WriteLine("never going to see that");
13: }
编译器将捕获到一个小错误,并类似这样报告该错误:
wrongcatch.cs(10,9): error CS0160: A previous catch clause already
catches all exceptions of this or a super type (''System.Exception'')
三、Java篇
我们知道,Java提供了丰富的错误及异常情况处理措施,对各种可能出现的异常都作了充分的考虑,可以说,没有哪一种语言象Java对异常考虑得这样周全。作为一门面向对象语言,Java把它对异常的处理都封装到类里。
1.出错处理
在传统的非面向对象的编程语言中,错误处理的任务全在程序员身上,程序员必须考虑在程序中可能出现的种种问题,并且自行决定如何处理这些问题。这样做有两个缺点,一是编程人员的负担过重,二是出错处理不规范,每个程序员都有自己的一套处理错误的习惯,这样就不利于程序员之间的合作,而且降低了程序的可读性。
在Java中使用异常为程序提供了一种有效的错误处理的方式,使得方法的异常中止和错误处理有了一个清晰的接口。异常处理的方式和传统的方式有所不同,它的基本处理方式是:当一个方法引发一个异常之后,可以将异常抛出,由该方法的直接或间接调用者处理这个异常。这就是我们常说的catch-throw(捕获-抛出)方式。这种处理方式使得错误的处理变得规范化,程序员可以用一致的方式来处理错误。
2.异常类
Java是一门面向对象的编程语言,因此,异常在Java中也是作为类的实例的形式出现的。Java中的所有的异常类都是从Throwable类派生出来。Throwable类就是Java提供的最有效的异常处理类,它有两个直接子类──Error和Exception。
Error类及其子类主要用来描述一些Java运行时刻系统内部的错误或资源枯竭导致的错误,普通的程序不能从这类错误中恢复。
Exception类和它的子类与Error类不同,它是普通程序可以从中恢复的所有标准异常的超类。
Exception类又有两个分支──从RuntimeException中派生出来的类和不是从RuntimeException类中派生的类,这样分类的根据是错误发生的原因。RuntimeException是程序员编写程序不正确所导致的异常;而其他的异常则是由于一些异常的情况造成的,不是程序本身的错误,如果这些异常情况没有发生,程序本身仍然是好程序。
RuntimeException类的错误又包括:错误的强制类型转换、数组越界访问、空指针操作。其他异常(非RuntimeException类的异常)则包括文件指针越界、格式不正确的URL、试图为一个不存在的类找一个代表它的Class类的对象。
RuntimeException类的异常的产生是程序员的过失,理论上,程序员经过检查和测试可以查出这类错误。
3.在方法中声明异常
通常情况下,我们会在方法中声明可能出现的异常。这样做的目的是让方法就可以告诉编译器它可能会产生哪些异常,从而要求它的使用者必须考虑对处理这些异常,这样就使异常及时得到处理,减少了程序崩溃的几率。
Java可能会抛出异常的情况包括:调用的方法抛出了异常、检测到了错误并使用throw语句抛出异常、程序代码有错误,从而导致异常,比如数组越界错误、Java运行时刻系统产生内部错误。
当前两种异常发生时,你应该告诉使用你的方法的人,你的方法强迫Java抛出异常。因为任何抛出异常的方法都是导致程序死亡的陷阱,如果没有任何代码来处理方法抛出的异常,就会导致程序结束。
在Java中,内置了异常的处理,在方法的声明中,可以指定方法中可能产生的异常。例如:
class Test
{
...
public String getInput() throws IOException
{
...
}
}
如果一个方法可能抛出的异常不止一个,可以在方法的throws子句中声明多个异常,这些异常使用逗号“,”隔开。例如:
class Animation
{
public Image loadImage(String s) throws EOFException, MalformURLException
{
...
}
}
并不是所有可能发生的异常都要在方法的声明中指定,从Error类中派生出的异常和从RuntimeException类中派生的异常就不用在方法声明中指定。这两类异常属于不检查异常(unchecked exception)。Java中的另一类异常是检查异常(checked exception)。检查异常是那些在程序中你应该处理的异常,而不检查异常则是哪些你无法干预的异常(如Error类异常)或者完全在你的控制之下,你完全可以避免的异常(比如数组越界错误)。而方法的声明中必须指明所有可能发生的检查异常。
方法实际抛出的异常可能是throws子句中指定的异常类或其子类的实例。比如在方法的声明中指明方法可能产生IOException,但是实际上可能抛出的异常或许是EOFException类的实例,这些异常类都是IOException的子类。
当子类的方法覆盖了超类的方法时,子类方法的throws子句中声明的异常不能多于超类方法中声明的异常,否则会产生编译错误。因此,如果超类的方法没有throws子句,那么子类中覆盖它的方法也不能使用throws子句指明异常。对于接口,情况相同。
4.抛出异常
你可以在程序中使用throw语句来抛出异常。throw语句的形式为:
import java.io.IOException;
public class ThrowException
{
public static void main(String[] args)
{
try
{
String s = getInput();
System.out.println(s);
}
catch(IOException e)
{
System.out.println(e.getMessage());
}
}
static String getInput() throws IOException
{
char[] buffer = new char[20];
int counter = 0;
boolean flag = true;
while(flag)
{
buffer[counter] = (char)System.in.read();
if (buffer[counter]=='\n')
flag = false;
counter++;
if (counter>=20)
{
IOException ae = new IOException("buffer is full");
throw ae;
}
}
return new String(buffer);
}
}
上面的代码中,我们声明了一个方法getInput(),用来获取从键盘输入的字符,并以字符串对象的形式返回。我们还规定,从键盘输入的字符数不能多于20个,否则就抛出一个IOException异常。由于在方法getInput()中,可能会产生IOException异常,所以在方法的声明中必须指明这个异常:
static String getInput() throws IOException
在方法getInput()中,可能产生异常的地方有两处,一是Java的InputStream类的read()方法。InputStream类的read()方法的方法头如下所示:
Public abstract int read() throws IOException
但是在方法getInput()中,我们并没有捕获和处理这个异常:
buffer[counter] = (char)System.in.read();
另一个可能产生异常的地方是方法中的while循环,在这个循环中,如果输入的字符数大于20,我们就抛出一个IOException异常。
在方法main中,我们捕获这个异常,并打印出异常的信息。如果没有异常发生,就打印出我们得到的输入字符串。
由上可见,抛出异常无非就是一个三步曲:
确定异常类;创建异常类的实例;抛出异常。
一旦Java抛出了异常,方法不会被返回调用者,因此你不必担心返回值的问题,也不必提供一个缺省的返回值。
5.捕获异常
通过上面的介绍,我们已经知道异常的抛出是很简单的,只要遵循教条化的三步曲就可以了。然而异常被抛出以后,应该有相应的处理它的代码,否则,如果异常没有被处理,那么程序就会被系统终止。捕获异常并处理它要比抛出异常麻烦一些。
为了捕获异常,你需要使用try...catch语句。一种简单的try...catch语句的形式如下:
try
{
//正常执行的代码
......
}
catch(异常类型 e)
{
//这种类型的异常的处理代码
......
}
在建立了try...catch块之后,如果在try块中的任何地方抛出在catch子句中指定类型的异常,Java解释器将跳过异常抛出处以后的代码,而直接跳到catch子句中的异常处理代码处开始执行异常处理。
如果在try块中抛出的异常没有能够捕获它的catch块,则Java将立即退出这个方法;由于在方法的声明中已经指定了方法中可能产生的异常,所以在这个方法的调用者的代码中已经有了处理这种异常的代码,这样,这种类型的异常在此方法的调用者中得到了处理。调用者可能自己处理这种异常,也可能将这个异常放给它的调用者。异常就这样逐级上溯,直到找到处理它的代码为止。如果没有任何代码来捕获并处理这个异常,Java将结束这个程序的执行。
看下面的例子:
import java.io.IOException;
public class CatchException
{
public static void main(String[] args)
{
testTryCatch();
}
static void testTryCatch()
{
try
{
try
{
throw new IOException("exception 1");
//下面这条语句将产生编译错误,因为编译器发现程序永远也执行不到那里
// System.out.println("Executing here?");
}
catch(IOException e)
{
System.out.println(e.getMessage());
}
throw new IllegalAccessException("exception 2");
}
catch(IllegalAccessException ie)
{
System.out.println(ie.getMessage());
}
}
}
编译并运行程序以上代码将产生如下的输出结果:
exception 1
exception 2
从此程序中也可以看出,try…catch块允许嵌套。嵌套的try…catch块的处理也很简单。当有异常抛出时,Java将检查异常抛出点所在的try…catch块,看是否有能够处理它的catch块,如果有,则异常被此catch块所捕获。否则,异常被转到外层的try…catch块处理。
在上面的代码中,我们在方法testTryCatch()中捕获并处理了异常IOException和IllegalAccessException。然而,另外一种选择是在方法testTryCatch()中并不处理此异常,而将异常留给这个方法的调用者去处理,这时,你需要在方法testTryCatch()的throws子句中指明这个异常。
还有一种情况,你可能也希望在捕获了一个异常之后重新抛出一个异常,那就是在你捕获一个异常之后,你希望它作为另一个异常被方法的调用者处理。比如,你可能可以处理某些运行时刻异常,这时你可以刻意的捕获这个运行时刻异常并重新抛出一个新的异常供方法的调用者处理。运行时刻异常属于不检查异常,如果你在方法的throws子句中声明了运行时刻异常,那么方法的调用者可以不处理此异常。所以,如果你要强迫方法的调用者处理某个运行时刻异常,可以采用上述的迂回的方法──捕获运行时刻异常、抛出一种检查异常。
public class IndirectThrow
{
public static void main(String[] args)
{
try
{
testRuntime();
}
catch(MyArithException me)
{
System.out.println("Handle runtime exception here.");
}
}
static void testRuntime() throws MyArithException
{
try
{
//此处刻意产生一个除零错的异常
int i,j;
j = 0;
i = 1/j;
}
catch(ArithmeticException ae)
{
//在这里捕获运行时刻异常ArithmeticException
//不作任何处理,只是重新抛出一个新异常MyArithException
//因为运行时刻异常不在方法声明的throws子句中声明
throw new MyArithException(ae.getMessage());
}
}
}
class MyArithException extends Exception
{
MyArithException()
{
}
MyArithException(String msg)
{
super(msg);
}
}
此程序的运行结果如下:
Handle runtime exception here.
在testRuntime()方法中,我们刻意制造了一个除零错异常,它属于ArithmeticException异常类,而ArithmeticException类是RuntimeException类的子类。在方法声明的throws子句中,我们并没有声明ArithmeticException或RuntimeException,而是声明了自己的一个异常类MyArithException。这样,方法testRuntime()的调用者就不得不处理这个异常。在testRutime()方法内的try…catch块中,我们捕获了除零错的异常,但是在catch块中没有对它进行任何处理,而只是重新抛出了一个MyArithException类的异常。