异常的最佳做法

正确的异常处理对于应用程序的可靠性至关重要。 可以有意处理预期异常以防止应用崩溃。 但是,崩溃的应用比具有未定义行为的应用更可靠且可诊断。

本文描述处理和创建异常的最佳做法。

处理异常

以下最佳做法涉及到如何处理异常:

使用 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,而不是派生自 OperationCanceledExceptionTaskCanceledException。 如果请求了取消,许多异步方法会引发 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

引发异常

以下最佳做法涉及到如何引发异常:

使用预定义的异常类型

仅当预定义的异常类不适用时,引入新异常类。 例如:

注意

虽然在可能的情况下最好是使用预定义的异常类型,但不应引发某些保留的异常类型,例如 AccessViolationExceptionIndexOutOfRangeExceptionNullReferenceExceptionStackOverflowException。 有关详细信息,请参阅 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 帮助器方法,这些方法可以分配和引发异常。 应调用这些方法,而不是构造并引发相应的异常类型:

提示

以下代码分析规则可以帮助你在代码中查找可利用这些静态 throw 帮助器的位置:CA1510CA1511CA1512CA1513

如果你要实现异步方法,请调用 CancellationToken.ThrowIfCancellationRequested(),而不是检查是否请求了取消,然后构造并引发 OperationCanceledException。 有关详细信息,请参阅 CA2250

包含本地化的字符串消息

用户看到的错误消息派生自引发的异常的 Exception.Message 属性,而不是派生自异常类的名称。 通常将值赋给 Exception.Message 属性,方法是将消息字符串传递到异常构造函数message 参数。

对于本地化应用程序,应为应用程序可能引发的每个异常提供本地化消息字符串。 资源文件用于提供本地化错误消息。 有关本地化应用程序和检索本地化字符串的信息,请参阅以下文章:

使用正确的语法

编写清晰的句子,包括结束标点。 分配给 Exception.Message 属性的字符串中的每个句子应以句点结尾。 例如“日志表已溢出。”使用了正确的语法和标点符号。

妥善放置 throw 语句

将 throw 语句放置在堆栈跟踪有帮助的位置。 堆栈跟踪从引发异常的语句开始,到捕获异常的 catch 语句结束。

不要在 finally 子句中引发异常

不要在 finally 子句中引发异常。 有关详细信息,请参阅代码分析规则 CA2219

不要从意外的位置引发异常

某些方法(例如 EqualsGetHashCodeToString 方法)、静态构造函数和相等运算符不应引发异常。 有关详细信息,请参阅代码分析规则 CA1065

同步引发参数验证异常

在任务返回方法中,应该在进入方法的异步部分之前验证参数并引发任何相应的异常,例如 ArgumentExceptionArgumentNullException。 在方法的异步部分引发的异常将存储在返回的任务中,并且在等待任务等状态之前不会出现。 有关详细信息,请参阅任务返回方法中的异常

自定义异常类型

以下最佳做法涉及到自定义异常类型:

在异常类名的末尾使用 Exception

需要自定义异常时,对其正确命名并从 Exception 类进行派生。 例如:

public class MyFileNotFoundException : Exception
{
}
Public Class MyFileNotFoundException
    Inherits Exception
End Class

包含三个构造函数

创建自己的异常类时,请至少使用三种公共构造函数:无参数构造函数、采用字符串消息的构造函数以及采用字符串消息和内部异常的构造函数。

有关示例,请参阅如何:创建用户定义的异常

根据需要提供其他属性

仅当存在附加信息有用的编程方案时,才在异常中提供附加属性(不包括自定义消息字符串)。 例如,FileNotFoundException 提供 FileName 属性。

另请参阅