例外狀況的最佳做法

設計良好的應用程式可處理例外狀況和錯誤,防止應用程式損毀。 本文將說明處理和建立例外狀況的最佳作法。

使用 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)
{
    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 (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

選擇的方法取決於您預期事件會發生的頻率。

  • 如果事件不常發生,也就是說事件確實是例外且表示錯誤 (例如未預期的檔案結尾),則使用例外狀況處理。 當您使用例外狀況處理時,在正常情況下會執行較少的程式碼。

  • 如果事件定期發生而且可視為正常執行的一部分,請檢查程式碼中是否有錯誤狀況。 當您檢查是否有常見的錯誤狀況時,因為避免例外狀況,所以會執行較少的程式碼。

設計類別以避免例外狀況

類別可提供方法或屬性,讓您避免進行會觸發例外狀況的呼叫。 例如,FileStream 類別會提供方法,協助判斷是否已經抵達檔案結尾。 這些方法或屬性可用來避免萬一您讀取超過檔案結尾時,所擲回的例外狀況。 下列範例示範如何讀取至檔案結尾,而不會觸發例外狀況:

class FileRead
{
public:
    void ReadAll(FileStream^ fileToRead)
    {
        // This if statement is optional
        // as it is very unlikely that
        // the stream would ever be null.
        if (fileToRead == nullptr)
        {
            throw gcnew System::ArgumentNullException();
        }

        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 void ReadAll(FileStream fileToRead)
    {
        // This if statement is optional
        // as it is very unlikely that
        // the stream would ever be null.
        if (fileToRead == null)
        {
            throw new ArgumentNullException();
        }

        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> 還是預設值做為您的錯誤指標,都是必須為您應用程式考慮的事項之一。 透過使用 Nullable<Guid>default 會成為 null,而非 Guid.Empty。 有時候,當值存在或不存在時,新增 Nullable<T> 可讓您清楚了解。 在其他時候,新增 Nullable<T> 會建立不必要的額外案例,而且會產生潛在的錯誤來源。

擲回例外狀況來代替傳回錯誤碼

例外狀況可確保不會發生未通知失敗的情況,因為呼叫程式碼並不會檢查傳回碼。

使用預先定義的 .NET 例外狀況類型

只有在預先定義的類型不適用時,才引進新的例外狀況類別。 例如:

使用字組 Exception 作為例外狀況類別名稱的結尾

如需自訂例外狀況,請適當地加以命名,並從 Exception加以衍生。 例如:

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

在自訂例外狀況類別中包含三個建構函式

當您建立自己的例外狀況類別時,請使用至少三個種常見的建構函式:無參數建構函式、採用字串訊息的建構函式,以及採用字串訊息和內部例外狀況的建構函式。

如需範例,請參閱如何:建立使用者定義的例外狀況

確保從遠端執行程式碼時可以使用例外狀況資料

當您建立使用者定義的例外狀況時,請確保例外狀況的中繼資料可供遠端執行的程式碼使用。

例如,在支援應用程式網域的 .NET 實作上,例外狀況可能會在跨應用程式網域發生。 假定應用程式定義域 A 建立應用程式定義域 B,其會執行擲回例外狀況的程式碼。 為使應用程式定義域 A 能夠正確地攔截及處理例外狀況,其必須能夠尋找含有應用程式定義域 B 所擲回之例外狀況的組件。若應用程式定義域 B 擲回例外狀況,但此例外狀況包含在位於其應用程式基底之下的組件,而不是位於在應用程式定義域 A 的應用程式基底之下的組件,應用程式定義域 A 將無法尋找例外狀況,而且 Common Language Runtime 將會擲回 FileNotFoundException 例外狀況。 若要避免這個情形,您可以透過兩種方式之一,部署含有例外狀況資訊的組件:

  • 將組件放入這兩個應用程式定義域共用的通用應用程式基底。
  • 如果網域不共用通用應用程式基底,則以強式名稱簽署含有例外狀況資訊的組件,並將組件部署到全域組件快取中。

使用文法正確的錯誤訊息

撰寫清楚的句子並包含結尾標點符號。 在每個指派給 Exception.Message 屬性的字串中之句子,都應以句點結束。 例如「記錄表溢位」就是適當的訊息字串。

在每個例外狀況中,納入當地語系化的字串訊息

使用者所看到的錯誤訊息,衍生自所擲回例外狀況的 Exception.Message 屬性,而並非來自例外狀況類別的名稱。 一般來說,您要將值指派到 Exception.Message 屬性,方法是將訊息字串傳遞到例外狀況建構函式message 引數。

若是當地語系化的應用程式,則應對每個應用程式可能會擲回的例外狀況,該提供當地語系化的訊息字串。 您可使用資源檔,提供當地語系化的錯誤訊息。 如需當地語系化應用程式與擷取當地語系化字串的詳細資訊,請參閱:

在自訂例外狀況中,視需要提供額外的屬性

只有在其他資訊對某個程式設計案例實用時,才為例外狀況提供其他屬性 (以及自訂訊息字串)。 例如,FileNotFoundException 提供 FileName 屬性。

放置 throw 陳述式讓堆疊追蹤更有用

堆疊追蹤會從擲回例外狀況所在的陳述式開始,並在攔截例外狀況的 catch 陳述式結束。

使用例外狀況產生器方法

類別在其實作中從不同的地方擲回相同的例外狀況是很常見的。 若要避免過多的程式碼,請使用 Helper 方法,以建立例外狀況並將它傳回。 例如:

ref class FileReader
{
private:
    String^ fileName;

public:
    FileReader(String^ path)
    {
        fileName = path;
    }

    array<Byte>^ Read(int bytes)
    {
        array<Byte>^ results = FileUtils::ReadFromFile(fileName, bytes);
        if (results == nullptr)
        {
            throw NewFileIOException();
        }
        return results;
    }

    FileReaderException^ NewFileIOException()
    {
        String^ description = "My NewFileIOException Description";

        return gcnew FileReaderException(description);
    }
};
class FileReader
{
    private string fileName;

    public FileReader(string path)
    {
        fileName = path;
    }

    public byte[] Read(int bytes)
    {
        byte[] results = FileUtils.ReadFromFile(fileName, bytes);
        if (results == null)
        {
            throw NewFileIOException();
        }
        return results;
    }

    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

在某些情況下,使用例外狀況的建構函式來建置例外狀況會更適當。 範例為全域例外狀況類別 ArgumentException

當方法因為例外狀況而未完成時還原狀態

呼叫端應該能夠假設,從方法擲回例外狀況時不會產生副作用。 例如,如果您有用來轉帳的程式碼,會從某個帳戶提款再存入另一個帳戶,而且在執行存款時擲回例外狀況,則您不想要讓提款仍有效。

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

擷取稍後要重新擲回的例外狀況

若要擷取例外狀況並保留其呼叫堆疊,以便稍後能夠重新擲回,請使用 System.Runtime.ExceptionServices.ExceptionDispatchInfo 類別。 這個類別提供下列方法與屬性 (其他則不贅述):

下列範例示範如何使用 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

另請參閱