Programmation par contrat avec assert


Le mot clé assert est populaire en Python essentiellement grâce à la lib pytest, dont on vous a parlé dans le dossier sur les tests unitaires.

En dehors de ce cas d’usage, personne ne comprend bien son utilité.

Déjà, dans les tutoriaux, on vous signale de ne pas l’utiliser pour faire des vérifications importantes, à cause d’une particularité d’assert: il peut disparaitre à tout moment !

En effet, si vous lancez Python toutes voiles dehors avec l’option -o (pour “optimize”), les lignes contenant ce mot clé sont ignorées.

Alors, mille millions de mille sabords, pourquoi se faire chier à avoir ajouté ce truc ?

A quoi un machin qui peut disparaitre à tout instant peut-il bien servir ?

Well, it’s not a bug, it’s a feature, my dear.

Voyez-vous, on connait souvent le premier effet kisskool d’assert:

    assert condition

Et si la condition est fausse, on se tape une exception AssertionError.

En revanche, ce que les gens savent moins, c’est qu’il existe une seconde forme, beaucoup plus utile:

    assert condition, "Message en cas d'erreur"

Qui fait tout pareil, mais permet de donner un feedback a qui tombe sur l’erreur susnommée.

Et c’est là que c’est intéressant: parce que ça vous permet de mettre des vérifications complexes dans le code, qui vont permettre aux devs d’éviter les âneries. C’est ce qu’on appelle un contrat.

Imaginez une fonction qui prend en paramètre une valeur, qui est la clé d’un dictionnaire:

    ARTIBUSES = {
        'truc': 1,
        'bidule': 1,
        'machin': '2',
        'chose': '3',
        'chouette': '3',
        'foo': '4',
        # ...
    }
 
    def souquer_les_artibuses(artibuse):
 
        target = ARTIBUSES[artibuse]
        # un code de souquage professionnel s'ensuit

Si quelqu’un utilise votre code, et insère la mauvaise artibuse (le vil flibustier !), notre fonction va (s’)échouer sur une KeyError. Ceci va obliger le contrevenant (le perfide faquin !) à regarder dans le code source et comprendre le code pour trouver la source de son désarroi. Quelle perte de temps pour notre dev (le fils de pute !).

Une solution est alors de mettre:

    def souquer_les_artibuses(artibuse):
        if artibuse not in ARTIBUSES:
            raise ValueError(
                f"'{artibuse}'' n'est pas une artibuse valide. "
                f"Utilisez une valeur parmi: {', '.join(ARTIBUSES)}"
            )
        target = ARTIBUSES[artibuse]

Et c’est certes une bonne solution, claire et explicite.

Mais elle a un défaut majeur: à chaque appel, on rajoute le poids d’un test qui n’a aucun intérêt pour le fonctionnement normal du programme. En fait, la majorité des appels de cette fonction se feront avec les bonnes valeurs (logique sinon le code planterait), et donc notre test est un poids superflu.

Ce problème se cumule quand les tests deviennent plus nombreux, plus complexes, et plus lourds, tandis que la fonction est appelée de plus en plus de fois.

Comment concilier donc son besoin impérieux d’aider son prochain, qui est probablement soi-même un vendredi à 3h du mat après un push qu’on n’aurait pas du faire en fin de semaine, et éviter ce gâchis de ressource ?

Ventre-saint-gris de sa race ! Avec assert bien entendu !

 
    def souquer_les_artibuses(artibuse):
 
        assert artibuse in ARTIBUSES, (
            f"'{artibuse}'' n'est pas une artibuse valide. "
            f"Utilisez une valeur parmi: {', '.join(ARTIBUSES)}}"
        )
 
        target = ARTIBUSES[artibuse]

En dev, ou quand on debug en production, on obtient toutes les vérifications et infos nécessaires. Le contrat à remplir avec notre fonction est vérifié. On peut mettre autant de clauses à notre contrat qu’on le souhaite, aussi lourdes qu’on veut !

Quand on est prêt à relancer la prod en mode propre, on exécute tout avec -o

Les vérifications faites en amont d’une fonction sont ce qu’on appelle des pré-conditions dans le jargon de la programmation par contrat. Ce sont les plus faciles à comprendre et les plus courantes. On peut néanmoins faire également des vérifications à la fin de la fonction, qui permette de vérifier qu’on est bien dans un état cohérent à la sortie de celle-ci.

C’est ce qu’on appelle des post-conditions: c’est moins courant, et moins facile à écrire, mais très puissant car on se souci d’un état final (est-ce que mon résultat est > 3 ?), et non de comment on y arrive. Un moyen bien plus souple d’attraper des erreurs plutôt que de chercher toutes les combinaisons de ce qu’on peut mal faire.

Effets secondaires de -o

Le saviez-vous ? Python vient aussi avec une variable magique, __debug__, qui est à True par défaut. Utiliser -o met cette variable a False. Plus fort encore, toute condition sur __debug__ sera retirée du code !

if __debug__:
    un_dump_log_de_porc()

Pouf, avec -o, plus de dump.

Il existe même -oo pour retirer en plus les docstrings, et économiser un peu de mémoire.

Le problème avec assert

Le problème avec assert, c’est vous. Enfin je dis vous, le vous du passé, celui qui ne savait pas. En effet, plein de devs vont utiliser des assert dans leur code sans penser à mal. Et vous arrivez avec votre -o, et boom, le code est tout cassé. C’est bien con de ne pas pouvoir stripper ses assert à soi sous prétexte qu’une dépendance est codée par un connard Bachi-bouzouk.

Or assert est très souple, et permet de tester littéralement n’importe quoi. Du coup, cela demande un peu d’expérience pour savoir quand l’utiliser, et quand ne pas le faire.

Le concept général est: si votre erreur concerne la fonctionnalité fondamentale de votre fonction, levez toujours une exception. Si en revanche vous testez si les valeurs des paramètres correspondent à quelque chose de sain, et qu’une erreur n’aurait pas d’effet de bord, vous pouvez (ce n’est pas du tout obligatoire, une exception normale reste un usage correct) utiliser assert.

Donc, n’utilisez pas assert pour tester la logique de votre programme, ni pour protéger l’intégrité de vos données face à une erreur.

Une dernière chose: n’utilisez pas assert pour fournir un contrat basé sur les types (comme par exemple, appeler isinstance()), puisque mypy et les types hints le font déjà et qu’ils sont maintenant très agréables à utiliser.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.