异常的最佳做法
正确的异常处理对于应用程序的可靠性至关重要。 可以有意处理预期异常以防止应用崩溃。 但是,崩溃的应用比具有未定义行为的应用更可靠且可诊断。
本文描述处理和创建异常的最佳做法。
处理异常
以下最佳做法涉及到如何处理异常:
使用 try/catch/finally 块从错误中恢复或释放资源
对于可能生成异常的代码,以及在应用可以从该异常中恢复时,请在代码的两侧使用 try
/catch
块。 在 catch
块中,始终按从派生程度最高到派生程度最低的顺序对异常排序。 (所有异常都派生自 Exception 类。位于处理基本异常类的 catch
子句之后的 catch
子句不会处理其他派生的异常。)当代码无法从异常中恢复时,请勿捕获该异常。 如有可能,请启用调用堆栈中更上层的方法来进行恢复。
使用 using
语句或 finally
块清除分配的资源。 当引发了异常时,优先使用 using
语句自动清除资源。 使用 finally
块清除未实现 IDisposable 的资源。 即使引发了异常,通常也会执行 finally
子句中的代码。
处理常见状态以避免异常
对于易于发生但可能会触发异常的情况,请考虑使用能避免异常的方法进行处理。 例如,如果尝试关闭已关闭的连接,则会收到 InvalidOperationException
。 尝试关闭前,可通过使用 if
语句检查连接状态,避免该情况。
if (conn.State != ConnectionState.Closed)
{
conn.Close();
}
If conn.State <> ConnectionState.Closed Then
conn.Close()
End IF
如果关闭前未检查连接状态,则可能捕获 InvalidOperationException
异常。
try
{
conn.Close();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.GetType().FullName);
Console.WriteLine(ex.Message);
}
Try
conn.Close()
Catch ex As InvalidOperationException
Console.WriteLine(ex.GetType().FullName)
Console.WriteLine(ex.Message)
End Try
选择的方法取决于事件的预期发生频率。
如果此事件未经常发生(也就是说,如果此事件确实为异常事件并指示错误[如意外的文件尾]),则使用异常处理。 如果使用异常处理,将在正常条件下执行较少代码。
如果事件例行发生,且被视为正常性执行的一部分,请检查代码中是否存在错误情况。 检查常见错误情况时,为了避免异常,执行较少的代码。
注意
提前检查在大多数情况下都可以消除异常。 但可能存在争用条件:受保护的条件在检查和操作之间会发生变化,在这种情况下,仍可能会引发异常。
调用 Try*
方法以避免异常
如果异常造成的性能代价过大,某些 .NET 库方法会提供替代形式的错误处理。 例如,如果要分析的值太大,因而无法用 Int32 表示,则 Int32.Parse 会引发 OverflowException。 但是,Int32.TryParse 不会引发此异常。 取而代之的是,它会返回一个布尔值并提供一个 out
参数,其中包含成功时分析的有效整数。 在尝试从字典中获取值时,Dictionary<TKey,TValue>.TryGetValue 具有类似的行为。
捕获取消和异步异常
调用异步方法时,最好捕获 OperationCanceledException,而不是派生自 OperationCanceledException
的 TaskCanceledException。 如果请求了取消,许多异步方法会引发 OperationCanceledException 异常。 一旦观察到取消请求,这些异常就能有效地停止执行,并展开调用堆栈。
异步方法将执行期间引发的异常存储在它们返回的任务中。 如果将异常存储在返回的任务中,则等待任务时将引发该异常。 使用情况异常(例如 ArgumentException)仍会同步引发。 有关详细信息,请参阅异步异常。
设计类,以避免异常
类可提供一些方法或属性来确保避免生成会引发异常的调用。 例如,FileStream 类提供可帮助确实是否已到达文件末尾的方法。 可以使用这些方法来避免在读取操作超过文件末尾时引发的异常。 以下示例显示如何读取文件末尾而不会引发异常:
class FileRead
{
public static void ReadAll(FileStream fileToRead)
{
ArgumentNullException.ThrowIfNull(fileToRead);
int b;
// Set the stream position to the beginning of the file.
fileToRead.Seek(0, SeekOrigin.Begin);
// Read each byte to the end of the file.
for (int i = 0; i < fileToRead.Length; i++)
{
b = fileToRead.ReadByte();
Console.Write(b.ToString());
// Or do something else with the byte.
}
}
}
Class FileRead
Public Sub ReadAll(fileToRead As FileStream)
' This if statement is optional
' as it is very unlikely that
' the stream would ever be null.
If fileToRead Is Nothing Then
Throw New System.ArgumentNullException()
End If
Dim b As Integer
' Set the stream position to the beginning of the file.
fileToRead.Seek(0, SeekOrigin.Begin)
' Read each byte to the end of the file.
For i As Integer = 0 To fileToRead.Length - 1
b = fileToRead.ReadByte()
Console.Write(b.ToString())
' Or do something else with the byte.
Next i
End Sub
End Class
避免异常的另一方法是,对最常见的错误案例返回 null
(或默认值),而不是引发异常。 常见的错误案例可被视为常规控制流。 通过在这些情况下返回 null
(或默认值),可最大程度地减小对应用的性能产生的影响。
对于值类型,请考虑是要使用 Nullable<T>
还是 default
作为应用的错误指示符。 通过使用 Nullable<Guid>
,default
变为 null
而非 Guid.Empty
。 有时,添加 Nullable<T>
可更加明确值何时存在或不存在。 在其他时候,添加 Nullable<T>
可以创建额外的案例以查看不必要的内容,并且仅用于创建潜在的错误源。
因发生异常而未完成方法时还原状态
当异常从方法引发时,调用方应能够假定没有副作用。 例如,如果你的代码可以通过从一个帐户取钱并存入另一个帐户来转移资金,而在存款时引发了异常,你不希望取款仍然有效。
public void TransferFunds(Account from, Account to, decimal amount)
{
from.Withdrawal(amount);
// If the deposit fails, the withdrawal shouldn't remain in effect.
to.Deposit(amount);
}
Public Sub TransferFunds(from As Account, [to] As Account, amount As Decimal)
from.Withdrawal(amount)
' If the deposit fails, the withdrawal shouldn't remain in effect.
[to].Deposit(amount)
End Sub
上述方法不会直接引发任何异常。 但是,你必须编写该方法,以便在存款操作失败时撤消取款。
解决这一情况的一种方法是,捕获由存款交易引发的异常,然后回滚取款。
private static void TransferFunds(Account from, Account to, decimal amount)
{
string withdrawalTrxID = from.Withdrawal(amount);
try
{
to.Deposit(amount);
}
catch
{
from.RollbackTransaction(withdrawalTrxID);
throw;
}
}
Private Shared Sub TransferFunds(from As Account, [to] As Account, amount As Decimal)
Dim withdrawalTrxID As String = from.Withdrawal(amount)
Try
[to].Deposit(amount)
Catch
from.RollbackTransaction(withdrawalTrxID)
Throw
End Try
End Sub
此示例介绍如何使用 throw
再次引发原始异常,让调用方更轻松地找到问题的真正原因,而无需检查 InnerException 属性。 另一种方法是,引发一个新的异常并将原始异常包括在其中作为内部异常。
catch (Exception ex)
{
from.RollbackTransaction(withdrawalTrxID);
throw new TransferFundsException("Withdrawal failed.", innerException: ex)
{
From = from,
To = to,
Amount = amount
};
}
Catch ex As Exception
from.RollbackTransaction(withdrawalTrxID)
Throw New TransferFundsException("Withdrawal failed.", innerException:=ex) With
{
.From = from,
.[To] = [to],
.Amount = amount
}
End Try
正确捕获和重新引发异常
引发异常后,它所包含的部分信息为堆栈跟踪。 堆栈跟踪是一个方法调用层次结构列表,它以引发异常的方法开头,以捕获异常的方法结尾。 如果通过在 throw
语句中指定异常(例如 throw e
)来再次引发该异常,则将在当前方法处重启堆栈跟踪,并且引发该异常的原始方法与当前方法之间的方法调用列表将丢失。 若要保留异常的原始堆栈跟踪信息,可以根据重新引发异常的位置使用两个选项:
- 如果从捕获异常实例的处理程序(
catch
块)内部重新引发异常,请在不指定异常的情况下使用throw
语句。 代码分析规则 CA2200 可帮助你查找代码中可能会无意中丢失堆栈跟踪信息的位置。 - 如果要从处理程序(
catch
程序块)以外的某个位置再次引发异常,请在需要再次引发异常时使用 ExceptionDispatchInfo.Capture(Exception) 捕获处理程序中的异常以及 ExceptionDispatchInfo.Throw()。 可以使用 ExceptionDispatchInfo.SourceException 属性检查捕获的异常。
以下示例演示 ExceptionDispatchInfo 的使用方法,以及可能产生的输出示例。
ExceptionDispatchInfo? edi = null;
try
{
var txt = File.ReadAllText(@"C:\temp\file.txt");
}
catch (FileNotFoundException e)
{
edi = ExceptionDispatchInfo.Capture(e);
}
// ...
Console.WriteLine("I was here.");
if (edi is not null)
edi.Throw();
如果示例代码中的文件不存在,则会生成以下输出:
I was here.
Unhandled exception. System.IO.FileNotFoundException: Could not find file 'C:\temp\file.txt'.
File name: 'C:\temp\file.txt'
at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options)
at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
at System.IO.StreamReader.ValidateArgsAndOpenPath(String path, Encoding encoding, Int32 bufferSize)
at System.IO.File.ReadAllText(String path, Encoding encoding)
at Example.ProcessFile.Main() in C:\repos\ConsoleApp1\Program.cs:line 12
--- End of stack trace from previous location ---
at Example.ProcessFile.Main() in C:\repos\ConsoleApp1\Program.cs:line 24
引发异常
以下最佳做法涉及到如何引发异常:
使用预定义的异常类型
仅当预定义的异常类不适用时,引入新异常类。 例如:
- 如果根据对象的当前状态,属性集或方法调用不适当,则会引发 InvalidOperationException 异常。
- 如果传送了无效的参数,则引发 ArgumentException 异常或从 ArgumentException 派生的一个预定义类。
注意
虽然在可能的情况下最好是使用预定义的异常类型,但不应引发某些保留的异常类型,例如 AccessViolationException、IndexOutOfRangeException、NullReferenceException 和 StackOverflowException。 有关详细信息,请参阅 CA2201:不要引发保留的异常类型。
使用异常生成器方法
类从其实现中的不同位置引发同一异常是常见的情况。 为了避免代码过多,请创建一个帮助器方法来创建并返回异常。 例如:
class FileReader
{
private readonly string _fileName;
public FileReader(string path)
{
_fileName = path;
}
public byte[] Read(int bytes)
{
byte[] results = FileUtils.ReadFromFile(_fileName, bytes) ?? throw NewFileIOException();
return results;
}
static FileReaderException NewFileIOException()
{
string description = "My NewFileIOException Description";
return new FileReaderException(description);
}
}
Class FileReader
Private fileName As String
Public Sub New(path As String)
fileName = path
End Sub
Public Function Read(bytes As Integer) As Byte()
Dim results() As Byte = FileUtils.ReadFromFile(fileName, bytes)
If results Is Nothing
Throw NewFileIOException()
End If
Return results
End Function
Function NewFileIOException() As FileReaderException
Dim description As String = "My NewFileIOException Description"
Return New FileReaderException(description)
End Function
End Class
某些关键的 .NET 异常类型具有这种静态 throw
帮助器方法,这些方法可以分配和引发异常。 应调用这些方法,而不是构造并引发相应的异常类型:
- ArgumentNullException.ThrowIfNull
- ArgumentException.ThrowIfNullOrEmpty(String, String)
- ArgumentException.ThrowIfNullOrWhiteSpace(String, String)
- ArgumentOutOfRangeException.ThrowIfZero<T>(T, String)
- ArgumentOutOfRangeException.ThrowIfNegative<T>(T, String)
- ArgumentOutOfRangeException.ThrowIfEqual<T>(T, T, String)
- ArgumentOutOfRangeException.ThrowIfLessThan<T>(T, T, String)
- ArgumentOutOfRangeException.ThrowIfNotEqual<T>(T, T, String)
- ArgumentOutOfRangeException.ThrowIfNegativeOrZero<T>(T, String)
- ArgumentOutOfRangeException.ThrowIfGreaterThan<T>(T, T, String)
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual<T>(T, T, String)
- ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual<T>(T, T, String)
- ObjectDisposedException.ThrowIf
如果你要实现异步方法,请调用 CancellationToken.ThrowIfCancellationRequested(),而不是检查是否请求了取消,然后构造并引发 OperationCanceledException。 有关详细信息,请参阅 CA2250。
包含本地化的字符串消息
用户看到的错误消息派生自引发的异常的 Exception.Message 属性,而不是派生自异常类的名称。 通常将值赋给 Exception.Message 属性,方法是将消息字符串传递到异常构造函数的 message
参数。
对于本地化应用程序,应为应用程序可能引发的每个异常提供本地化消息字符串。 资源文件用于提供本地化错误消息。 有关本地化应用程序和检索本地化字符串的信息,请参阅以下文章:
使用正确的语法
编写清晰的句子,包括结束标点。 分配给 Exception.Message 属性的字符串中的每个句子应以句点结尾。 例如“日志表已溢出。”使用了正确的语法和标点符号。
妥善放置 throw 语句
将 throw 语句放置在堆栈跟踪有帮助的位置。 堆栈跟踪从引发异常的语句开始,到捕获异常的 catch
语句结束。
不要在 finally 子句中引发异常
不要在 finally
子句中引发异常。 有关详细信息,请参阅代码分析规则 CA2219。
不要从意外的位置引发异常
某些方法(例如 Equals
、GetHashCode
和 ToString
方法)、静态构造函数和相等运算符不应引发异常。 有关详细信息,请参阅代码分析规则 CA1065。
同步引发参数验证异常
在任务返回方法中,应该在进入方法的异步部分之前验证参数并引发任何相应的异常,例如 ArgumentException 和 ArgumentNullException。 在方法的异步部分引发的异常将存储在返回的任务中,并且在等待任务等状态之前不会出现。 有关详细信息,请参阅任务返回方法中的异常。
自定义异常类型
以下最佳做法涉及到自定义异常类型:
在异常类名的末尾使用 Exception
需要自定义异常时,对其正确命名并从 Exception 类进行派生。 例如:
public class MyFileNotFoundException : Exception
{
}
Public Class MyFileNotFoundException
Inherits Exception
End Class
包含三个构造函数
创建自己的异常类时,请至少使用三种公共构造函数:无参数构造函数、采用字符串消息的构造函数以及采用字符串消息和内部异常的构造函数。
- Exception()(使用默认值)。
- Exception(String),它接受字符串消息。
- Exception(String, Exception),它接受字符串消息和内部异常。
有关示例,请参阅如何:创建用户定义的异常。
根据需要提供其他属性
仅当存在附加信息有用的编程方案时,才在异常中提供附加属性(不包括自定义消息字符串)。 例如,FileNotFoundException 提供 FileName 属性。