《编写高质量C#的建议》学习笔记 第五部分 异常与自定义异常
异常机制关注最多的就是效率问题。但正常控制流程下的代码运行并不会出现问题,只有引发异常时才会带来效率问题。
开发共识
1 |
|
CLR异常机制至少有以下几个优点:
- 正常控制流会被立即中止,无效值或状态不会在系统中继续传播。
- 提供了统一处理错误的方法。
- 提供了在构造函数、操作符重载及属性中报告异常的便利机制。
- 提供了异常堆栈,便于开发者定位异常发生的位置 。
1、用抛出异常代替返回错误代码
try
{
SaveUser(user);
}
catch (IOException)
{
// IO 异常,通知当前用户
}
catch (UnauthorizedAccessException)
{
// 权限失败,通知客户端管理员
}
catch (CommunicationException)
{
// 网络异常,通知发送E-mail给网络管理员
}
注意
尽量不要在catch和finally中再让代码“出错”,如本例中,不要真的编写发送邮件的代码,因为发送邮件这个行为可能会产生更多的异常,而“通知发送”这个行为稳定性更高(即不“出错”)。
2、不要在不恰当的场合下引发异常
例如业务中,判断Age是否是负数,是正常的业务逻辑,不应该被处理为一个异常,应该采用Tester-Doer来验证输入。
原则是:
- 正常的业务流程不应使用异常来处理。
- 不要总是尝试去捕获异常或引发异常,而应该允许异常向调用堆栈往上传播。
第一类情况
如果运行代码后会造成内存泄露、资源不可用,或者应用程序装填不可恢复,则引发异常。(对在可控范围内的输入和输出不引发异常。所谓“可控”,可定义为:发生异常后,系统资源仍可用,或资源状态可恢复)
第二类情况
在捕获异常的时候,如果需要包装一些更有用的信息,则引发异常。
第三类情况
如果底层异常在高层操作的上下文中没有意义,则可以考虑捕获这些底层异常,并引发新的有意义的异常。
当需要调用API或者第三方接口时,如果对方的异常报告机制使用的是错误代码,最好重新引发该接口提供的错误,因为需要让自己的团队更高的理解这些错误。
3、重新引发异常时使用Inner Exception
当捕获了某个异常,将其包装或重新引发异常的时候,如果其中包含了Inner Exception,则有助于分析内部信息,方便代码调试。
try
{
SaveUser(user);
}
catch (SocketException err)
{
// 将异常重新包装成为一个CommucationFailureException,并将SocketException作为Inner Exception(即err)向上传递
throw new CommucationFailureException("网络连接失败,请稍后再试", err);
}
还有一个可以采用的技巧,如果不打算使用Inner Exception,但是仍然想要返回一些额外信息,可以使用Exception的Data属性:
try
{
SaveUser(user);
}
catch (SocketException err)
{
err.Data.Add("SocketInfo", "网络连接失败,请稍后再试");
throw err;
}
在上层捕获的时候,可以通过键值来得到异常信息:
catch (SocketException err)
{
Console.WriteLine(err.Data["SocketInfo"].ToString());
}
4、避免在finally内撰写无效代码
除非发生让应用程序中断的异常,否则finally总是会先于return执行。
所以,一般使用finally避免资源泄露。
因为finally的特性决定了资源释放的最佳位置就是在finally块中;另外,资源释放会随着调用堆栈由下往上执行。
5、避免嵌套异常
过多使用catch会带来两个问题:
- 代码更多了。看上去好像我们根本不知道该怎么处理异常,所以总在不停地catch。
- 隐藏了堆栈信息,使我们不知道真正发生异常的地方。
如果真的需要补货这个异常来恢复一些状态,然后重新抛出,代码应如下:
try
{
(new NestedExceptionSample2().MethodWithTry2());
}
catch (Exception)
{
// 工作代码
throw;
}
或者
try
{
(new NestedExceptionSample2()).MethodWithTry2();
}
catch
{
// 工作代码
throw;
}
尽量避免如下引发异常:
catch (Exception err)
{
// 工作代码
throw err;
}
直接throw err 而不是throw将会重置堆栈信息。
6、避免“吃掉异常”
避免“吃掉”异常,并不是说不应该“吃掉”异常,而是这里面有个重要原则:该异常可被预见,并且通常情况它不能算是个bug。
7、为循环增加Tester-Doer模式而不是将try-catch置于循环内
应该尽量在循环当中对异常发生的一些条件进行判断,然后根据条件进行处理。
8、总是处理未捕获的异常
9、正确捕获多线程中的异常
Thread t = new Thread((ThreadStart)delegate
{
try
{
throw new Exception("多线程异常");
}
catch (Exception error)
{
MessageBox.Show("工作线程异常:" + error.Message + Environment.NewLine + error.StackTrace);
}
});
t.Start();
新起的线程中异常的捕获,可以将线程内部代码全部try起来。原则上说,每个线程的业务异常应该在自己的内部处理完毕。
不过仍需一个办法,将线程的内部的异常传递到主线程上。
Windows窗体程序中,用BeginInvoke方法。
Thread t = new Thread((ThreadStart)delegate
{
try
{
throw new Exception("非窗体线程异常");
}
catch (Exception ex)
{
this.BeginInvoke((Action)delegate
{
throw ex;
});
}
});
t.Start();
除了这种方式,更建议使用事件回调的方式将工作线程的异常包装到主线程。
用事件回调的方式处理异常的好处是提供了统一的入口进行异常的处理。
10、慎用自定义异常
除非有充分的理由,否则一般不创建自定义异常。如果要对某类程序出错信息做特殊处理,就自定义异常:
- 方便调试。通过抛出一个自定义的异常类型实例,我们可以使捕获代码精确地知道所发生的事情,并以合适的方式进行恢复。
- 逻辑包装。自定义异常可包装多个其他异常,然后抛出一个业务异常。
- 方便调用者编码。在编写自己的类库或者业务层代码的时候,自定义异常可以让调用方更方便处理业务异常逻辑。例如,保存数据失败可以分为两个异常“数据库连接失败”和“网络异常”。
- 引入新异常类。能够根据异常类在代码中采取不同的操作。
11、避免在调用栈较低的位置记录异常
并非所有的异常都要被记录到日志,一类是异常发生的场景需要被记录,另一类就是未被捕获的异常(通常视为bug)。
最适合进行异常记录和报告的是应用程序的最上层,通常是UI层。