Ouvrez un fichier qui n’existe pas en Python, open() lève un FileNotFoundError qui remonte hors de votre fonction. Faites la même chose en Go, os.ReadFile vous renvoie une valeur error accompagnée d’un slice d’octets nil. Même échec, deux formes de code complètement différentes autour. La différence n’est pas cosmétique. Elle décide si l’appelant peut ignorer l’échec par accident, et si le compilateur le repère quand ça arrive.
Qu’est-ce que les exceptions et les valeurs d’erreur font réellement différemment ?
Une exception est un flux de contrôle qui saute. Quelque chose échoue, la fonction lève, et l’exécution quitte la pile d’appels en cours pour atterrir dans le premier bloc catch ou except correspondant, peu importe le nombre de niveaux au-dessus. La signature de la fonction ne donne aucun indice que cela puisse arriver. loadConfig(path: string): Config en TypeScript ressemble exactement à une fonction qui ne peut pas échouer, jusqu’à ce qu’elle lève une exception pour un fichier manquant.
Une valeur d’erreur est une donnée. La fonction la renvoie en plus, ou à la place, du résultat demandé. Le (Config, error) de Go, le Result<Config, ConfigError> de Rust, et une recherche qui renvoie null en l’absence de résultat sont la même idée : l’échec fait partie du type de retour, donc l’appelant le voit directement au point d’appel. En Rust, le système de types vous oblige à le traiter avant de toucher au résultat. En Go, c’est la convention qui s’en charge.
Aucune des deux approches n’est nouvelle, et aucune n’a gagné. C renvoyait des valeurs sentinelles comme -1 et NULL ; les exceptions sont arrivées plus tard, pour sortir la gestion des erreurs de la logique principale. Python, Java, C# et JavaScript utilisent les exceptions par défaut. Go et Rust utilisent les valeurs d’erreur par défaut. La plupart des langages qui en adoptent une par défaut vous laissent tout de même recourir à l’autre quand elle convient mieux.
Comment Go renvoie-t-il les erreurs comme des valeurs ?
Go n’a pas d’exceptions pour les échecs ordinaires. Une fonction qui peut échouer renvoie une error comme dernière valeur, et l’appelant la vérifie immédiatement :
| |
%w enveloppe l’erreur d’origine au lieu de la jeter, ce qui permet à errors.Is et errors.As plus haut dans la pile de vérifier ce qui s’est passé sans comparer des chaînes de caractères dans le message.
Le point faible : rien ne vous empêche d’écrire data, _ := os.ReadFile(path) et de jeter l’erreur. Le compilateur l’accepte sans broncher. Un linter comme errcheck repère les erreurs non vérifiées, mais seulement s’il est intégré à votre CI.
Comment les exceptions Python sautent-elles vers un gestionnaire ?
Le même chargeur de configuration en Python n’a pas de vérification d’erreur en ligne après chaque appel. La vérification se fait autour de l’appel, pas à l’intérieur :
| |
raise ... from e garde le traceback d’origine accroché au nouveau ConfigError, de la même façon que %w en Go garde l’erreur d’origine accessible.
La différence qui compte, c’est la signature. load_config ne dit jamais qu’elle peut lever une exception. Un appelant trois niveaux plus haut qui a oublié le try le découvre quand le programme plante avec un traceback en production, parce que rien dans le langage ne vérifie le point d’appel.
Comment le Result de Rust vous oblige-t-il à traiter l’erreur ?
Rust garde l’idée de l’erreur comme valeur et ajoute un système de types qui ne vous laisse pas toucher la valeur de succès sans reconnaître le cas d’échec :
| |
Cela nécessite serde (avec la feature derive) et serde_json dans Cargo.toml.
Result<T, E> est un enum à deux variantes, Ok(T) et Err(E), et vous ne pouvez pas obtenir une Config sans traiter les deux. L’opérateur ? est un raccourci pour ce match : si fs::read_to_string renvoie Err, load_config ressort immédiatement avec cette erreur, mappée en ConfigError::Io. Sinon, il déballe le Ok et continue.
Vous pouvez toujours contourner tout cela avec .unwrap(), qui panique sur Err. C’est la version Rust d’une exception non capturée. Traitez cet appel comme quelque chose à questionner en revue de code, pas comme un raccourci.
Pourquoi fetch() ne lève-t-il pas d’exception sur un 404 ?
La séparation ne se fait pas seulement entre langages. Une seule API peut utiliser les deux modèles pour des échecs différents. fetch() rejette sa promise en cas d’échec réseau (le DNS ne résout pas, la connexion est refusée) mais se résout normalement sur un 404 ou un 500. Le statut HTTP est une valeur sur la réponse que vous avez déjà : vous vérifiez response.ok, vous n’attrapez rien. Gestion des Erreurs avec la Fetch API en JavaScript explique pourquoi cela surprend ceux qui s’attendent à ce qu’une erreur HTTP lève une exception, et comment envelopper fetch() pour retrouver un comportement façon exception si vous le voulez.
Quand utiliser les exceptions, et quand renvoyer des erreurs ?
Posez-vous une question sur l’échec précis : l’appelant immédiat doit-il agir dessus, ou devrait-il remonter jusqu’à ce qu’une frontière le gère ?
Un cache miss ou une erreur de validation sur une entrée utilisateur est un résultat que votre code devrait prévoir et sur lequel brancher immédiatement. Une valeur d’erreur convient. L’appelant ne peut pas faire son travail sans réagir, donc un type de retour qui rend l’échec visible est une aide, pas une formalité.
Une connexion à une base de données qui tombe en pleine transaction, ou une assertion sur l’état du programme qui se révèle fausse, c’est différent. Rien de ce que fait l’appelant immédiat ne va arranger ça. Laisser ça se propager sous forme d’exception ou de panic jusqu’à un seul endroit qui enregistre le problème et s’arrête proprement est moins de code, et un code plus honnête, que de le faire traverser manuellement chaque frame.
Les langages qui adoptent un modèle par défaut utilisent déjà l’autre là où ça convient. Go panique sur un index de slice hors limites, parce que c’est un bug, pas une condition que l’appelant devrait gérer. Rust panique sur un dépassement d’entier dans les builds de debug. Le dict.get(key, default) de Python renvoie une valeur plutôt que de lever KeyError, pour le cas où une clé manquante est attendue plutôt qu’exceptionnelle.
Qu’est-ce qui va mal avec chaque stratégie en pratique ?
Erreurs avalées. Un except: vide qui ne fait rien, ou un if err != nil { } en Go avec un corps vide, efface l’échec. Le symptôme apparaît plus tard sous forme de données manquantes ou d’écran vide, sans aucune erreur près de la cause réelle. C’est le flux difficile à tracer décrit dans Qu’est-ce que le Spaghetti Code et Comment le Corriger.
Panics dans la mauvaise goroutine. recover() ne fonctionne que dans la goroutine qui a paniqué. Un recover différé dans main ne vous sauve pas d’un panic dans une goroutine worker ; tout le programme s’arrête. Goroutines et Channels en Go : Guide avec Exemples montre pourquoi les erreurs issues du travail concurrent devraient revenir par un channel, comme des valeurs.
.unwrap() et un catch générique au lieu d’une décision. Les deux vous permettent d’arrêter de penser au cas d’échec sans vraiment le faire disparaître. Écrivez un test qui force le chemin d’erreur. C’est bien plus facile quand la fonction renvoie une erreur que vous pouvez construire vous-même que quand elle la lève depuis l’intérieur d’une bibliothèque que vous ne contrôlez pas, et c’est pour ça que les chemins d’erreur ont leurs propres tests dans Tests Unitaires vs Tests d’Intégration vs Tests End-to-End.
Comment choisir par fonction, pas par langage
La prochaine fois que vous êtes sur le point d’écrire throw ou return err, regardez la fonction devant vous. Si son appelant doit réagir immédiatement, mettez l’échec dans le type de retour : (T, error) en Go, Result en Rust, None ou une valeur par défaut en Python. Si rien d’utile ne peut se produire avant que l’échec n’atteigne un gestionnaire de plus haut niveau, levez une exception ou paniquez et gérez-le une seule fois, à cette frontière. Ajoutez ensuite un test pour le chemin d’erreur, quel que soit celui que vous avez choisi.