L’anxiété de l’assertion
La plupart des codebases de production se divisent en deux camps. Le camp A traite assert comme un assaisonnement décoratif, en saupoudrant une ligne sur deux jusqu’à ce que la fonction ressemble à un contract juridique rédigé par un avocat paranoïaque. Le camp B traite les assertions comme des roulettes d’entraînement réservées au développement, les supprimant toutes au moment du build et espérant que le code fonctionnera en production parce que les tests ont réussi une fois.
Les deux camps ont tort. La question n’est pas de savoir s’il faut assert. La question est de savoir ce qu’une assertion signifie réellement.
Une assertion n’est pas de la gestion d’erreur. Ce n’est pas de la validation d’entrée. Ce n’est pas une suggestion polie. Une assertion est l’affirmation que quelque chose est impossible. Si l’assertion se déclenche, votre modèle mental du programme est brisé. Cette distinction détermine tout de l’endroit où les assertions doivent se trouver et du nombre que vous devriez écrire.
Les assertions concernent les invariants, pas les erreurs
Quand un utilisateur transmet un âge négatif à votre API, c’est une erreur. Les erreurs sont attendues. Les erreurs méritent une véritable gestion, du logging et des messages destinés aux utilisateurs. Quand votre calcul interne produit un nombre négatif de lignes de base de données après une requête supposément réussie, c’est une violation d’invariant. Cela ne devrait jamais arriver. C’est pour cela que les assertions existent.
Cela paraît évident jusqu’à ce que vous lisiez du code de production. J’ai vu des fonctions qui assertent qu’une chaîne est non vide, puis trois lignes plus loin vérifient if (!str) et lancent une exception formatée. Le développeur a utilisé les deux outils pour la même condition parce qu’il n’a jamais décidé lequel était le vrai contract.
Voici la règle. Si la condition peut être déclenchée par une entrée externe, ce n’est pas une assertion. Si elle ne peut être déclenchée que par un bug dans votre propre code, c’en est une.
def process_payment(user_id: str, amount_cents: int) -> Receipt:
# NOT an assertion. Users or upstream services can send bad data.
if amount_cents <= 0:
raise ValueError("amount_cents must be positive")
# NOT an assertion. The user_id comes from the outside world.
if not user_id:
raise ValueError("user_id is required")
receipt = _charge_card(user_id, amount_cents)
# THIS is an assertion. If charge_card returned None after
# succeeding, our understanding of the universe is wrong.
assert receipt is not None, "charge_card succeeded but returned None"
# THIS is an assertion. A receipt with zero items after a
# successful charge means our internal logic is broken.
assert len(receipt.items) > 0, "receipt has no items after successful charge"
return receipt
Les deux premières vérifications gardent la boundary. Les deux dernières gardent la cohérence interne du système. Les mélanger crée de la confusion sur qui est responsable de quoi.
Le plafond des trois assertions
Si vous vous surprenez à écrire plus de trois assertions dans une seule fonction, vous avez l’un de ces deux problèmes. Soit votre fonction en fait trop, soit vos invariants sont trop vagues pour être appliqués.
Une fonction avec douze assertions n’est pas défensive. Elle est incertaine. L’auteur ne fait confiance ni au code qui l’appelle, ni au code qu’elle appelle, ni aux données qui circulent entre eux. Cette incertitude devrait être résolue par un refactor, pas en ajoutant plus d’instructions assert.
La limite pratique vient de ce qu’un développeur peut garder dans sa tête. Une fonction devrait avoir un contract clair. Ce contract implique un petit nombre d’invariants. Si vous avez besoin d’une douzaine d’assertions pour vous sentir en sécurité, votre fonction a probablement absorbé des responsabilités qui appartiennent ailleurs.
Divisez la fonction. Extrayez la partie qui transforme les données. Extrayez la partie qui appelle les services externes. Donnez à chaque fonction extraite son propre petit ensemble d’invariants. Trois assertions par fonction est un voyant d’alerte. Cinq est un pneu crevé.
Assertions en production : activer ou désactiver ?
Différents langages font différents choix. Python supprime les instructions assert quand vous exécutez avec le flag -O. Les compilateurs C et C++ retirent couramment les assertions dans les builds de release. JavaScript n’a pas du tout d’assert intégré. Vous devez soit le polyfill, soit utiliser une bibliothèque qui reste active en production.
Cela crée un véritable dilemme. Si vous supprimez les assertions, vous perdez le filet de sécurité exactement au moment où vous en avez le plus besoin. Les bugs qui n’apparaissent qu’en production corrompront silencieusement les données au lieu de faire un fail fast. Si vous les gardez, vous risquez de faire crasher un processus de production à cause d’une condition qui, bien que théoriquement impossible, n’est pas réellement fatale.
La réponse dépend du coût de la continuation. Si violer l’invariant signifie que l’opération suivante corrompra la base de données ou fera fuiter des données sensibles, l’assertion devrait faire crasher le processus. Un arrêt brutal vaut mieux qu’une violation silencieuse. Si violer l’invariant signifie une entrée de log légèrement erronée ou un petit problème d’interface, loggez-la et continuez.
// This should probably crash. Continuing with a null user
// after auth succeeded is a security hole waiting to happen.
assert(user !== null, "auth middleware returned null user after success");
// This should probably not crash. A stale cache timestamp
// is annoying but not dangerous.
if (cache.timestamp > Date.now()) {
logger.warn("cache timestamp is in the future, ignoring");
}
Tous les invariants ne méritent pas la même posture. Apprenez à faire la différence entre « cela doit s’arrêter » et « c’est bizarre mais survivable ».
Ce que nous avons essayé et qui n’a pas fonctionné
Au début d’un projet, nous avons essayé d’affirmer chaque précondition de fonction. Chaque argument était vérifié pour null, type, range et format. Le résultat était prévisible. Les tests réussissaient magnifiquement. La production a crashé la première fois qu’une API tierce a renvoyé un champ sous forme de chaîne au lieu d’un nombre.
Le problème n’était pas l’assertion. Le problème était que nous affirmions des données hors de notre contrôle, puis compilions avec les assertions activées en production. Une réponse externe malformée a tué notre processus au lieu d’être nettoyée et gérée. Nous avions construit un système internement cohérent et externement fragile.
Nous avons appris à séparer la boundary de l’intérieur. À la boundary, parsez et validez agressivement. Convertissez le chaos externe en certitude interne. À l’intérieur de la boundary, affirmez les invariants qui définissent cette certitude. Les assertions sont restées. La validation d’entrée a été déplacée vers des fonctions de parsing explicites qui retournaient des types Result au lieu de throw.
Une checklist pratique
Avant d’ajouter une assertion, parcourez cette liste :
- Une entrée externe peut-elle déclencher cela ? Si oui, utilisez la validation, pas l’assertion.
- Si cela se déclenche en production, le processus devrait-il s’arrêter ? Si non, loggez un avertissement à la place.
- Cette fonction a-t-elle déjà trois assertions ou plus ? Si oui, envisagez un refactor avant d’en ajouter une autre.
- Cette assertion aura-t-elle encore du sens pour quelqu’un lisant le code dans six mois ? Les assertions obscures se font supprimer lors des refactors. Les claires survivent.
Les assertions sont un outil de communication autant qu’un outil de sécurité. Elles disent au prochain développeur : « cette condition est impossible par design. » Si la condition n’est pas réellement impossible par design, l’assertion ment. Et les mensonges dans le code de production coûtent cher.
FAQ
Devrais-je assert sur les arguments de fonction ?
Seulement si l’appelant est également votre code et que l’argument est le produit d’une logique interne, pas d’une entrée externe. Les fonctions d’API publiques devraient valider. Les fonctions d’aide privées peuvent affirmer des invariants sur les valeurs qu’elles reçoivent.
Et TypeScript ? Il attrape déjà les nulls à la compilation.
Le système de types de TypeScript est une puissante couche d’assertion, mais il disparaît au runtime. Utilisez-le pour tout ce que le compilateur peut prouver. Ajoutez des assertions runtime pour les lacunes : réponses d’API, données désérialisées, et tout cast as qui contourne le type checker.
Les assertions nuisent-elles aux performances ?
Dans la plupart des langages, une assertion bien placée coûte quelques microsecondes. Si vous assertez à l’intérieur d’une boucle serrée traitant des millions d’éléments, déplacez l’assertion hors de la boucle. Vérifiez l’invariant sur le lot, pas sur chaque élément.
Devrais-je écrire des fonctions assert personnalisées ?
Seulement quand le message d’assertion intégré serait inutile. Une assertNonEmpty personnalisée qui affiche la longueur réelle du tableau est plus utile qu’un assert len(items) > 0 générique qui crashe sans contexte. Gardez-les petites. Ne construisez pas un framework d’assertion.