C#基础知识梳理系列十:异常处理 System.Exception
人非圣贤,孰能无过。代码是人写的,当然也不可能不出错,我们只能期望代码更健壮,不可能追求完美,能做更多的就是如何从错误中恢复或寻找替代方案。CLR提供了异常处理机制,它不仅能让代码在出错的时候更优雅地让人们去解决异常,也能在必要的时候抛出异常。那么,如何更规范的定义和使用异常消息呢?抛出异常会不会影响性能呢?
在早期的Win32 API设计中是通过返回true/false来表示一个过程(方法、函数)是否执行成功,在COM中是使用HRESULT来表示一个过程是否正确执行,然而这种处理异常的方式使开发人员对哪里出错,为什么出错,出什么样的错这些问题很难找到明确的答案,再一点,调用者很容易忽略一个过程执行的结果,如果调用者丢弃了过程执行结果,则代码将“按照期望的状态正常执行”,这是很危险的。后来,在.NET Framework中,已经不再使用这种简单的以状态码来表示执行结果的处理方式,而是使用抛出异常的方式来告诉调用者:兄弟,出错了!如果你不赶紧修复,系统将让你无法进入决赛!
CLR中的异常是基于SEH的结构化异常处理机制构建的,在基础的SEH机制中是向系统注册出错时的回调函数,当在监视区内出错时,系统会取得当前线程的控制权处理回调函数,处理完毕后,系统释放控制权给当前线程,当前线程继续执行,如果未处理异常的代码段,会导致进程中止。有关SEH的详细内容请参考MSDN文档。
CLR在对SEH封装的基础上以更优雅的方式向开发人员提供良好的编程体验,它把异常处理分为三块try、catch、finally。
Try块是监视区,其内放置一些正常实现编程功能的代码、资源清除的代码、状态维护(状态改变和状态恢复)的代码等。
Catch块捕获区,当try块出现异常时,如果异常类型与该区域期望的类型一致,则执行此区域的代码,可以进行状态恢复,也可以重新抛出异常。一个try块可以个catch块,也可以无catch块。
Finally块作最后清理工作,在一个try/catch 结构中,无论try是否抛出异常,无论catch是否破获到异常,如果有finally块,在最后都会执行,通常在这里放置资源清理的代码。一个try结构可有finally块,也可以没有。
如下是一个使用try/catch/finally块的示例:
FileStream fs = null; try { fs = new FileStream("c:\\file.txt", FileMode.Open); fs.ReadByte(); } catch (IOException) { } catch { } finally { if (fs != null) { fs.Close(); fs = null; } }
需要注意的是,一个try块必须有一个catch块或finally块与其对应,如下几种使用方式都是可以的:try…catch 、try…finally、try…catch.finally。
前面已经说到,一个try块可以有对应的0个或多个catch块,如果try块中无异常抛出,则CLR不会执行任何catch块。Catch关键字后的圆括号中的表达式称为捕捉类型,每个catch块只能指定一个捕捉类型,C#要求捕捉类型只能是 System.Ex ception类或其派生类。当try块出现了异常,CLR会以catch编码的顺序从上向查找与异常类型相匹配的catch 块,所以“窄”的异常类型应该放在最前面的catch 块中,最“宽”的异常类型应该放在最后面的catch块中。假如有如下继承关系的伪代码:
Class 类A:类B:类C:System.Exception
我信通常应该如下来放置catch筛选器类型:
try { // } catch(A) {} catch(B) {} catch(C) {} finally {}
如果查找完catch 块都没有发现有匹配的类型,CLR会去向更高一层的调用栈查找相匹配的异常类型,如果到了栈的最顶部还未找到,CLR会抛出“未处理异常”。在堆栈上查找的过程中,如果找到了相匹配的catch块,则执行从抛出异常的try块开始到匹配异常的catch 块为止这个范围内的所有的finally块(如果有)。注意,此时匹配的catch关联的finally还未执行,执行完该匹配catch块内的代码后才执行此finally块。
无论抛出哪种异常,其实都是CLR抛出经过包装后的.NET异常,也就是说CLR已经在内部对这些异常进行过处理,只是以更优雅且强制的形势抛出来。假如有如下的调用过程:
方法Method0内调用方法Method1,方法Method1内调用方法Method2。如果在方法Method2方法内抛出一个异常,且无与此异常类型匹配的catch块,则CLR会回溯调用堆栈向Method2、Method1、Method0查找匹配的异常类型,如果找到了则执行相关的finally和catch块, 如果还未找到,则抛出未处理异常,如下代码:
public void Method0() { try { Method1(); } catch { Debug.WriteLine("Method0 catch"); } finally { Debug.WriteLine("Method0 finally"); } } public void Method1() { try { Method2(); } catch (NullReferenceException) { Debug.WriteLine("Method1 catch NullReferenceException"); } catch (FileNotFoundException fileNot) { Debug.WriteLine("Method1 catch FileNotFoundException"); throw fileNot; } finally { Debug.WriteLine("Method1 finally"); } } public void Method2() { FileStream fs = null; try { //假如c:\\file.txt不存在,这里一定会抛出文件未找到异常 fs = new FileStream("c:\\file.txt", FileMode.Open); fs.ReadByte(); } catch (ArgumentException) { Debug.WriteLine("Method2 ArgumentException"); } finally { Debug.WriteLine("Method2 finally"); if (fs != null) fs.Close(); fs = null; } }
在方法Method2内我们仅仅期望捕获 ArgumentException类型异常,很显然逮不到任何东西。在方法Method1内我们先期望捕获NullReferenceException类型异常,如果未逮到,我们期望捕获FileNotFoundException类型异常,这时可能真的逮到了,接着我们又将该异常抛出,而在上一级调用中,Method0方法内我们使用了异常的基类捕获范围超级广的异常!在APP内调用Method0方法通过打印出来的记录来看一下执行流程:
public void DoWork() { Method0(); }
打印:
Method2 finally
Method1 catch FileNotFoundException
Method1 finally
Method0 catch
Method0 finally
结果验证了CLR对异常处理的回溯过程。为了减少回溯的“长度”,建议在方法Method2内赶紧有目的地捕获可能的异常类型,这样让CLR少走点路。
在上面我们已经讲到,CLR是以抛出异常的方式来报告程序出现问题。早期微软定义了一个异常的基类:System.Exception,并且还定义了两个派生类:System.SystemException和System.ApplicationException。它们希望所有系统异常(CLR抛出)都必须派生于System.SystemException类,所有应用程序抛出的异常必须派生于System.ApplicationException类,但后来地方人员不听从中央人员的管理,结果从上到下都没很好的遵从这个原则,以至于我们后来在定义自己的异常类型时,直接从System.Exception类派生。大势已成定局,至于你喜欢从哪个类派生,看你的爱好了,其实在MSDN文档中已经建议我们应该从System.Exception类派生我们自定义的异常类型。
CLR要求自定义的异常类型必须从System.Exception继承。
FCL已经定义了很多可能用在各种情景下的异常类型,如常用的:
System. ArgumentException 参数无效时的异常
System. FileNotFoundException 访问磁盘上不存在的文件时引用的异常
System. IndexOutOfRangeException试图访问索引超出数组界限的数组元素时引发的异常
尽管FCL还有很多的异常类型,但这些不一定适合我们开发的需要,比如方便记录日志、方便调查项目层次、WCF中的异常消息等等,这些特殊的要求可能需要我们定义自己的异常类型。
关于异常System.Exception类本身的信息(如:Message、TargetSite、InnerException等成员)这里就不再描述,可查询MSDN文档获取更多信息。除了自定义异常类必须继承于System.Exception类以外,这里还给出一些自定义异常类型的建议:
(1) 所有的自定义异常类型名称应该以Exception后缀;
(2) 类型及其成员据,应该支持可序列化(实际上System.Exception类型是支持序列化的);
(3) 要提供以下三个构造函数:
public MyException() { } public MyException(string message) { } public MyException(string message, Exception inner) { }
(4) 建议重写ToString()方法来获取异常的格式化信息。
(5) 在跨越应用程序边界的开发环境中,如面向服务开发环境,应该考虑异常的兼容性。
定义异常的目的是在合适的时候抛异常来告诉客户程序:这里出异常了。通常是在一个方法内抛异常,当方法无法完成预定任务时应该抛出异常,抛出异常时应该抛出明确的异常类型,而永远不要抛出异常的基类型System.Exception、System.SystemException和System.ApplicationException。在第二节中,我们已经描述过,CLR是自上而下的寻找catch块,所以我们抛出异常的时候,也应该抛出意义明确的“窄”且“浅”的异常类型,这样可能会让CLR尽快找到。另外,在抛出异常类型时,我们应该详细地描述出现异常的原因、状况、可能的修复措施等,这样有利于调用者尽快定位问题,当然在面向服务开发的时候,可能出于安全考虑而不愿意详细描述,此时可以以提前约定的编码形式抛出异常码,此时可能需要你向客户程序提供一个与异常码对应的描述信息列表。尽量不要使用返回错误码的方式来代替异常,前面已经讲过,客户程序很可能忽略你的返回结果。如程序遇到了致命性的错误,请大胆地使用System.Environment.FailFast()方法来毫不留情的终止程序,否则程序以错误的状态运行可能会带来灾难,比如你的无人驾驶飞机可能去火星上找“好奇号”谈恋爱。
以下几个系统保留的异常类型,应该尽量避免抛出:StackOverflowException、OutOfMemoryException、ComException 和 SEHException、ExecutionEngineException。
catch块是专为处理异常而生的,CLR赋予它很强悍无比但不是至高无上的权力,所以我们应该使用catch块合理恰当的捕获我们有能力处理的异常,这里给一些建议或许能更好地让代码从异常中恢复:
(1) 如果你开发的是基础类库,出现异常时,哪怕是客户程序提供的数据无法让你完成功能流程时,要雄赳赳气昂昂地抛出异常,不要忍气吞声地吃掉它;
(2) 不要在catch块内编写可能再出异常的代码,除非是抛出异常,也要尽量保证不在finally块内编写可能出新异常的代码;
(3) 不要捕获你没能力处理的异常,关闭你的大门,让CLR尽快点离开向上回溯找到那个像铁笼赛中拍打着笼门急等着出来拼战的猛士,他或许能拯救这个世界;
(4) 尽量不要使用catch(Exception)撒天网,它将死的很惨;
(5) 捕获了异常后,你应该尽快让代码数据从异常中恢复,如果不能回复,你应该想办法让状态回滚,否则,要么你就继续抛出异常,要么就拿出尚方宝剑System.Environment.FailFast();
(6) 如果在捕获异常后出于某种目的,想再次抛出异常,请保持堆栈信息,这样方便在上层排查异常;
(1) catch块和catch(Exception)块
CLS要求所有面向CLR的编程语言都必须抛出继承于System.Exception的异常类型。在CLR 2.0以前的版本中,catch块只能捕获与CLS相容的异常,它无法捕获与CLS不相容的任何异常(包括其他面向CLR的编程语言抛出的异常)。在CLR2.0中,微软有了一个新的类型System.Runtime.CompilerServices.RuntimeWrappedException,当一个与CLS不相容的类型抛出时,CLR会实例化一个RuntimeWrappedException类型的对象,将在其私有字段WrappedException中放置非CLS相容的异常,接着抛出RuntimeWrappedException。
catch(Exception e){}块,在CLR2.0以前可以捕获所有与CLS相容和不相容的异常,但CLR2.0及以后的版本中,只能捕获与CLS相容的异常。
Catch{}块在所有版本中可以捕获所有与CLS相容和不相容的异常。
(2) throw和throw ex
在前面我们讲过,CLR有可能通过回溯堆栈来查找与异常类型一致的catch块,System.Exception类有一个属性StackTrace,它记录了发生异常的堆栈跟踪信息,它描述的是异常发生前调用的方法。当一个异常抛出时,CLR会记录抛出异常(throw)的位置,经过CLR回溯找到对应的catch块后,CLR会再记录catch的位置,然后在内部会使用StackTrace记录这两个起止位置之间的调用(方法)过程。我们对第2节中的方法Method1进行改造,在捕获了FileNotFoundException类型的异常后,重新抛出该异常:
catch (FileNotFoundException fileNot) { Debug.WriteLine("Method1 catch FileNotFoundException"); throw fileNot; //throw; }
先用throw fileNot;看看在Method0方法中捕获到的异常堆栈信息:
再来看看使用throw;抛出异常的堆栈信息:
可以看到,使用throw fileNot;后的堆栈信息是从那个方法为异常堆栈信息的起点,使用throw;后的规模信息是从System.IO.__Error.WinIOError方法为起点,二者无非就是CLR确定异常的起始位置不一样。
最后要明确一点的是,无论在什么情景下抛出什么样的异常,Windows都会重置堆栈的起点,我们所拿到的堆栈信息都是最新的起止方法调用记录信息。