编写高质量代码改善C#程序的157个建议[用抛异常替代返回错误、不要在不恰当的场合下引发异常、重新引发异常时使用inner Exception]
前言
自从.NET出现后,关于CLR异常机制的讨论就几乎从未停止过。迄今为止,CLR异常机制让人关注最多的一点就是“效率”问题。其实,这里存在认识上的误区,因为正常控制流程下的代码运行并不会出现问题,只有引发异常时才会带来效率问题。基于这一点,很多开发者已经达成共识:不应将异常机制用于正常控制流中。达成的另一个共识是:CLR异常机制带来的“效率”问题不足以“抵消”它带来的巨大收益。CLR异常机制至少有一下几个优点:
1、正常控制流会倍立即中止,无效值或状态不会在系统中继续传播。
2、提供了统一处理错误的方法。
3、提供了在构造函数、操作符重载及属性中报告异常的便利机制。
4、提供了异常堆栈,便于开发者定位异常发生的位置。
另外,“异常”其名称本身就说明了它的发生是一个小概率事件。所以,因异常带来的效率问题会倍限制在一个很小的范围内。实际上,try catch所带来的效率问题几乎忽略的。在某些特定的场合,如Int32的Parse方法中, 确实存在这因为滥用而导致的效率问题。在这种情况下,我们就应该考虑提供一个TryParse方法,从设计的角度让用户选择让程序运行得更快。另一种规避因为异常而影响效率的方法是:Tester-doer模式,下文将详细阐述。
本章将给出一些在C#中处理CLR异常方面的通用建议,一帮助大家构建和开发一个运行良好和可靠的应用系统。
本文已同步到http://www.cnblogs.com/aehyok/p/3624579.html。本文主要来学习以下几点建议
建议58、用抛出异常代替返回错误代码
建议59、不要在不恰当的场合下引发异常
建议60、重新引发异常时使用inner Exception
58、用抛出异常代替返回错误代码
在异常机制出现之前,应用程序普遍采用返回错误代码的方式来通知调用者发生了异常。本建议首先阐述为什么要用抛出异常的方式来代替返回错误代码的方式。
对于一个成员方法来说,它要么执行成功,要么执行失败。成员方法成功的情况很容易理解。但是如果执行失败了却没有那么简单,因为我们需要将导致执行失败的原因通知调用者。抛出异常和返回错误代码都是用来通知调用者的手段。
假设我们要实现这样一个简单的功能:应用程序需要完成一次保存新建用户的操作。这是一个分布式的操作,保存动作除了需要将用户保存在本地外,还需要通过WCF在远程服务器上保存数据。负责保存用户的成员方法如下:
public int SaveUser(User user) { if (!SaveToFile(user)) { return 1; } if (!SaveToDataBase(user)) { return 2; } return 0; } public bool SaveToFile(User user) { return true; } public bool SaveToDataBase(User user) { return true; }
如果单纯的看SaveUser方法,似乎一切都还不错,在约定好了错误代码后,调用者只要接收到1或2,就知道到底是那里出现了问题。但仔细研究会发现,如果方法执行失败,似乎还可以挖掘出更多的原因。
假设在SaveToFile方法中,我们可能会遇到:
1、程序无数据存储文件写权限导致的失败。
2、硬盘空间不足导致的失败。
在SaveToDataBase方法中,我们可能会遇到:
1、服务不存在导致的失败。
2、网络连接不正常导致的失败。
当我们想要告诉调用者更多的细节的时候,就需要与调用者约定更多的错误代码。于是我们很快就会发现,错误代码飞速膨胀,直到看起来似乎无法维护。因为我们总在查找并确认错误代码。
采用接下来的方法,可能会省略很大一部分的错误代码:
public bool SaveUser1(User user,ref string errorMessage) { if (!SaveToFile(user)) { errorMessage = "本地保存失败"; return false; } if (!SaveToDataBase(user)) { errorMessage = "远程保存失败"; return false; } return true; }
这看上去不错,即使存在更多的错误也可以将失败信息呈现给调用者或者上层用户。然后仅仅呈现失败信息就可以了吗?我们来看看这样一种情况:给失败通知增加稍微复杂一点的功能。
如果本地保存失败,要完成“通知运行本段代码的客户机管理员”的功能。通常情况下,仅仅只需要显示类似的信息:“本地保存失败,请检查用户权限”。如果远程保存失败,应用程序需要“发送一封邮件给远程服务器的系统管理员”。总金额个增加的功能导致我们不能像处理“本地保存失败”那样来处理“远程保存失败”。
一切仿佛又回到了起点,在没有异常处理机制之前,我们只能返回错误代码,但是现在有了另一种选择,即使用异常机制。如果使用异常机制,那么最终的代码看起来应该是下面这样的:
static void Main(string[] args) { try { SaveUser(new User()); } catch (IOException e) { ///IO异常,通知当前用户 } catch (UnauthorizedAccessException e) { ////权限异常,通知客户端管理员 } catch (CommunicationException e) { ///网络异常,通知发送给网络管理员 } } public static void SaveUser(User user) { SaveToFile(user); SaveToDataBase(user); }
使用CLR异常机制后,我们会发现代码变得更清晰、更易于理解了。至于效率问题,还可以重新审视“效率”的立足点:throw exception产生的那点效率损耗与等待网络连接异常相比,简直微不足道,而CLR异常机制带来的好处却是显而易见的。
这里需要稍加强调的是,在catch(CommunicationException)这个代码块中,代码所完成的功能是“通知发送”而不是“发送”本身,因为我们要确保在catch和finally中所执行的代码是可以倍执行的。换句话说,尽量不要在catch和finally中再让代码“出错”,那么让异常堆栈信息变得复杂和难以理解。
在本例的catch代码块中,不要真得编写发送邮件的代码,因为发送邮件这个行为可能会产生更多的异常,而“通知发送”这个行为稳定性更高(即不“出错”)。
以上通过实际的案例阐述了抛出异常相比于返回错误代码的优越性,以及在某些情况下错误代码将无用武之地,如构造函数、操作符重载及属性。语法特性决定了其不能具备任何返回值,于是异常机制倍当作取代错误代码的首要选择。
59、不要在不恰当的场合下引发异常
最常见不易引发异常的情况是对在可控范围内的输入和输出引发异常。如下面的代码所示:
public void SaveUser2(User user) { if (user.Age < 0) { throw new ArgumentOutOfRangeException("Age不能为负数"); } }
暂时可以发现此方法有两处不妥:
1、判断Age为负数。这是一个正常的业务逻辑,它不应该倍处理为一个异常。
2、应该采用Tester-Doer来验证输入。
我们现在来添加一个Tester方法
public static bool CheckAge(int age,ref string errorMessage) { if (age < 0) { errorMessage = "Age不能为负数"; return false; } else if (age > 100) { errorMessage = "Age不能大于100"; return false; } return true; }
而调用的地方看起来是这样的
string errorMessage = string.Empty; if (CheckAge(30, ref errorMessage)) { SaveUser(new User()); }
程序员,尤其是类库开发程序员,要掌握的两条首要原则是:
正常的业务流程不应使用异常来处理。
不要总是尝试去捕获异常或引发异常,而应该允许异常向调用堆栈往上传播。
那么到底应该在什么情况下引发异常呢?
第一种情况 如果运行代码后会造成内存泄漏、资源不可用,或者应用程序状态不可恢复,则引发异常。
第二种情况 在捕获异常的时候,如果需要包装一些更有用的信息, 则引发异常。
这类异常的引发在UI层特别有用。系统引发的异常所带的信息往往更倾向于技术性的描述;而在UI层,面对异常的很可能是最终的用户。如果需要将异常信息呈现给用户,更好的做法是先包装异常,然后引发一个包含友好信息的新异常。
第三种情况 如果底层异常在高层操作的上下文中没有意义,则可以考虑捕获这些底层异常,并引发新的有意义的异常。
例如下面的代码中:
public void CaseSample(object o) { if (o == null) { throw new ArgumentNullException("o"); } User user = null; try { user = (User)o; } catch (InvalidCastException) { throw new ArgumentException("输入参数不是一个User", "o"); } }
如果抛出InvalidCastException则没有任何意义,甚至会造成误解,所以更好的方式是抛出一个ArgumentException。
需要重点介绍的正确引发异常的典型例子就是捕获底层API错误代码,并抛出。查看如下代码:
public void Test() { int errorCode=Marshal.GetLastWin32Error(); if (errorCode == 6) { throw new InvalidOperationException("具体错误"); } }
很显然当需要调用WIndows API或第三方API提供的接口时,如果对方的异常报告机制使用的是错误代码,最好重新引发该接口提供的错误,因为你需要让自己的团队更好地理解这些错误。
建议60、重新引发异常时使用inner Exception
当捕获了某个异常,将其包装或重新引发异常的时候,如果其中包含了Inner Exception,则有助于程序员分析内部信息,方便调试。
可以先来查看以下代码
static void Main(string[] args) { try { Test(); } catch (Exception err) { Console.WriteLine(err.Message); if (err.InnerException != null) { Console.WriteLine(err.InnerException.Message); } } } public static void Test() { try { SaveUser(new User()); } catch (Exception err) { var ex = new Exception("网络链接失败,请稍后再试",err); //throw err; //这样抛出异常会丢掉异常原有的堆栈信息 throw ex; } }
如果不想使用Inner Exception,可以使用如下方式
static void Main(string[] args) { try { Test(); } catch (Exception err) { Console.WriteLine(err.Data["SockInfo"].ToString()); } } public static void Test() { try { SaveUser(new User()); } catch (Exception err) { err.Data.Add("SockInfo", "网络链接失败,请稍后再试"); throw err; } }
相当于把Test方法中的异常当作Inner Exception,然后向上抛出。
意思其实也就是将异常进行简单的封装,然后继续向上抛出,让上层来捕获异常信息。