Compter les moutons avec des contrats en Python

La programmation par contrat est fascinante, ce nom désigne un ensemble d'outils permettant de rendre un code fiable, facile a maintenir et simple a débugger. Une fois que vous commencez à les utiliser, ils deviennent tout simplement addictifs.

Compter les moutons avec des contrats en Python

(original english version available)

Je trouve la programmation par contrat fascinante, cette appellation désigne un ensemble d'outils permettant de spécifier le comportement de votre système, ce qui le rend plus fiable, facile a maintenir et beaucoup plus simple a débugger. Ils s’intègrent parfaitement avec les outils classiques de qualité de code (intégration continue, tests unitaires, revues de code...), et une fois que vous commencez à les utiliser, ils deviennent tout simplement addictifs.

Dans cet article, je vais expliquer comment ils fonctionnent, pourquoi ils sont géniaux, et comment les utiliser en Python grâce à un petit framework appelé dbc (dont je suis l’auteur). J’introduirai également quelques bonnes pratiques à suivre lors de leur utilisation.

Commençons par un exemple léger: compter des moutons 🐑.
Nous voulons compter le nombre d'occurrences du mot "mouton" dans une chaîne de caractères, et une fois que le dixième mouton est atteint, le programme "s’endort".

En Python, cela ressemblerait à

def count_sheeps(story : str) -> str:
    """
    Counts sheeps, and fall asleep if 10 or more are provided.

    Returns the text, up to the 10th sheep, with some snoring if the
    10th sheep was reached.
    """

L’exécution de ce code sur un texte renvoie... Eh bien, au moins une partie du texte, avec des ronflements si le texte parle de moutons, et sans ronflements dans les autres cas. Donc, ça semble fonctionner, mais je ne vais pas m’embêter à vérifier manuellement, car le texte est assez long, et je ne voudrais pas risquer de m'endormir moi-même !

Tester peut parfois sembler... peu enthousiasmant

À ce stade, je pourrais lancer des tests pour m'assurer que l'implémentation est correcte, mais faisons-le en mode design-by-contract (DbC) !

Qu’est-ce que le design-by-contract (DbC) ?

Pour tester quoi que ce soit, vous avez toujours besoin d’entrées de test et d’assertions de test. Les entrées définissent le contexte du test, et les assertions sont les règles permettant de vérifier le résultat. Cela semble assez basique, n’est-ce pas ? Maintenant, accrochez-vous ...

En DbC, c’est la fonction elle-même qui porte les assertions : elle s’auto-vérifie. Oui, vous connaissez sûrement déjà cela avec les doctests... Non, ce n’est pas du tout la même chose.

Pour effectuer ces vérifications, cette technique repose sur des éléments toujours vrais, divisés entre les préconditions : des choses qui sont toujours vraies avant que le code s'exécute, et les postconditions : des choses qui sont toujours vraies après l'exécution.

Si ces conditions sont vérifiées, on dit qu’elles sont satisfaites, et l’exécution continue sans encombre. Sinon, le programme plante avec une trace d’erreur indiquant la condition qui a échoué. C’est aussi simple que ça. Si vous connaissez les assertions, c’est basé sur les mêmes principes, mais avec quelques subtilités intéressantes !

Un contrat, engloutissant tout vos bugs avant qu'ils ne se réalisent, miam miam

Pour tester, nous avons également besoin d'entrées. Le DbC ne peut pas vraiment vous aider directement à ce niveau, mais si vous êtes intéressé par la génération d'entrées, vous pouvez jeter un œil à la technique du fuzzing 🐇.
Dans cet exemple simple, nous allons simplement générer une jolie histoire avec chatGPT.

(Nota : il est également possible de combiner les contrats et le fuzzing, ce que l'on appelle le fuzzing sémantique. Je vais laisser le lien ici, car c’est un peu trop pointu pour cet article, mais faites-moi savoir si vous souhaitez en savoir plus !)

Ajouter les conditions

Voyons maintenant comment ajouter des préconditions et des postconditions pour que le code fonctionne correctement.

Tout d'abord, j'attends qu'une chaîne de caractères soit fournie, sinon mon code plantera. Cela nous donne une première précondition :

  • isinstance(story, str)

Ensuite, si je suppose que la chaîne résultante est dans la variable result, je veux qu’elle soit une chaîne de caractères une fois l’exécution terminée. C’est une postcondition :

  • isinstance(result, str)

Enfin, si le texte donné contient moins de 10 occurrences du mot "mouton", je m’attends à ce que la fonction renvoie le texte en entier sans tomber de sommeil.

  • Si le texte contient 10 occurrences ou plus
    story.count("sheep") < 10
    • alors nous retournons l'histoire complète en restant éveillés (comme des grands)
      result == story
    • sinon c'est un peu plus complexe, le résultat doit inclure le texte avec les 9 premiers "moutons", suivi d’un ronflement :
      result.count("sheep") == 9
      result == story[: len(result) - len(snoring)]) + snoring

