导航
第十四章 Errors and Exceptions
14.1 简介
大部分的错误(Error)通常不是写代码的人引发的。有的时候仅仅只是因为一些用户的误操作,又或者是程序的环境上下文运行出错,程序会抛出一个错误。在任何情况下,你都需要提前预估那些会发生在你的程序代码里的错误,做好后续处理。
.NET加强了你处理错误的能力。C#的错误处理机制使你可以提供对任何类型错误的自定义处理,这样代码就被分成两部分,一部分用来识别错误,一部分用来处理错误。
不管你的代码水平有多高,你的程序也必须能够处理任何可能发生的错误。举个例子,在处理一些特别复杂的代码的时候,你可能会发现你没有读取本地文件的权限;又或者,当你的程序发送一个网络请求时,网络瘫痪了。在这些异常情况下,程序仅仅返回一个错误代码是不够的 —— 一段复杂代码可能会有十几二十个嵌套方法调用。事实上你更愿意你的代码能跳过这些出错的内容,完成整个任务,即使执行得不那么完美。C#提供了一种非常好的异常处理机制,可以帮你解决这个情况。
本章涵盖了在不同场景下如何捕获与抛出异常的各种内容。你将看到不同命名空间下的各种异常类型,以及他们的层次结构。然后你会明白如何创建一个自定义的异常类。你将学会使用不同的方式来捕获异常——例如,如何获取基础的异常类型或者它的某个特例。你将会看到如何使用嵌套的try代码块,以及这些代码又是如何捕获异常的。而对于那些不管有没有发生异常都需要执行的代码,我们将会为你介绍try/finally代码块的使用。
通过本章你将会更好的掌握各种异常处理的高级技巧。
14.2 异常类
在C#里,当某块代码出现异常的时候,其实是创建(或者说抛出了)了一个异常对象。这个对象包含了各种相关信息以便你能识别错误出现的地方。虽然你也可以创建你自己的异常处理类(后面会介绍),.NET提供了许多预先定义好的异常类——不是一般的多,涵盖了各种可能的错误。下面的类层级图仅仅只是展示了其中的冰山一角,好让你对系统异常的组织模式(gerneral pattern)有个基础的认识。本小节会让你快速的了解.NET基础类库提供的一小部分异常。
上图中的所有类,基本上都是System命名空间下的一员,只有IOException、CompositionException以及这俩的子类不是。IOException的命名空间是System.IO。这个命名空间主要处理的是文件的读写。而CompositionException和它的子类则是System.ComponentModel.Composition的一部分,这个命名空间主要处理动态加载部分和组件管理。一般来说,异常没有别的命名空间了。异常类需要放在任何可以处理它们的类的命名空间下——因此,I/O相关的异常就在System.IO命名空间下。在基础类命名空间下你可以找到相当多的异常类。
通用的异常处理类,System.Exception,继承自System.Object,正如你所想,是一个.NET类。一般来说,你不能直接在你的代码里抛出通用的System.Exception对象,因为它们无法定位具体的错误信息。
在上面的层次结构中,有两个非常重要的类,继承自System.Exception:
- SystemException——这个类通常用来抛出.NET运行时或者那些可能被任何应用程序抛出的通用异常。举个例子,假如栈满了,.NET运行时就会抛出一个StackOverflowException。又或者,你可能会选择抛出一个ArgumentException或者它的子类型当你发现你的方法接收了一个错误参数的时候。SystemException的子类包含了各种致命的或者普通的异常情况。
- ApplicationException——.NET最初设计的时候,这个类用来代表那些用户自定义的程序错误的基类。然而,有些异常类是CLR抛出的(如TargetInvocationException),而某些程序运行过程中可能出现的异常又是继承于SystemException的(如ArgumentException)。因此,用户自定义的异常类继承自ApplicationException不再是一个很好的设计,一点用都没有。取而代之的是,用户自定义异常类可以直接继承自基类Exception。而且很多系统预定义的异常类也是直接继承自Exception的。
上图中另外的一些异常类也非常有用:
- StackOverflowException——当栈的可分配空间已经满了,试图再次申请栈空间时就会抛出这个异常。通常当一个方法递归调用,进入无限死循环的时候很容易发生这个错误。这是一个致命的错误,除了强制结束程序之外,无法再做任何其他的处理(这种情况下就算你声明了finally块也不太可能执行)。试图自己处理这种错误是毫无意义的,你更需要考虑的是,如何在这种情况下让你的程序可以优雅地退出。
- EndOfStreamException——这种错误通常发生在你试图读取一个超出文件尾的内容时。我们将在第23章介绍。
- OverflowException——最简单的例子就是你在checked关键字声明的代码段中,试图将一个值进行类型转换时,溢出了。譬如你试图将一个值为-40的int类型转换成一个uint类型。
上图中还有一些异常类我们不在这里做进一步的讨论,它们仅仅只是为了拿来演示异常类的层次结构。
异常类虽然有着自己的层次结构,然而大部分的子类,并不会为它们各自的基类添加任何新的功能,这在类的继承里不常见。然而,在异常类的处理中,添加一个继承子类,通常是为了描述更具体的特定错误情况。它没有覆写父类方法或者添加一些新方法的必要(但它们常常会添加一些额外的Property以便能更详细的描述错误情况)。举个例子,你可能会在方法的调用过程中使用ArgumentException来处理参数传递的异常,而一个更具体的异常ArgumentNullException可以派生于它,专门用来处理传递的参数中带有null值的情况。
14.3 捕获异常
上面我们介绍了.NET内置了很多预定义的基础异常类型,本节主要讲述的是,你如何在代码里捕获(trap)不同的错误情况。为了处理C#代码里可能会出现的异常,你往往会将你的代码分为三个不同的代码块:
- try代码块封装(encapsulate)了正常的业务代码,这部分代码可能会引发某些严重的错误。
- catch代码块封装了处理不同异常类型的代码,一旦try代码块在运行过程中发生某种异常情况,就会进入到相应的catch块里进行处理。通常你也可以在这里记录一些错误日志。
- finally块则通常是一些处理资源释放相关的操作,或者任何你想要保证在try块和catch块后必须要要执行的代码。需要注意的是,不管有没有异常发生,finally块的内容一定会执行(除非前面整个程序就崩了)。因为finally块最早是设计来释放资源的并且一定会执行的,如果你试图在finally块里面添加一个return语句来中断某些操作的话,编译器会抛出一个异常。finally块是可选的,当你的代码不需要释放任何资源时(譬如不需要对象销毁或者关闭某些已经打开的对象),则完全没有必要书写这个代码块。
下面的步骤概述了这三个代码块是如何协同工作的:
- 首先代码进入try块里执行。
- 如果try里面没有发生任何错误,代码会普通的执行,直到try块的结尾。然后代码会跳到finally块(如果有的话);如果try块里发生任何错误了,就直接跳到catch块执行。
- catch块负责处理预定义的错误。
- catch块处理完成后,则跳到finally块(如果有的话)。
- finally块(如果有的话)执行。
这三部分的C#语法是这样子的:
try
{
// code for normal execution
}
catch
{
// error handling
}
finally
{
// clean up
}
这个方式可以有些变化:
- 你可以省略finally块因为它是可选的。
- 你可以提供很多个catch块如果需要按照不同的异常类型进行不同的处理的话,然而你要注意不要因为创建了海量的catch块而使代码失去控制。
- 你可以为catch块创建过滤器(filter),只有出现的异常符合filter要求时才会进入相应的处理。
- 你也可以省略catch块,在这种情况下,语法服务器不会去识别究竟发生了什么错误,仅仅只是保证执行权跳出try块进入到finally块时,finally里的代码一定会被执行。这点在try代码块中含有若干程序退出点时会很有用。
到目前为止还好,但仍然有一个问题没有被解答:当代码在try语句块中正常执行的时候,它是如何知道出错了,得切换到catch块里进行处理的?假如程序检测到一个错误,代码内部就会执行一些抛出异常的操作。换句话说,它会实例化一个异常类的对象,并且像这样抛出它:
throw new OverflowException();
这里,我们实例化了一个OverflowException的实例。只要程序在try语句块中遇到throw语句,它会立马检查与该try块关联的catch块。如果有多个catch块,它通过判断catch块关联的异常类,来决定应该进入哪个catch块进行异常处理。例如,当OverflowException实例被抛出的时候,执行将会跳到一个像这样的catch块中:
catch (OverflowException ex)
{
// exception handling here
}
换句话说,程序会优先查找那些跟抛出的异常实例完全一致(或者与它的父类一致)的catch块。
基于这一点,你可以扩展上面示例的try语法块。假设由于参数的原因,try块里可能会产生两个严重错误:一个数据溢出(overflow)或者一个数组越界(array out of bounds)。我们假定你代码里有两个布尔变量,一个叫Overflow,另外一个叫OutOfBounds,分别用来定义是否存在错误。前面你已经见过了预定义的OverflowException,同样的,C#里也定义了数组越界的异常IndexOutOfRangeException。
扩展后的try代码块可能是这样子的:
try
{
// code for normal execution
if (Overflow == true)
{
throw new OverflowException();
}
// more processing
if (OutOfBounds == true)
{
throw new IndexOutOfRangeException();
}
// otherwise continue normal execution
}
catch (OverflowException ex)
{
// error handling for the overflow error condition
}
catch (IndexOutOfRangeException ex)
{
// error handling for the index out of range error condition
}
finally
{
// clean up
}
C#允许你在代码里主动使用throw语句抛出一些异常,就算你不写,当代码执行到同样的错误的时候,运行时也会帮你自动生成一样的异常。而如果没有错的话,try语句块就正常执行其他的方法调用。当应用程序遇到throw语句的时候,它会马上终止当前的方法调用,转到try语句块的结尾,开始查找是否有相应的catch语句块处理。在这个过程中,所有的方法调用里的内部变量都会因为超出作用域而无效。这种try...catch处理方式非常适合本章开头说的那种程序死循环的情况,当任何一个方法调用出错时,try语句块中的所有方法调用都会被立刻停止,不管里面写了15个还是20个方法。
你可能会从这个案例中总结出更多有意义的try语句块应用场景。但是,你要明白异常处理机制最主要的目的就是用来处理异常情况的,就如它们名字定义的一样。你千万不要试图用它们来控制代码的执行过程,譬如用异常控制何时退出一个do...while循环。
14.3.1 异常和性能
异常类的处理上会影响到性能。如果可以的话,你不要频繁地使用异常类来处理异常。举个例子,当你将一个string对象转换成number类型的时候,你可以使用int类型的Parse方法。当字符串无法转换成number的时候,Parse方法会抛出一个FormatException,而如果它转换的值超出了int类型定义的数值范围的话,则会抛出一个OverflowException,你的代码可能是这样子:
try
{
int i = int.Parse(n);
Console.WriteLine($"converted: {i}");
}
catch (FormatException ex)
{
Console.WriteLine(ex.Message);
}
catch (OverflowException ex)
{
Console.WriteLine(ex.Message);
}
如果你只是普通的接收一个字符串值n然后正常地将它转换成int类型的数值,期间没有出现任何异常的话,你可以这么写。但是,假如你多次调用这种转换方式,而有不少情况会出现各种转换异常的话,这种写法就会有很大的性能损失。更建议的方式是使用TryParse方法,当字符串无法正常转换成数值时,这个方法不会抛出任何异常,当能转换时,该方法会返回true,不能则返回false,你可以像下面这样写:
static void NumberDemo2(string n)
{
if (n is null) throw new ArgumentNullException(nameof(n));
if (int.TryParse(n, out int result))
{
Console.WriteLine($"converted {result}");
}
else
{
Console.WriteLine("not a number");
}
}
14.3.2 实现多个catch 块
想看try...catch..finally语句块实际是如何执行的,最简单的方式就是写几个例子。我们的第一个例子叫SimpleExceptions。它重复地获取用户输入的数字,然后在控制台上显示。为了代码的演示效果,我们假定只有0-5之间的数字是有效的,其他的数值这个程序都处理不了;因此当你输入一个超出范围的数值的时候,程序就会抛出一个异常。只有在接收到一个空的Enter之后,我们的程序才会结束运行。
注意:本例子只是为了演示异常是如何被捕捉和处理的,并不是一个好的异常使用示例,千万不要在业务环境里这么写。就像"异常"这个名字的含义一样,异常是用来处理那些意料之外的情况的,而非应付可预期的代码逻辑。用户经常会输入一些奇怪的内容,而且会有多奇怪多蠢完全无法预计。正常情况下,你的程序会验证一些无效的输入,提示用户检查或者重新输入正确的内容。而想通过一个小代码示例,让你在短短几分钟内理解异常情况具体是怎么处理的,并不容易。所以我才会设计了这么一个不太理想的示例来简单地演示异常是如何工作的。后续的示例会更加的贴合实际一些。这个例子的代码如下所示:
public class Program
{
public static void Main()
{
while (true)
{
try
{
string userInput;
Console.Write("Input a number between 0 and 5 " + "(or just hit return to exit)> ");
userInput = Console.ReadLine();
if (string.IsNullOrEmpty(userInput))
{
break;
}
int index = Convert.ToInt32(userInput);
if (index < 0 || index > 5)
{
throw new IndexOutOfRangeException($"You typed in {userInput}");
}
Console.WriteLine($"Your number was {index}");
}
catch (IndexOutOfRangeException ex)
{
Console.WriteLine("Exception: " + $"Number should be between 0 and 5. {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"An exception was thrown. Message was: " + $"{ex.Message}");
}
finally
{
Console.WriteLine("Thank you
");
}
}
}
}
这段代码的核心是一个while循环,它使用ReadLine方法持续读取控制台的用户输入。ReadLine方法返回一个string类型的值,代码的第一步就是使用System.Convert.Int32方法将它转换成一个int类型。System.Convert类提供了很多有用的数据转换方法,是int.Parse以外的可选方案。通常来讲,System.Convert类可以处理多种数据类型的变换。调用它可以让编译器生成一个基于System.Int32的int实例。
在上面的例子里,我们也检查了是否输入了一个空的字符串,因为我们把它当做退出while循环的必要条件。注意break语句实际上跳出的是while循环,因为try代码块只是一个异常处理的封装,而while循环才是有效的代码块。当然,在break语句跳出while循环前,因为它包含在try语句块内,因此关联的finally语句块也会被执行。虽然这里你可能只是显示了一句谢谢,然而更常见的方式是,你会在这个语句块里做一些譬如,关闭文件句柄,调用不同对象的Dispose方法,释放占用的资源之类的操作。当程序离开finally代码块时,程序会接着执行其他部分的代码。在上面的例子里,你会重新迭代回while循坏开头的位置,从try代码块接着执行(除非finally块是紧跟着break语句之后执行的,那样则是最后一次执行finally块了,执行完就直接退出了while循环)。
接下来,你检查了你的异常条件:
if (index < 0 || index > 5)
{
throw new IndexOutOfRangeException($"You typed in {userInput}");
}
当你需要抛出一个异常的时候,你需要指定抛出的是什么类型。虽然你也可以笼统地使用System.Exception,但它仅仅是个基类。如果你只是抛出一个基础类型的异常,其实是一种不好的程序实践,因为它无法传达错误信息产生的本质。与之相对的是,.NET包含很多派生于它的具体Exception类,每一个派生类都匹配一种特定的异常情况,而且.NET也允许你定义属于你自己的异常类。这种设计的目的是希望提供尽可能详尽的错误信息,以便快速定位错误来源。在上面的这个例子里,比起System.Exception,System.IndexOutOfRangeException是一个更好的选择。IndexOutOfRangeException拥有不同的构造函数,例子中使用的是带错误描述字符串的这种。另外,你也可以选择继承Exception,实现你自定义的异常类,以便提供你程序所需的上下文错误信息。
假如用户输入了一个0-5之外的数字,并且进入了上面的if块内,一个IndexOutOfRangeException对象实例就会被创建,并且抛出。此时,应用程序会马上结束try块的执行,并且catch块会捕获到这个IndexOutOfRangeException异常进行处理。第一个catch块会被调用:
catch (IndexOutOfRangeException ex)
{
Console.WriteLine("Exception: " + $"Number should be between 0 and 5. {ex.Message}");
}
因为这个catch块里明确指定了异常类型的参数,因此在接收到同样类型的异常时,会优先进入这个catch块进行处理。这里面在控制台上显示了一个详细的异常信息ex.Message,取决于IndexOutOfRangeException的构造函数是怎么创建的。catch块执行完毕后,程序控制权切换给了finally块,就像没有任何异常发生过一样。
你可以注意到我们还提供了另外一个catch块:
catch (Exception ex)
{
Console.WriteLine($"An exception was thrown. Message was: " + $"{ex.Message}");
}
这个catch块当然也可以处理IndexOutOfRangeException,假如异常没有被前一个catch块捕获的话。一个基类的引用可以适配所有继承于它的子类异常,而我们所有的异常都派生于Exception,所以Exception可以捕获所有的异常。因为前面那个catch块的已经匹配上了,所以第二个catch块没有被执行。程序只会按顺序执行catch块,找到第一个适配的catch块,进入执行,后续的catch块则不会再次进行匹配。
假如用户输入了非数值类型——譬如输入了字母a或者单词hello——那么Convert.ToInt32方法则会抛出一个System.FormatException异常,来指明要转换的字符串不是一个有效的int类型的数字。当这个异常产生的时候,应用程序会回溯方法调用栈,查找是否有一段处理程序可以用来处理这个异常。很显然第一个catch块匹配的是IndexOutOfRangeException,跟这个不同,所以它不会被执行。而第二个catch块定义的是Exception类型,FormatException是Exception的子类,所以一个FormatException的实例可以被当做Exception参数传递进这个catch块。
上面的例子其实是一个相当典型的多catch块应用。开始的时候,你写上了各种catch块并且试图捕获不同类型的错误。最后,你写上了通用的catch块以便捕获那些你没有指定的错误类型。事实上,catch块的书写顺序是很重要的。如果你调整了上面例子的两个catch块的顺序,代码将会提示一个编译错误:"上一个 catch 子句已经捕获了此类型或超类型(Exception)的所有异常",所以第二个catch块永远无法生效。因此,最上方的catch块必须是最小粒度的特定异常,而最后的catch块则可以匹配更多的通用异常。
现在你已经明白了整段代码的内容,你可以尝试运行它。输入不同的内容,然后看看程序是如何显示的IndexOutOfRangeException和FormatException,你可能会看到这样的结果:
Input a number between 0 and 5 (or just hit return to exit)>4
Your number was 4
Thank you
Input a number between 0 and 5 (or just hit return to exit)>0
Your number was 0
Thank you
Input a number between 0 and 5 (or just hit return to exit)>10
Exception: Number should be between 0 and 5. You typed in 10
Thank you
Input a number between 0 and 5 (or just hit return to exit)>hello
An exception was thrown. Message was: Input string was not in a correct format.
Thank you
Input a number between 0 and 5 (or just hit return to exit)>
Thank you
14.3.3 在其他代码中捕获异常
前面的例子演示了如何一次处理2个异常,这两个异常里面,IndexOutOfRangeException是你自己代码抛出的,而另外一个FormatException,则是基础类库里面抛出的。类库抛出异常是一种很普遍的情况,当它发现错误的时候,或者当错误地传递了某个参数进行方法调用时。然而,类库基本不会捕获异常,这部分处理交由开发者负责。
你在调试的时候常常会得到一些基础类库的异常。调试进程某些程度上会涉及异常是怎么发生或者如何解决的情况。你的任务就是,确认代码是否被封装好,异常只在预料到的场景发生,如果可以的话,采用更合适的手段处理它。
14.3.4 System.Exception 属性
上面的例子仅仅演示了关于Message属性的使用,事实上,System.Exception里还包括了很多其他的属性,就像下表所示:
属性 | 描述 |
---|---|
Data | 允许你用添加key/value的语句为Exception添加额外的信息。 |
HelpLink | 一个帮助文件的超链接可以查阅Exception的更多信息。 |
InnerException | 如果在catch代码段里又引发了一个异常,InnerException包含最早导致代码进入catch块的异常信息。 |
Message | 描述错误信息的文本。 |
Source | 引发Exception的应用程序或者对象名称。 |
StackTrace | 提供方法调用堆栈,以便开发人员追溯Exception是如何逐级发生的。 |
HResult | 赋值给Exception的一个数值。 |
TargetSite | 一个.NET反射类型,用来描述抛出异常的Method。 |
StackTrace的值是.NET运行时自动生成的,如果有的话。.NET运行时则经常将引发异常的程序集的名称赋值给Source属性(虽然你可能试图修改这些属性以便它能给出更多更详细的信息),其他诸如Data,Message,HelpLink以及InnerException等属性在抛出异常时也是必须赋值的。一个抛出异常时的代码可能是这样子的:
if (ErrorCondition == true)
{
var myException = new ClassMyException("Help!!!!");
myException.Source = "My Application Name";
myException.HelpLink = "MyHelpFile.txt";
myException.Data["ErrorDate"] = DateTime.Now;
myException.Data.Add("AdditionalInfo", "Contact Bill from the Blue Team");
throw myException;
}
这里,ClassMyException是你抛出的自定义异常。注意通常异常名称都以Exception结尾。另外你可以注意到Data属性可以有两种赋值方式。
14.3.5 异常过滤器
从第6版开始,C#就允许异常过滤器(filters)了。一个catch代码块只有当过滤器返回true时才会执行。你可以书写不同的catch代码块,当捕获到不同的异常类型时进行不同的处理。在某些应用场景中,根据一个异常执行不同的catch块会是一项很有用的特性。举个例子,当使用Windows运行时的时候,你经常会从不同类型的Exception中获取COM类型的异常,或者当你在请求网络资源时遇到各种各样的异常——服务器不可用啦,数据不匹配啦。对不同类型的异常最好是能有不同的响应(react)方式。有些异常可以用各种方式进行还原,而有些则需要更多的用户信息。
下面是一个代码例子,抛出一个自定义的用户异常,并且设置了相应的错误代码:
public static void ThrowWithErrorCode(int code)
{
throw new MyCustomException("Error in Foo") { ErrorCode = code };
}
让我们再看一下主程序里的代码:
try
{
ThrowWithErrorCode(405);
}
catch (MyCustomException ex) when (ex.ErrorCode == 405)
{
Console.WriteLine($"Exception caught with filter {ex.Message} " + $"and {ex.ErrorCode}");
}
catch (MyCustomException ex)
{
Console.WriteLine($"Exception caught {ex.Message} and {ex.ErrorCode}");
}
在这个示例中,try代码块通过两个catch块来保障方法调用。第一个catch块使用了when关键字,来过滤异常信息,只有当ErrorCode的值为405时它才生效。when语句必须返回一个布尔值true或者false。如果是true,那么则进入这个catch块进行处理,如果是false,编译器会继续寻找下一个可能适配的catch块。通过调用ThrowWithErrorCode方法,给它传一个405参数,将ErrorCode主动设置成405,过滤器就会返回一个true值,这样子第一个catch块就会接收到异常并进行处理。而如果我们传递了其他错误代码,过滤器返回的肯定是false,那么第二个catch块就会负责处理我们的异常。通过过滤器,你可以为同一个异常类型设定不同的处理器。
当然,你也可以不写第二个catch块,不为错误码不等于405的情况做任何处理。