Bonnes pratiques pour les exceptions

Une application bien conçue gère les exceptions et les erreurs pour empêcher les incidents d'applications. Cet article présente les meilleures pratiques pour la gestion et la création des exceptions.

Utiliser des blocs try/catch/finally pour procéder à une récupération après des erreurs ou libérer des ressources

Utilisez des blocs try/catch autour du code susceptible de générer une exception, et votre code peut récupérer suite à cette exception. Dans les blocs catch, veillez à toujours classer les exceptions de la plus dérivée à la moins dérivée. Toutes les exceptions dérivent de la classe Exception. Les exceptions les plus dérivées ne sont pas gérées par une clause catch qui est précédée d’une clause catch pour une classe d’exception de base. Quand votre code ne peut pas récupérer suite à une exception, n’interceptez pas cette exception. Activez des méthodes un peu plus haut dans la pile d’appels pour récupérer si possible.

Nettoyez les ressources qui sont allouées avec des instructions using ou des blocs finally. Préférez les instructions using pour nettoyer automatiquement les ressources quand des exceptions sont levées. Utilisez des blocs finally pour nettoyer les ressources qui n’implémentent pas IDisposable. Le code dans une clause finally est presque toujours exécuté même lorsque des exceptions sont levées.

Gérer les conditions courantes sans lever d’exception

En ce qui concerne les conditions susceptibles de se produire, mais pouvant déclencher une exception, gérez-les de façon à éviter l’exception. Par exemple, si vous essayez de fermer une connexion déjà fermée, vous obtenez une exception InvalidOperationException. Vous pouvez l’éviter avec une instruction if qui permet de vérifier l’état de la connexion avant d’essayer de la fermer.

if (conn->State != ConnectionState::Closed)
{
    conn->Close();
}
if (conn.State != ConnectionState.Closed)
{
    conn.Close();
}
If conn.State <> ConnectionState.Closed Then
    conn.Close()
End IF

Si vous ne vérifiez pas l’état de la connexion avant de la fermer, vous pouvez intercepter l’exception 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

Le choix de la méthode dépend de la fréquence à laquelle l’événement doit se produire.

  • Utilisez la gestion des exceptions si l’événement ne se produit pas souvent, c’est-à-dire, si l’événement est véritablement exceptionnel et indique une erreur, telle qu’une fin de fichier inattendue. Lorsque vous utilisez la gestion des exceptions, la quantité de code exécutée en situation normale est moindre.

  • Recherchez les conditions d’erreur dans le code si l’événement se produit régulièrement et peut être considéré comme faisant partie de l’exécution normale. Quand vous recherchez les conditions d’erreur courantes, vous exécutez moins de code, car vous évitez les exceptions.

Concevoir des classes pour éviter les exceptions

Une classe peut fournir des méthodes ou propriétés qui vous permettent d’éviter d’effectuer un appel susceptible de déclencher une exception. Par exemple, une classe FileStream fournit des méthodes qui permettent de déterminer si la fin du fichier a été atteinte. Ces méthodes peuvent servir à éviter l’exception qui est levée si vous dépassez la fin du fichier pendant la lecture. L’exemple suivant montre comment lire un fichier jusqu’à la fin sans lever d’exception :

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

Un autre moyen d’éviter les exceptions est de retourner Null (ou une valeur par défaut) pour la plupart des cas d’erreur répandus au lieu de lever une exception. Un cas d’erreur répandu peut être considéré comme un flux de contrôle normal. En retournant null (ou une valeur par défaut) dans ces cas-là, vous réduisez l'impact sur les performances d'une application.

Pour les types valeur, s’il faut utiliser Nullable<T> ou une valeur par défaut comme indicateur d’erreur est quelque chose à prendre en compte pour votre application. À l’aide de Nullable<Guid>, default devient null au lieu de Guid.Empty. Parfois, l’ajout de Nullable<T> peut éclaircir les choses, lorsqu’une valeur est présente ou absente. D’autres fois, l’ajout de Nullable<T> peut créer des cas supplémentaires qui ne sont pas nécessaires et uniquement servir pour créer les sources potentielles d’erreurs.

Lever des exceptions au lieu de retourner un code d’erreur

Les exceptions font en sorte que les échecs ne passent pas inaperçus parce que l’appel du code n’a pas vérifié le code de retour.

Utiliser les types d’exception .NET prédéfinis

N'introduisez une nouvelle classe d'exception que quand aucune classe d'exception prédéfinie ne s'applique. Par exemple :

  • Si un appel de jeu de propriétés ou de méthode n’est pas approprié étant donné l’état actuel de l’objet, levez une exception InvalidOperationException.
  • Si des paramètres non valides sont passés, levez une exception ArgumentException ou l’une des classes prédéfinies qui dérivent de ArgumentException.

Terminer les noms de classes d’exception par le mot Exception

Quand une exception personnalisée est nécessaire, nommez-la de manière appropriée et dérivez-la de la classe Exception. Par exemple :

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

Inclure trois constructeurs dans des classes d’exception personnalisées