Voici ce que cela donne avec le framework dbc :

@precondition(lambda story: isinstance(story, str))
@postcondition(lambda story, result: result.count("sheep") < 10)
@postcondition(
    lambda story, snoring, result: result == story
    if story.count("sheep") < 10
    else result.count("sheep") == 9 and result == story[: len(result) - len(snoring)]) + snoring
)
def count_sheeps(story: str, snoring: str = "\nZzzz...") -> str:

Aparté : aide sur la syntaxe

Le framework déclare les décorateurs (decorators) @precondition et @postcondition, c'est un moyen pratique d’ajouter du code autour d’une fonction existante sans en modifier directement l’implémentation, tout en exprimant les conditions à vérifier à l’aide d'expressions lambda (lambda expressions).

Cela peut sembler un peu complexe si vous n’êtes pas familier avec la syntaxe fonctionnelle / d’expression de Python ou les fonctions anonymes de JavaScript. Prenons donc un moment pour décomposer un peu cette syntaxe :
@precondition(lambda story: isinstance(story, str))
J'utilise ici @precondition, avec le @ qui indique qu'il s'agit d'un décorateur. Cela signifie que le paramètre entre parenthèses est la fonction de précondition. Cette fonction lambda story: isinstance(story, str) est écrite sous forme d’une expression lambda : une fonction anonyme (qui n'a pas de nom propre), prenant comme paramètres ce qui se trouve avant les deux-points :, ici story, et renvoyant ce qui suit, à savoir isinstance(story, str).

Nous vérifions donc la première condition que nous avons définie : story est une instance de str.
Le fonctionnement est simple : à chaque appel de count_sheeps, le framework dbc va d'abord appeler cette expression lambda avec la valeur réelle du paramètre story, et agira en conséquence : si story est bien une chaîne de caractères (str), c'est conforme aux attentes, rien à signaler 👮🏻, l'appel peut avoir lieu. Sinon, c'est un bug, et le framework va alors s'en occuper.

Vous remarquerez également qu'il peut y avoir un paramètre result dans la post-condition, ce paramètre correspond à la valeur que la fonction a renvoyée. C'est aussi simple que ça.

Il existe également un paramètre old, que je n'ai pas utilisé ici, mais qui contient la valeur des paramètres avant l’appel de la fonction. Cela peut être utile pour des fonctions censées modifier un objet (par exemple, ajouter un élément à une liste list_of_things, on vérifiera alors la valeur de list en sortie comparée a celle en entrée).

Retour au DbC

Que se passe-t-il si les conditions ne sont pas respectées, me demanderez-vous ?
Chaque fois qu'une fonction est appelée, il y a 3 étapes :

  1. La pré-condition est vérifiée. Comme elle teste les arguments avant l’exécution, si une pré-condition échoue, cela signifie que la fonction appelante est en faute.
  2. L’appel est effectué, la fonction prend des entrées correctes et calcule un résultat.
  3. La post-condition est vérifiée – et voici le point crucial : si la pré-condition est respectée, la post-condition doit l’être aussi, sinon cela signifie que l’implémentation est en faute.

Voyons cela en action :

count_sheeps(10) # oh no, it’s not a string!
AssertionError: **caller** of count_sheeps() bug: precondition 'isinstance(story, str)' failed: story = 10
def count_sheeps(story: str, snoring: str = "\nZzzz...") -> str:
   return snoring # early sleeper, heh
AssertionError: count_sheeps() bug: postcondition 'result == story' failed: result = '\nZzzz...', snoring = '\nZzzz...', story = '\nIn a quiet [...]p.\n\nTHE END'

Dans les deux cas, le framework fait plusieurs choses pour vous aider à déboguer le code :

  • Il identifie d’où vient le bug : du contexte appelant ou de l’implémentation de la fonction.
  • Il affiche la condition exacte qui a échoué.
  • Il affiche les entrées fournies, afin que vous puissiez déboguer la cause de l’échec vous-même.

NB : Cela constitue en soi un jeu de tests valide pour l'intégration dans les tests unitaires !

Maintenant, si vous voulez suivre, retracer et déboguer toutes les erreurs de votre application, existe-t-il plus simple et efficace à utiliser que ce framework ?!

Illustration: Mon enfant intérieur quand le DbC me donne plein de bugs faciles à identifier et reproduire

Vue d’ensemble
Prenons du recul et passons en revue ce que j’ai discuté jusqu’à présent, d’un point de vue plus global. Les contrats peuvent être considérés comme des morceaux de documentation vivants et exécutables. Si vous regardez leur utilisation ci-dessus, vous verrez qu’il y a peu de choses qu’un commentaire pourrait ajouter concernant la manière d'utiliser cette fonction. En revanche, un commentaire peut se concentrer sur le pourquoi (ding ding ding 🔔 bonnes pratiques de développement logiciel !).

Vous remarquerez également que je n’ai pas inclus l’implémentation. En fait, vous n’en avez même pas besoin pour comprendre ce qui est fait : l’implémentation représente le comment. Pour autant que nous le sachions, elle pourrait transmettre l’histoire à quelqu’un, qui la lira à haute voix à un jeune enfant (ou à un enfant plus âgé, essayez avec un jeune parent de 30 ans et plus, ça devrait aussi fonctionner) et nous avertir lorsque l’enfant est profondément endormi.

L’implémentation n’a plus vraiment d’importance, ce qui en fait des contrats un outil parfait pour les profils plus... matheux, comme dans cette blague : un mathématicien voit une poubelle en feu dans la rue, il se dit : "Ah, pas intéressant, je sais comment résoudre ce problème", et poursuit son chemin sans éteindre le feu.

Enfin, les contrats se prêtent naturellement au passage à l'échelle, au moyen d'une technique qui porte le nom informel de percolation, et c’est là que réside une grande force de cette pratique.

Perso j'ai été conquis sitôt que j'ai entendu une métaphore sur le café

Percolation de conditions

La percolation se produit lorsque vous avez des contrats autour d’une fonction que vous appelez. Par exemple, si vous avez une fonction appelante call_count_sheep qui appelle count_sheep, quels devraient être les contrats de cette fonction ? Eh bien, cela dépend de ce qu’elle fait, mais si elle appelle count_sheep, vous pouvez déjà en identifier certains : ce sont les mêmes que ceux de count_sheep ! Le simple fait de les copier-coller fonctionnera. Ou vous pouvez les refactoriser si vous ne voulez pas copier-coller partout. Après tout, ce n’est que du code !

@precondition is the same as count_sheep!
@postcondition is the same as count_sheep!
def call_count_sheep(story : str) -> str:
   ... # unrelated work
   return count_sheep(story)

Conclusion

Voici quelques avantages supplémentaires à cette manière d’utiliser le DbC :

  • Ce code est exécutable, donc l’utilisateur peut vérifier la pré-condition avant de provoquer une erreur (et c’est d’ailleurs une bonne pratique !)
  • L'implémentation et la syntaxe sont en pur Python, donc n’importe quel utilisateur pourra la comprendre “sans apprentissage préalable” (pas de syntaxe personnalisée et bizarre à apprendre – coucou doctest 👀)
  • Le code est “toujours” à jour, parce qu’il est “toujours” vérifié (en réalité, pour des raisons de performance, vous voudrez désactiver la plupart des vérifications en production, mais j'en parlerai une autre fois 😜)
  • Les tests unitaires s’écrivent pratiquement tout seuls (si ce n’est pas clair pourquoi, ne vous inquiétez pas, j’entrerai dans plus de détails dans un autre post)

C’est tout, voilà tout ce que cela fait. Mais cela peut déjà sembler un peu magique si vous n’êtes pas habitué aux arcanes du codage fonctionnel Python et à la réflexion, mais heureusement tout cela est bien enveloppé dans le framework dbc, et c’est tout ce qui compte.

Dans un prochain post, j’expliquerai comment j’ai implémenté tout cela en utilisant de la magie noire en python, et comment le DbC peut être utilisé en parallèle de vos techniques de développement habituelles pour produire d’énormes gains de productivité durant le codage et les tests unitaires.

Je parlerai aussi de l’intégration facilitée des tests unitaires grâce à cela, et comment l’utiliser sur des API complexes et mal documentées (coucou python-gitlab et l’API Google Cloud 👀) pour aider à les découvrir et à les documenter en interne. Dites-moi si cela vous titille l’esprit 😉

Ceci est mon premier véritable article de blog, donc je serais ravi d’avoir vos retours et je répondrai avec plaisir à toute question ou commentaire que vous pourriez avoir, alors n’hésitez pas à les poster ci-dessous ou à me les envoyer directement (contact en bas de page).

Bonne peinture en code !

Quelques liens pour référence

Framework DbC

Léo GERMOND / python-dbc · GitLab
GitLab.com

Le framework Python pour du design-by-contract

Concepts logiciels

Programmation par contrat — Wikipédia
Réflexion (informatique) — Wikipédia

Concepts en python

7. Decorators — Python Tips 0.1 documentation
Primer on Python Decorators – Real Python
In this tutorial, you’ll look at what Python decorators are and how you define and use them. Decorators can make your code more readable and reusable. Come take a look at how decorators work under the hood and practice writing your own decorators.

Un aperçu beaucoup plus en profondeur

What is the purpose of Lambda expressions?
This is a typical example for beginners: x=lambda n:2*n print(x(7)) Otherwise I would create a function: def dbl(n): return 2*n print(dbl(7)) Of course: I can write simply 2*7, but the idea is to save a complex formula in an object once, and reuse it several times. In this case the Lambda expression saved me one line, and one of the characteristics of Python is that the code is short, clear and elegant; but are there more advantages to the Lambda expressions apart of it? In performance…

Autre ressources

Semantic fuzzing with zest [ISSTA 2019] sur le site de l'ACM