Chapitre 1024 minIntermédiaire
Exceptions : try, catch et exceptions personnalisées
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- attraper une exception avec
try,catchetfinally; - lever l'exception adaptée à chaque situation ;
- créer des exceptions personnalisées pour les règles métier ;
- libérer une ressource avec
using; - repérer une mauvaise gestion des erreurs.
4 exercices corrigés1 schéma
Une exception est un objet qui signale une erreur survenue pendant l'exécution : un fichier introuvable, une saisie qui n'est pas un nombre, une règle de gestion violée. En C#, on la lève avec throw, et on l'attrape avec un bloc try … catch ; si personne ne l'attrape, le programme s'arrête. Ce chapitre explique le mécanisme, la hiérarchie des exceptions de .NET, la création d'exceptions personnalisées pour ton domaine métier, et les bonnes pratiques qui distinguent un code robuste d'un code qui cache ses erreurs.
Pourquoi des exceptions
Sans exception, une méthode qui échoue doit le dire par sa valeur de retour : -1, null, false. Chaque appelant doit tester ce code, et l'oubli est silencieux : le programme continue avec une valeur fausse. Une exception, elle, interrompt le déroulement normal et remonte jusqu'au code qui sait la traiter. Impossible de l'ignorer par mégarde.
Tu en as déjà levé : dans les propriétés du chapitre 3 et les constructeurs du chapitre 4, throw new ArgumentException(…) refuse une valeur invalide. Ce chapitre regarde maintenant l'autre côté : que fait le code appelant ?
try, catch et finally
Logiciel acrobat = new Logiciel("Adobe Acrobat Pro", 2);
string[] postes = ["PC-0001", "PC-0002", "PC-0003"];
foreach (string p in postes)
{
try
{
acrobat.Installer(p);
Console.WriteLine($"{p} : installé");
}
catch (LicencesEpuiseesException e)
{
Console.WriteLine($"{p} : refusé. {e.Message}");
Console.WriteLine($" logiciel = {e.NomLogiciel}, licences = {e.NbLicences}");
}
finally
{
Console.WriteLine($" (tentative journalisée pour {p})");
}
}
PC-0001 : installé
(tentative journalisée pour PC-0001)
PC-0002 : installé
(tentative journalisée pour PC-0002)
PC-0003 : refusé. Les 2 licences du logiciel Adobe Acrobat Pro sont toutes utilisées.
logiciel = Adobe Acrobat Pro, licences = 2
(tentative journalisée pour PC-0003)
| Bloc | Rôle |
|---|---|
try | le code qui peut échouer ; à la première exception, la suite du bloc est abandonnée |
catch (TypeException e) | traite les exceptions de ce type (et de ses sous-types) ; e donne accès au message et aux détails |
finally | s'exécute toujours, qu'il y ait eu une exception ou non, même après un return : pour libérer, fermer, journaliser |
Un try peut avoir plusieurs catch : le premier dont le type correspond est exécuté, les autres sont ignorés. Range-les donc du plus précis au plus général : un catch (Exception) placé avant un catch (FormatException) rendrait le second inaccessible, et le compilateur le refuse (erreur CS0160).
La hiérarchie des exceptions
Toutes les exceptions héritent de la classe System.Exception. Attraper un type attrape aussi toutes ses classes filles : c'est le polymorphisme appliqué aux erreurs.
| Exception | Signification | Exemple |
|---|---|---|
ArgumentException | un argument est invalide | nom vide passé à un constructeur |
↳ ArgumentNullException | un argument vaut null alors qu'il est obligatoire | ArgumentNullException.ThrowIfNull(demandeur) |
↳ ArgumentOutOfRangeException | un argument sort de l'intervalle permis | durée négative |
InvalidOperationException | l'opération est impossible dans l'état actuel | résoudre un ticket sans intervention |
FormatException | un texte n'a pas le format attendu | int.Parse("abc") |
NullReferenceException | appel sur une référence null | presque toujours un bug, à corriger plutôt qu'à attraper |
KeyNotFoundException | clé absente d'un dictionnaire | d["PC-0009"] |
ArithmeticException ↳ DivideByZeroException | erreur de calcul | division entière par zéro |
IOException ↳ FileNotFoundException | erreur d'entrée-sortie | fichier d'import absent |
NotSupportedException, NotImplementedException | opération non prise en charge, pas encore écrite |
Chaque exception porte des informations utiles : Message (le texte), StackTrace (la pile d'appels, les méthodes traversées), InnerException (l'exception d'origine, quand une erreur en a provoqué une autre) et GetType().Name (son type).
La propagation
Quand une méthode lève une exception sans l'attraper, l'exception remonte à la méthode qui l'a appelée, puis à celle qui a appelé celle-ci, et ainsi de suite, jusqu'au premier catch compatible. Chaque méthode traversée est interrompue (ses blocs finally s'exécutent au passage). Si aucune ne l'attrape, le programme s'arrête et affiche Unhandled exception avec la pile d'appels.
C'est ce qui permet de séparer la détection de l'erreur (au plus près des données, dans la classe métier) de son traitement (au plus près de l'utilisateur, dans l'écran ou le programme principal). Logiciel.Installer sait que les licences manquent, mais ne sait pas s'il faut afficher une boîte de dialogue, écrire dans un journal ou renvoyer un code HTTP 409 : ce n'est pas son rôle.
Lever une exception : throw
public void Installer(string numPoste)
{
if (!PeutInstaller)
{
throw new LicencesEpuiseesException(Nom, NbLicences);
}
postes.Add(numPoste);
}
throw interrompt immédiatement la méthode : la ligne postes.Add n'est pas exécutée, l'objet reste dans un état cohérent. Pour les vérifications d'arguments, .NET fournit des méthodes de garde qui lèvent la bonne exception avec un message standard :
public Logiciel(string nom, int nbLicences)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nom);
ArgumentOutOfRangeException.ThrowIfNegative(nbLicences);
Nom = nom;
NbLicences = nbLicences;
}
throw peut aussi s'écrire dans une expression, après ?? ou dans un opérateur conditionnel : Demandeur = demandeur ?? throw new ArgumentNullException(nameof(demandeur));.
Créer ses propres exceptions
Les exceptions de .NET décrivent des erreurs techniques. Pour une règle de ton domaine, une exception personnalisée est plus parlante, et le code appelant peut l'attraper spécifiquement sans confondre avec une autre erreur. On crée une classe qui hérite d'Exception (ou d'une de ses filles), dont le nom se termine par Exception.
class GestParcException : Exception
{
public GestParcException(string message) : base(message) { }
public GestParcException(string message, Exception inner) : base(message, inner) { }
}
class LicencesEpuiseesException : GestParcException
{
public LicencesEpuiseesException(string nomLogiciel, int nbLicences)
: base($"Les {nbLicences} licences du logiciel {nomLogiciel} sont toutes utilisées.")
{
NomLogiciel = nomLogiciel;
NbLicences = nbLicences;
}
public string NomLogiciel { get; }
public int NbLicences { get; }
}
class TransitionInterditeException : GestParcException
{
public TransitionInterditeException(string de, string vers)
: base($"Passage de « {de} » à « {vers} » interdit.") { }
}
Trois bonnes habitudes :
- une classe mère commune (
GestParcException) permet d'attraper d'un coup toutes les erreurs métier de l'application, et seulement elles ; - le message est construit par l'exception elle-même, à partir de ses données : il sera identique partout ;
- des propriétés (
NomLogiciel,NbLicences) donnent au code appelant les détails dont il a besoin, sans analyser le texte du message.
Ticket t = new Ticket(12);
try
{
t.Resoudre();
}
catch (GestParcException e)
{
Console.WriteLine($"{e.GetType().Name} : {e.Message}");
}
// TransitionInterditeException : Passage de « ouvert » à « resolu » interdit.
C'est la situation de l'épreuve blanche n° 2 de l'E6 SLAM : quand le nombre d'installations atteint le nombre de licences, l'installation est refusée, et l'écran affiche un message clair.
Relancer une exception et l'envelopper
Parfois, un catch doit agir (journaliser, annuler une transaction) puis laisser l'exception continuer sa remontée. On écrit alors throw;, sans rien derrière :
static void Traiter()
{
try
{
ChargerFichier();
}
catch (InvalidOperationException)
{
Console.WriteLine("journalisé, puis relancé");
throw; // relance la MÊME exception, avec sa pile d'appels d'origine
}
}
throw e; relancerait aussi l'exception, mais en effaçant la pile d'appels d'origine : on ne saurait plus où l'erreur est née. Utilise toujours throw;.
Pour traduire une erreur technique en erreur métier, enveloppe-la : crée la nouvelle exception en lui passant l'ancienne comme InnerException.
try
{
int.Parse("douze");
}
catch (FormatException e)
{
throw new ImportException("Ligne 3 du fichier d'import invalide.", e);
}
Le code appelant reçoit une ImportException compréhensible, et le journal garde la cause exacte (e.InnerException est la FormatException).
Filtrer avec when
Une clause when ajoute une condition à un catch : il n'attrape l'exception que si la condition est vraie.
foreach (string saisie in new[] { "45", "-5", "abc" })
{
try
{
int duree = int.Parse(saisie);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(duree);
Console.WriteLine($"{saisie} : durée acceptée");
}
catch (FormatException)
{
Console.WriteLine($"{saisie} : ce n'est pas un nombre");
}
catch (ArgumentOutOfRangeException e) when (e.ActualValue is int v && v < 0)
{
Console.WriteLine($"{saisie} : durée négative");
}
}
45 : durée acceptée
-5 : durée négative
abc : ce n'est pas un nombre
Avec la saisie "0", aucun catch ne correspondrait (la valeur n'est pas négative) : l'exception remonterait.
Exception ou valeur de retour ? Le modèle TryXxx
Une exception coûte cher (la pile d'appels est capturée) et interrompt le flux. Réserve-la aux situations exceptionnelles, que le code appelant ne pouvait pas raisonnablement prévoir ou empêcher. Pour un échec attendu et fréquent, propose une méthode TryXxx qui renvoie un bool, sur le modèle de int.TryParse :
public bool TryInstaller(string numPoste)
{
if (!PeutInstaller)
{
return false;
}
postes.Add(numPoste);
return true;
}
if (!acrobat.TryInstaller("PC-0004"))
{
Console.WriteLine("PC-0004 : pas de licence, demande d'achat à créer");
}
| Situation | Choix |
|---|---|
| Saisie de l'utilisateur à convertir | TryParse : une saisie invalide est normale |
| Argument invalide passé par un autre développeur | exception : c'est un bug à corriger |
| Règle métier violée par une action de l'utilisateur | exception métier, attrapée par l'écran ; ou méthode PeutXxx() à tester avant d'agir |
| Élément cherché qui peut ne pas exister | TryGetValue, FirstOrDefault : renvoyer null ou false |
| Ressource indispensable indisponible (base, fichier de configuration) | exception |
Libérer les ressources : using et IDisposable
Un objet qui tient une ressource extérieure (fichier ouvert, connexion à la base, connexion réseau) doit la libérer, même en cas d'exception. Ces classes implémentent l'interface IDisposable et sa méthode Dispose(). L'instruction using appelle Dispose() automatiquement à la sortie du bloc, quoi qu'il arrive : c'est un try … finally écrit pour toi.
class Journal : IDisposable
{
public Journal() => Console.WriteLine("journal ouvert");
public void Ecrire(string message) => Console.WriteLine($"écrit : {message}");
public void Dispose() => Console.WriteLine("journal fermé");
}
using (Journal j = new Journal())
{
j.Ecrire("ouverture du ticket 12");
}
Console.WriteLine("après le using");
journal ouvert
écrit : ouverture du ticket 12
journal fermé
après le using
La forme courte using Journal j = new Journal(); (sans accolades) libère l'objet à la fin du bloc qui contient la déclaration. Tu l'utiliseras pour chaque connexion à MySQL : le chapitre SQL dans une application en montre des exemples.
Les bonnes pratiques
- Attrape le type le plus précis possible.
catch (Exception)n'a sa place qu'au sommet du programme, pour journaliser et afficher un message générique avant de s'arrêter proprement. - Ne corrige pas un bug par un
catch. UneNullReferenceExceptionou uneIndexOutOfRangeExceptionsignalent une erreur de programmation : corrige le code. - Sépare le message technique du message utilisateur. L'utilisateur voit « Impossible d'installer : plus de licence disponible » ; le journal garde le type, le message et la pile d'appels.
- N'affiche jamais une pile d'appels à l'utilisateur d'une application en production. Elle révèle la structure interne du programme, des chemins de fichiers, parfois des requêtes : c'est une fuite d'information exploitable. Le cours Sécurité applicative traite la journalisation et la gestion des erreurs du point de vue de la sécurité.
- N'utilise pas les exceptions pour le déroulement normal (sortir d'une boucle, tester une existence) : c'est lent et illisible.
Exercices
Exercice 1 sur 4 Suivre le flux d'exécution Réussi À revoir
Donne la chaîne renvoyée par Tester("12"), Tester("0") et Tester("abc").
static string Tester(string saisie)
{
string trace = "";
try
{
trace += "A";
int n = int.Parse(saisie);
trace += "B";
int r = 100 / n;
trace += "C";
}
catch (FormatException)
{
trace += "F";
}
catch (ArithmeticException)
{
trace += "D";
}
finally
{
trace += "Z";
}
return trace;
}
Voir la réponseMasquer la réponse de l'exercice 1
Tester("12")renvoieABCZ: aucune exception, puis lefinally.Tester("0")renvoieABDZ: la division entière par zéro lève uneDivideByZeroException, fille d'ArithmeticException, donc attrapée par le secondcatch;Cn'est jamais ajouté.Tester("abc")renvoieAFZ:int.Parselève uneFormatExceptionavantB.
Dans les trois cas, le finally s'exécute.
Tu avais trouvé ?
Exercice 2 sur 4 Une exception métier Réussi À revoir
Écris l'exception TicketIntrouvableException, levée quand on cherche un ticket par un numéro qui n'existe pas. Elle porte le numéro cherché dans une propriété et construit elle-même le message « Aucun ticket ne porte le numéro 42. ». Puis écris la méthode string Trouver(int numero) d'une classe Depot qui range les titres dans un Dictionary<int, string> et lève cette exception si le numéro est absent.
Voir la réponseMasquer la réponse de l'exercice 2
class TicketIntrouvableException : Exception
{
public TicketIntrouvableException(int numero)
: base($"Aucun ticket ne porte le numéro {numero}.")
{
Numero = numero;
}
public int Numero { get; }
}
class Depot
{
private readonly Dictionary<int, string> tickets = new() { [1] = "Écran noir" };
public string Trouver(int numero) =>
tickets.TryGetValue(numero, out string? t) ? t : throw new TicketIntrouvableException(numero);
}
d.Trouver(42) lève l'exception ; un catch (TicketIntrouvableException e) peut afficher e.Message et utiliser e.Numero. Dans GestParc, elle hériterait de GestParcException. Selon l'usage, une méthode Ticket? TrouverOuNull(int numero) serait aussi acceptable : si chercher un numéro inexistant est fréquent (une recherche saisie par l'utilisateur), l'absence n'est pas exceptionnelle.
Tu avais trouvé ?
Exercice 3 sur 4 Critiquer une gestion d'erreurs Réussi À revoir
Relève les quatre mauvaises pratiques de ce code et propose une version corrigée.
public decimal ValeurDuParc(List<Materiel> parc)
{
decimal total = 0;
try
{
foreach (Materiel m in parc)
{
total += m.ValeurNette(DateOnly.FromDateTime(DateTime.Today));
}
}
catch (Exception e)
{
Console.WriteLine(e.StackTrace);
}
return total;
}
Voir la réponseMasquer la réponse de l'exercice 3
catch (Exception)attrape tout, y compris les bugs (NullReferenceExceptionsi un élément de la liste est nul).- L'erreur est avalée : la méthode renvoie un total partiel, faux, comme si tout allait bien.
- La pile d'appels est affichée à l'utilisateur : inutile pour lui, et une fuite d'information.
- Une classe métier écrit dans la console : elle décide de l'affichage à la place de la couche de présentation, qui ne sera peut-être pas une console (formulaire, API).
Ici, rien dans la boucle ne justifie un try : si une erreur survient, c'est un bug ou une donnée invalide, et l'appelant doit le savoir. Version corrigée :
public decimal ValeurDuParc(IEnumerable<Materiel> parc, DateOnly date)
{
ArgumentNullException.ThrowIfNull(parc);
return parc.Sum(m => m.ValeurNette(date));
}
La date passe en paramètre, ce qui rend aussi la méthode testable (chapitre 15).
Tu avais trouvé ?
Exercice 4 sur 4 Quelle exception lever ? Réussi À revoir
Pour chaque situation, choisis l'exception la plus adaptée.
- Le constructeur de
Interventionreçoit une durée de −15 minutes. - On appelle
Resoudre()sur un ticket déjà résolu. - Le constructeur de
Ticketreçoitnullcomme demandeur. - Le fichier
postes.csvcontient « douze » dans la colonne du prix. - On tente d'installer un logiciel dont toutes les licences sont utilisées.
Voir la réponseMasquer la réponse de l'exercice 4
ArgumentOutOfRangeException(ThrowIfNegativeOrZero).InvalidOperationException, ou mieux l'exception métierTransitionInterditeException.ArgumentNullException(ArgumentNullException.ThrowIfNull(demandeur)).FormatExceptionlevée pardecimal.Parse, à envelopper dans uneImportExceptionqui précise la ligne ; oudecimal.TryParsepour signaler la ligne sans exception.- L'exception métier
LicencesEpuiseesException, fille deGestParcException.
Tu avais trouvé ?