Utilisez au moins les trois constructeurs communs pendant la création de vos propres classes d’exception : le constructeur sans paramètre, un constructeur qui prend un message de type chaîne et un constructeur qui prend un message de type chaîne et une exception interne.

Pour obtenir un exemple, consultez Guide pratique : créer des exceptions définies par l’utilisateur.

Vérifier que les données d’exception sont disponibles quand le code s’exécute à distance

Quand vous créez des exceptions définies par l’utilisateur, vous devez vérifier que les métadonnées des exceptions sont disponibles pour le code qui s’exécute à distance.

Par exemple, sur les implémentations .NET qui prennent en charge des domaines d’application, des exceptions peuvent se produire entre domaines d’application. Supposons que le domaine d’application A crée le domaine d’application B, lequel exécute du code qui lève une exception. Pour que le domaine d’application A intercepte et gère l’exception correctement, il doit pouvoir trouver l’assembly qui contient l’exception levée par le domaine d’application B. Si le domaine d’application B lève une exception qui est contenue dans un assembly sous sa base d’application, mais pas sous la base d’application du domaine d’application A, le domaine d’application A ne peut pas trouver l’exception et le Common Language Runtime lève une exception FileNotFoundException. Pour éviter cette situation, vous pouvez déployer l’assembly qui contient les informations sur les exceptions de l’une des deux façons suivantes :

  • Placez l'assembly dans une base d'application commune partagée par les deux domaines d'application.
  • Si les domaines ne partagent pas une base d’application commune, signez l’assembly qui contient les informations sur les exceptions à l’aide d’un nom fort et déployez l’assembly dans le Global Assembly Cache.

Utiliser des messages d’erreur grammaticalement corrects

Écrivez des phrases claires et insérez une ponctuation finale. Chaque phrase de la chaîne affectée à la propriété Exception.Message doit se terminer par un point. Par exemple, « Dépassement de la table du journal. » doit être une chaîne de message appropriée.

Inclure une chaîne de message localisée dans chaque exception

Le message d’erreur que l’utilisateur voit est dérivé de la propriété Exception.Message de l’exception qui a été levée, et non pas du nom de la classe d’exception. En règle générale, vous affectez une valeur à la propriété Exception.Message en passant la chaîne de message à l’argument message d’un constructeur d’exception.

Pour les applications localisées, vous devez fournir une chaîne de message localisée pour chaque exception que votre application peut lever. Vous utilisez des fichiers de ressources pour fournir les messages d’erreur localisés. Pour plus d’informations sur la localisation d’applications et la récupération des chaînes localisées, consultez les articles suivants :

Dans les exceptions personnalisées, fournir des propriétés supplémentaires si nécessaire

Spécifiez des propriétés supplémentaires (en plus de la chaîne de message personnalisée) pour une exception seulement dans le cas d’un scénario du programme où les informations supplémentaires sont utiles. Par exemple, la classe FileNotFoundException fournit la propriété FileName.

Placer des instructions throw pour que la trace de la pile soit utile

La trace de la pile commence à l'instruction où l'exception est levée et se termine à l'instruction catch qui intercepte l'exception.

Utiliser des méthodes de générateur d’exceptions

Il est fréquent qu’une classe lève la même exception à partir de différents endroits de son implémentation. Pour éviter un excès de code, utilisez des méthodes d'assistance qui créent une exception et la retournent. Par exemple :

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

Dans certains cas, il est plus approprié d’utiliser le constructeur de l’exception pour générer l’exception. Par exemple, une classe d’exception globale comme ArgumentException.

Restaurer l’état quand les méthodes ne sont pas exécutées en raison d’exceptions

Les appelants doivent supposer qu'il n'y a aucun effet secondaire quand une exception est levée à partir d'une méthode. Par exemple, si vous avez du code qui transfère de l’argent en le retirant d’un compte pour le déposer dans un autre, et qu’une exception est levée pendant l’exécution du transfert, vous ne voulez pas que le retrait reste en vigueur.

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

La méthode précédente ne lève aucune exception directement. Toutefois, vous devez écrire la méthode afin que le retrait soit inversé si l’opération de dépôt échoue.

Pour gérer cette situation, vous pouvez intercepter toutes les exceptions levées par la transaction de dépôt et annuler le retrait.

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

Cet exemple illustre l’utilisation de throw pour lever de nouveau l’exception d’origine, ce qui permet aux appelants de voir plus facilement la véritable cause du problème sans avoir à examiner la propriété InnerException. Vous pouvez aussi lever une nouvelle exception et inclure l’exception d’origine comme exception interne.

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

Capture des exceptions pour les lever à nouveau ultérieurement

Pour capturer une exception et conserver sa pile d’appels afin de pouvoir la lever à nouveau ultérieurement, utilisez la classe System.Runtime.ExceptionServices.ExceptionDispatchInfo. Cette classe fournit les méthodes et propriétés suivantes (entre autres) :

L’exemple suivant montre comment la classe ExceptionDispatchInfo peut être utilisée et à quoi peut ressembler la sortie.

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();

Si le fichier de l’exemple de code n’existe pas, la sortie suivante est générée :

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

Voir aussi