应用程序执行时,可能遇到各种可能的错误。C#使用异常来处理这些错误,异常将有关错误的信息封装在一个类中。异常设计用于报告故障,它们提供了报告和响应错误的一致方式。设计异常的初衷并非要提供一种程序流程控制机制、报告成功状态或用作反馈机制,相反,它们报告程序执行期间发生的故障。
在很多语言(尤其是非面向对象语言)中,报告故障的标准机制是使用返回编码(return code)。然而,在面向对象语言中,并非总可以使用返回编码,而取决于故障发生的背景。另外,返回编码很容易被忽略;如果不想忽略它,就需要在有可能出现故障的地方做妥善处理,这非常复杂。
一、理解异常
异常能够以清晰、简洁、安全的方式表示运行期间发生的故障,它通常包含有关故障原因的详细信息,其中包括调用栈跟踪,它指出了当前代码块正常返回时的执行路径。
异常并非要提供一种处理预期错误(如用户操作或输入可能导致的错误)的方式。对于这些错误,通过核实操作或输入正确来防范错误要好得多。异常也并非要防范编码错误。编码错误可能导致异常,但对于这种错误,应进行修复,而不是依赖于异常。
发生异常时,如果应用程序显式地提供了此时应执行的代码,异常将得到处理;如果没有这样的代码,就将出现未处理的异常。
所有异常都是从 System.Exception 派生而来的。当托管代码调用非托管代码或外部服务(如 Microsoft SQL Server)并发生错误时,.NET 运行时将把错误条件包装在一个从System.Exception派生而来的异常中。
ps:RuntimeWrappedException
在诸如 C++等语言中,可引发任何类型的异常,而不仅仅是从System.Exception 派生而来的异常。在这种情况下,通用语言运行时将把这些异常包装在一个 RuntimeWrappedException 中。这确保了语言之间的兼容性。
System.Exception有多个属性提供了有关错误的详细信息:
- 最常用的是Message属性,它通过用户能够明白的描述详细地指出了导致异常的原因。通常,这个属性的内容是几个句子,对错误进行了大致描述。
- StackTrace属性包含调用栈跟踪,有助于判断错误发生在什么地方。如果调试信息在运行期间可用,调用栈跟踪将指出错误发生在哪一行及其所属的源文件。
- 当一个异常被包装在另一种异常内时,通常使用属性InnerException。原始异常存储在属性InnerException中,让错误处理代码能够检查原始信息。
- HelpLink属性可包含指向帮助文件的 URL,而帮助文件包含有关异常的更详细信息。
- Data属性是一个 IDictionary(字典的非泛型版本)对象,用于以键/值对的方式存储任意信息。
使用标准异常
虽然.NET Framework提供了 200多个公有异常类,但只有大约 15个是常用的,其他异常通常是从这些标准异常派生而来的。
除Exception外,其他两个主要的基类是SystemException和ExternalException,其中前者是运行时生成的所有异常的基类,后者是在运行时的外部环境中发生或针对这类环境的异常的基类。
对于Exception、SystemException和ExternalException,应只将它们用作更具体的派生异常的基类,还应避免从SystemException直接派生出自定义异常。
其他标准异常如下表所示,它们分两类:运行时引发的异常,您在代码中不应引发它们;可在代码中(也应该在代码中)引发的异常。
异常类型 | 描述 |
---|---|
IndexOutOfRangeException | 仅当使用错误的索引访问数组或集合时,才有由Runtime引发 |
NullReferenceException | 仅当对null引用接触引用时,才由Runtime引发 |
AccessViolationException | 仅当访问雾效内存时,才由Runtime引发 |
InvalidOperationException | 在无效状态下由成员引发 |
ArgumentException | 所有参数异常的基类 |
ArgumentNullException | 由不允许参数为null的方法引发 |
ArgumentOutOfRangeException | 由验证参数是否位于给定范围内的方法引发 |
COMException | 封装COM HRESULT信息的异常 |
SEHException | 封装Win32 结构化异常处理信息的异常 |
OutOfMemoryException | 在没有足够的内存供程序继续执行时,由Runtime引发 |
StackOverflowException | 因嵌套的方法调用过多(这通常是由于递归太深或无限递归导致的),导致执行栈溢出时,由Runtime引发 |
ExecutionEngineException | 在CLR的执行引擎内部错误时,由Runtime引发 |
验证传递给您的公有方法的参数时,如果传递的参数不正确,就应引发ArgumentException或其子类。
如果传递给方法的参数为null,就应引发ArgumentNullException;如果传递的参数不在可接受的范围内,就应引发ArgumentOutOfRangeException。
如果就对象的当前状态而言,访问属性或调用方法不合适,就可引发InvalidOperationException。这不同于ArgumentException,是否引发ArgumentException不依赖于对象的状态。
ps:验证参数
如果调用方不好判断参数是否有效,您应考虑提供一种方法,让它们能够对参数进行检查。
其他标准异常都是运行时专用的异常,您不应在代码中引发它们,也不应从它们派生出自定义异常。
应对参数进行检查,以防止发生IndexOutOfRangeException或NullReferenceException。
二、引发异常
要引发异常,可使用关键字throw。由于Exception是一个类,因此您必须使用关键字new创建其实例。如下代码引发一个Exception异常:
throw new System.Exception();
异常引发后,程序将立即停止执行,而异常将沿调用栈向上传递,并寻找合适的处理程序。如果没有找到处理程序,就将发生下述3种情况之一。
- 如果异常发生在构造函数内,就将终止执行该构造函数,并调用基类的析构函数(如果有)。
- 如果调用栈中包含静态构造函数或静态字段初始值设定项,就将引发TypeInitializationException,其InnerException属性包含原始异常。
- 如果到达了线程开头,线程就将终止。在大多数情况下,这意味着如果异常到达Main( )方法后,仍未找到兼容的处理器,应用程序就将终止。无论异常源自哪个线程,都将导致这样的结果。
要确定什么情况下应引发异常,需要明白编码错误和执行错误之间的差别。编码错误可通过修改代码来避免,因此没有理由使用异常来处理这类错误。编码错误可在编译阶段修复,因此可采取措施确保它们不会在运行阶段发生。
ps:System.Environment.FailFast
如果应用程序处于不能安全地继续执行下去的境地,应考虑调用System.Environment.FailFast,而不应引发异常。
如果继续执行会导致安全风险,如无法恢复的安全损害,那么也应考虑调用FailFast。
遇到意外的错误条件时,将引发异常。这通常发生在类成员无法执行其操作时。这种执行错误不能完全避免,不管在代码中采取多少防范措施。对于程序执行错误,可通过代码以编程方式进行处理,但系统执行错误无法通过代码进行处理。
三、处理异常
要处理异常,可使用异常对象和保护区域(protected regions)。可将保护区域视为特殊的代码块,设计用于让你能够处理一样。几乎任何代码行都可能导致异常,但大多数应用程序实际上不需要处理这些异常。仅当能够采取有意义的措施时,才应对异常进行处理。
在C#中,可使用关键字try声明保护区域(也叫try块),并将要保护的语句用大括号括起,而相关的处理程序放在右大括号的后面。必须至少将下述处理程序之一与保护区域相关联。
- finally处理程序:它在退出保护区域时执行,即使发生异常也是如此。保护区域最多可以有一个finally处理程序。
- catch处理程序:它与特定异常或其子类匹配。保护区域可以有多个 catch处理程序,但特定类型的异常只能有一个catch处理程序。
发生异常时,将首先确定当前指令(导致异常的指令)所属的保护区域是否有与异常匹配的 catch 处理程序。如果当前方法没有匹配的处理程序,就将在调用方处查找,这个过程将不断重复下去,直到找到匹配的处理程序或到达调用栈顶,到达调用栈顶后,应用程序将终止。如果找到匹配的处理程序,就将返回到发生异常的地方,执行finally处理程序,然后执行catch处理程序。
ps:通用catch处理程序
可仅使用关键字catch来指定catch处理程序,这被称为通用catch处理程序,但不应这样做。
在.NET Framework 1.0和 1.1中,存在这样的情况,即非托管代码可能引发运行时未能妥善处理的异常。
因此,这种异常没有包装到 System.Exception 派生异常中,除空 catch块外的其他catch块都无法捕获它。
.NET 2.0修复了这种问题,现在这些异常被包装到RuntimeWrappedException (它是从System.Exception派生而来的)中,因此不再需要空catch块。
根据保护区域提供了哪些处理程序,可将其分为3类。
- Try-Catch:只提供一个或多个 catch处理程序。
- Try-Finally:只提供了一个 finally处理程序。
- Try-Catch-Finally:提供了一个或多个 catch处理程序以及一个 finally处理程序。
编写 catch 块时,可只指定异常类型,也可同时指定异常类型和标识符,这种标识符称为catch处理程序变量。catch处理程序变量让您能够在catch块中引用异常对象,由于该变量的作用域为特定catch处理程序,且只会执行一个catch处理程序,因此同一个标识符可用于多个catch处理程序。然而,不能将该标识符用于方法参数或其他局部变量。
如下栗子为声明catch处理程序
try
{
int divisor = Convert.ToInt32(Console.ReadLine());
int result = 3 / divisor;
}
catch (DivideByZeroException ex)
{
Console.WriteLine(ex.Message);
}
如果有多个嵌套的try块,且每个try块都有匹配的catch处理程序,那么执行哪个catch处理程序取决于嵌套顺序。找到并执行匹配的catch处理程序后,就不会执行其他的catch处理程序。
如下示例捕获多种异常
try
{
int divisor = Convert.ToInt32(Console.ReadLine());
int result = 3 / divisor;
}
catch (DivideByZeroException)
{
Console.WriteLine("Attempted to divide by zero");
}
catch (FormatException)
{
Console.WriteLine("Input was not in the correct format");
}
catch (Exception)
{
Console.WriteLine("General catch handler");
}
如上try块导致DivideByZeroException,那么输出将为Attempted to divide by zero;如果该 try块导致FormatException,那么输出将为 Input was not in the correct format;如果导致其他异常,那么输出将为General catch handler。如果重新排列catch块的顺序,就将通用catch块放在最前面,程序将不能通过编译。
ps:隐藏异常
程序员经常编写这样的catch块,即除将错误写入日志外什么也不错。有时候这很重要,但通常是顶级调用方这样做,并非每个方法都需要这样做。捕获异常后什么也不做,而旨在禁止它沿调用链向上传递时,将导致被称为隐藏异常(swallowing exception)的问题。由于这只是隐藏了异常,而没有对异常做任何处理,这可能导致难以跟踪的间歇性问题。
最佳的做法是,仅当在异常发生后需要做有意义的清理工作(如关闭文件或断开数据库连接)时,才捕获异常。仅当异常发生时才会执行 catch 处理程序,因此不应使用它来做清理工作。如果有清理工作要做,就应在finally处理程序中进行。这意味着除非要在发生异常时采取某种措施,否则应使用try-finally,而不是try-catch或try-catch-finally。
ps:一般而言,不要在代码中捕获不具体的异常,如 Exception、SystemException 和ExternalException。另外,也不要捕获关键的系统异常,如 StackOverflowException 和OutOfMemoryException。对于这些类型的异常,通常无法采取有意义的措施,而捕获它们可能隐藏问题,导致调试和故障排除工作更复杂。
ps:恶化状态异常
恶化状态异常(corrupted state exception)指的是在应用程序外(如OS内核)发生的异常,这意味着运行进程的完整性可能遭到破坏。有大约 12种恶化状态异常,它们不同于常规异常。
常规异常和恶化状态异常之间的差别不在于异常的类型,而在于引发异常的环境。默认情况下,无法捕获恶化状态异常,哪怕捕获Exception也不行。
四、重新引发捕获的异常
如果捕获异常只为将错误信息写入日志或执行没有实际处理异常的操作,就应重新引发异常。通过重新引发异常,可让它继续沿调用栈传递,以寻找合适的catch处理程序。
关键字throw除用于指定要引发的新异常外,还可用于重新引发异常。
为保留调用栈跟踪信息,重新引发异常时应只使用关键字throw,即使catch处理程序包含catch处理程序变量。
如下代码演示了两种重新引发异常的方法,它们之间存在细微的差别,这种差别在于随异常传递的调用栈跟踪信息。
try
{
// Some operation that results in an InvalidOperationException
}
catch (InvalidOperationException ex)
{
Console.WriteLine("Invalid operation occurred");
throw ex;
}
catch (Exception)
{
Console.WriteLine("General catch handler");
throw;
}
在第一种方法中,InvalidOperationException的 catch处理程序使用了 throw ex,这将以出现故障的方法为分界点截断调用栈跟踪信息。这意味着如果查看调用栈跟踪信息,那么看起来异常源自代码。但情况并非总是如此,尤其是在向上传递CLR生成的异常(如SqlException)时。
这种问题被称为“破坏调用栈”,因为不再有完整的调用栈跟踪信息。要核实这一点,可查看这两个代码块的IL代码。在IL代码中,这一点很明显,因为第一种方法的IL指令为throw,而第二种方法的IL指令为rethrow。
包装异常
也可将捕获的异常包装在另一个异常中,然后引发新的异常。当实际异常在上一层环境中没有意义时,经常这样做。
包装异常时,将在 catch 处理程序中引发新异常,并在其中包含原始异常。由于调用栈跟踪信息将重置到新异常处,因此只有通过包含原始异常,才能获悉原始调用栈跟踪信息和异常细节。如下代码演示了引发包装的异常的正确方式。
try
{
// Some operation that can fail
}
catch (Exception ex)
{
throw new InvalidOperationException("The operation failed", ex);
}
应慎用异常包装,并做到深思熟虑。只要对是否应包装异常存在任何疑问,就不要包装它。包装异常可能导致无法获悉发生错误的方法和位置,进而需要花大量时间调试问题;还可能导致调用方难以处理异常,因为它们不仅需要处理当前异常,还需要处理包装的异常—提取并处理真正的异常。
五、溢出和整型算术运算
所有基本数值数据类型的取值范围都是固定的。要获悉该范围的上限和下限,可分别使用属性MaxValue和MinValue。如果试图给int.MaxValue加1,那么结果将如何呢?
这个问题的答案取决于编译器设置。默认情况下,C#编译器允许发生溢出,而上述运算的结果为可能的最大负值。在很多情况下,这种行为都是可以接受的,因为其风险很低。
如果对于每次整型算术运算都执行溢出检查,其开销将导致性能急剧下降,与此相比,溢出带来的风险微不足道。
如果要避免整型算术运算的溢出风险,即控制溢出检查行为,那么可以使用关键字checked和unchecked。这些关键字能够显式地控制溢出检查,因为它们将覆盖编译器设置。
ps:检查和不检查
在检查环境中,导致算术运算溢出的语句将引发异常;在不检查环境中,溢出将被忽略,而结果将被截断。只有一些数值运算受这种设置的影响。
(1)整型类型之间的显式转换。
(2)使用如下运算符的表达式:++、−−、单目−、+、−、*、/。
关键字 checked 和 unchecked 可用于语句块,这将影响语句块中所有的整型算术运算,如下所示
int max = int.MaxValue;
checked
{
int overflow = max++;
Console.WriteLine("The integer arithmetic resulted in an OverflowException.");
}
unchecked
{
int overflow = max++;
Console.WriteLine("The integer arithmetic resulted in a silent overflow.");
}
这些关键字还可用于整型运算表达式,这样表达式将在检查或不检查环境下计算。在这种情况下,必须用括号将表达式括起。
网友评论