Les contrats en pratique - Dessiner un SVG

Suite de ma série de billets de blog. Cette fois-ci j'utilise les contrats pour être productif rapidement et appréhender facilement une librairie complexe 🏎️

Les contrats en pratique - Dessiner un SVG

Ce post est la suite de Compter les moutons avec des contrats en Python

Les contrats sont une technique de code, qui nécessite de la pratique pour être bien compris. Dans ce post, je vais détailler des méthodes permettant une mise en place rapide et maintenables à long terme.

Nous allons faire quelque chose de nouveau: nous allons dessiner avec notre code. Je vais utiliser la librairie Pycairo pour générer un SVG. Ce post est une excuse pour aborder la mise en pratique des contrats concrètement tout en s'amusant. Pour une difficulté supplémentaire, je ne connais pas Pycairo donc je vais me servir des contrats pour faciliter ma découverte de l'API et sa prise en main.

Méthodologie

J'utilise un prototype sans aucun code de test: c'est le code lui-même qui sera mon test: si il tourne alors il marche. On appelle souvent cela un smoke test ("Si ça fait de la fumée alors ça tourne") et ils ont assez mauvaise réputation puisqu'ils se contentent souvent de regarder la sortie, sans tester le code lui même. En utilisant des contrats, j'ajoute cependant beaucoup d'assertions, comme autant de mini tests, qui vont guider mon développement à coup de messages d'erreur. Il ne s'agit ni plus ni moins que d'une technique de travail sous contrainte.

Schématiquement, je corrige le code à chaque erreur, jusqu'au code final

Dans ce contexte, les contrats peuvent être compris comme des garde-corps: ils sont là pour m'orienter rapidement et efficacement vers le bon résultat.

Avant de démarrer, un point très haut niveau: la mise en place initiale des contrats a un coût en efforts et en temps. C'est un investissement, de la même manière que cela demande du temps de documenter le code, de le tester, ou de vérifier qu'un algorithme fonctionne correctement. Il s'agit donc de rendre cet investissement le plus rentable possible, en réduisant l'effort associé et en augmentant leur effet de levier. C'est pourquoi dans cet exemple je m'attache a utiliser des techniques qui fournissent un bon retour sur investissement, sans qu'ils soient exhaustifs (ce qui serait nécessaire pour une preuve formelle).

Le juste milieu que j'ai trouvé est assez qualitatif, après tout du moment ou on a une belle image on peut s'estimer content. Cependant dans un contexte professionnel, il n'est pas rare qu'un tel programme soit réemployé, alors j'ai considéré que cette approche de "prototype contractualisé" constitue ce juste milieu.

Architecture générale

Puisque Pycairo est basé sur un ensemble limité et documenté de classes, j'ai choisi une approche basée sur l'héritage de classe pour mon interface "bas-niveau".

La classe mère, que j'appelle aussi classe implémentation, est celle que me fournit Pycairo. La classe fille, que j'appelle classe spécification est celle que j'implémente: elle hérite de la classe mère et rajoute des méthodes avec des contrats.

L'ensemble des conditions est stockée dans des parties du code que je nomme spécification. On l'appelle souvent interface mais je trouve ce terme un peu vague dans ce contexte. Si on y regarde de plus près cette partie du code n'est autre qu'un formalisation exécutable de la spécification. J'emploierai donc ce terme de spécification pour en parler. Dans le cas de la spécification écrite s'il y en a une, je l'appelle souvent spécification papier pour la distinguer.

Par ailleurs quelques liens si il y a un peu de flou dans ces définitions de spécification exécutable, spécification papier, et cahier des charges.

Note: PycairoSpec ne recouvre que la partie de Pycairo que j'utilise

Notez que la spécification est séparée de l'implémentation: il y a une séparation nette entre la partie contrat et la partie implémentation. On peut rapprocher cela de la notion d'interface en programmation orientée objets: les contrats décrivent le comportement attendu tandis que le code PyCairo décrit le comportement de l'implémentation.

On peut également faire un lien avec le concept d'encapsulation: les contrats font partie de l'interface externe, et ignorent tout des données internes.

Un paradoxe: La classe fille ajoute des conditions sur les entrées: elle rejette donc certaines entrées qu'acceptait la classe mère. Ceci contredit un principe important: le principe de substitution de Liskov (LSP).

📗
Le principe de substitution de Liskov est un principe important (mais controversé) en programmation. Il forme le "L" de l'acronyme SOLID.
Le principe spécifie que pour toute propriété positive du système parent, le système qui en hérite doit remplir la même propriété.
En langage commun: partout ou la classe parente peut être utilisée, je dois pouvoir utiliser la classe fille sans changer le code utilisateur - donc je dois pouvoir faire une substitution.
Par exemple: un chat est un animal, et un animal peut marcher, donc dans un code faisant marcher un animal je dois pouvoir le remplacer directement par un chat (héritant de la classe animal).
Maintenant pour la controverse: un chat qui dort ne peux pas marcher. Il ne s'agit donc plus d'un animal selon le LSP car il n'est plus substituable.

Dans notre cas c'est un faux paradoxe: les entrées qu'accepte la classe mère mais que rejette la classe fille ne respectent pas les contrats: elles sont donc invalides, et c'est un défaut de la classe mère de les avoir acceptées en premier lieu. En d'autres termes, la classe mère peut être substituée par la classe fille sans altérer ses propriétés désirables du système (les seules qui nous intéressent).

J'ai encapsulé une partie de Pycairo dans des objets Spec, ça peut être considérée peu pythonic car le Zen de Python propose de préférer le déroulé à l'imbriqué (principe 5). Le déroulé signifie ajouter du code avant ou après le code existant directement, tandis que l'imbriqué signifie concevoir un nouvel objet ou une nouvelle fonction pour le faire.

Dérouler les contrats directement dans le code de Pycairo serait un objectif louable mais:

  1. Il faudrait modifier le code de Pycairo
  2. Pycairo lèverait alors de nouvelles exception dans certains code utilisateur existant, et tout le monde sait que tout bug non identifié est une fonctionnalité qu'utilisent les clients de la librairie.
  3. Cela rajouterait de la complexité a Pycairo, et une dépendance au framework de DbC.
  4. Ce serait en temps et efforts pour un post de blog 😉
Mais ce bug était super pratique pour vérifier mes entrées utilisateur !
💡
Pour du debug, je déroule souvent les contrats en les insérant dans les packages installés dans mon environnement virtuel, de manière a pouvoir remonter a la source des bugs (les cas problématiques lèveront des exceptions). Ça fonctionne très bien dans tous les langages interprétés (python, bash, Makefile...).
Je pense faire un billet sur le sujet, dites moi si ça vous intéresse !

Interface Pycairo

Les contrats peuvent s'appliquer a plusieurs niveaux: la structure des données (typage), le contenu du ces données, et les valeurs croisées des données entre elles (ainsi qu'avec l'environnement).

Schématiquement on pourrait dire que nos données et les méthodes pour les vérifier sont représentées par le schéma suivant (avec un exemple bidon):

Duck-typing

Le duck-typing est le type de typage que préfère python. Il s'agit de vérifier le comportement et les capacités du type, et non sa représentation.

📗
Le duck-typing est le type de typage que préfère python: le principe est que "si ca fait Couac ! comme un canard, alors c'est un canard". C'est une approche dynamique et pragmatique: le type attendu pour un objet dépend uniquement de l'emploi qui en sera fait.
Par exemple si j'utilise le parametre X uniquement pour appeler X.couac() alors son duck-type est "tout objet qui implémente une méthode couac()"

Python a une famille de types: les types numériques, qui stockent des nombres. Ils étendent tous la classe numbers.Number.

Il demande par ailleurs souvent des nombres entre 0 et 1, je peux le vérifier avec les conditions suivantes:

def is_numeric(v):
    return isinstance(v, numbers.Number) and v != float('nan')

def is_unit(v):
    return is_numeric(v) and 0.0 <= v and v <= 1.0

Je commence par vérifier que j'ai bien reçu un type numérique, et qu'il ne s'agit pas du flottant Not-a-number, puis je peux vérifier la valeur. Ces fonctions doivent dans tous les cas retourner une valeur booléenne, soit vrai si le paramètre correspond a la cible, soit faux si ce n'est pas le cas. Elles ne doivent jamais lever d'exception. C'est paradoxal car lorsqu'un contrat échoue il lève une exception. Je peux le justifier par le partage des responsabilités: la spécification vérifie la validité sans plus de détail, la partie contrats communique au niveau supérieur l'échec d'une condition avec une exception.

Dans mon interface de spécification, j'ai défini de nouvelles opérations qui prennent des structures de données (un poil) plus complexes que celles que manipule Pycairo. Ainsi pour une position X, Y je passe un tuple de deux éléments.

On aura l'appel suivant

def has_len(x):
    return hasattr(x, "__len__")

def has_iter(x):
    return hasattr(x, "__iter__")

def is_point(p):
    return (has_len(p) and len(p) == 2
            and has_iter(p) and all(is_numeric(c) for c in p))

On vérifie le duck-type explicitement avant d'utiliser la fonction len()

Puisque je dois vérifier que je reçois bien deux nombres, je vérifie d'abord la taille du contenant. Je ne vérifie pas son type directement car je respecte (un minimum) le duck-typing. Je vérifie donc que len() == 2.

⚠️
Avant de pouvoir appeler cette fonction, je dois vérifier qu'elle est apellable, ce qui signifie que hasattr(x, "__len__")

Je vérifie ensuite que l'ensemble des objets présent dans ce contenant sont bien des type numériques.

💡
L’itération, implémentée par __iter__ et l'indexation __getitem__ sont deux interfaces complémentaires pour manipuler un contenant python. J'utilise la première ici, mais la seconde serait valide. Notez que si je voulais un contrat béton j'utiliserai l'une ou l'autre en fonction de sa disponibilité.
Derrière l'expertise nécessaire à l'implémentation des fonction de la spécification, il y a de grandes possibilités de factorisation du code. Un autre sujet à traiter...

Types machine

Puisque Pycairo est basé sur une libraire C (libcairo), les objets que nous lui fournissons doivent être du type machine correct: flottant, entier...

📗
Le type machine est le type sous-jacent utilisé par la machine pour réaliser l'opération. On distingue de nombreux types machines, qui peuvent changer d'une machine à l'autre.
Deux éléments sont nécessaires (mais non suffisant) pour se représenter ces types: la taille du type (32, 64 bits...) et son encodage (entier signé ou non, flottant...). Dans notre cas c'est son encodage qui nous sera important: si je passe une valeur non encodable, l'interface python vers le C me lèvera une erreur.
Petit quiz
A - Donnez une valeur de flottant 32 bits qui n'est pas encodable en entier signé 32 bits

Réponse: 1.0E10 qui est trop large et causerait un overflow.

B - Donnez une valeur d'entier signé 32 bits qui n'est pas encodable en flottant 32 bits

Réponse: 0x0000_ffff_ffff_ffff qui causerait une perte de précision

Par exemple, une taille width, height est passée dans Pycairo sous forme de deux entiers exclusivement: passer un flottant causera une erreur au niveau de l'interface avec le code C. Python manipule les données comme entiers si et seulement si elles n'ont pas de partie fractionnelle.

Vérifier qu'il s'agit d'un entier peut donc être fait de la manière suivante

def is_int(x):
    if isinstance(x, int):
        return True
    elif isinstance(x, float):
        return x.is_integer()
    elif is_numeric(x):
        return round(x) == x
    else:
        return False
ℹ️
Remarque: ce code est verbeux et pas particulièrement optimisé. Cela lui donne un avantage: il est très clair et maintenable. C'est un tradeoff très classique avec les contrats.
Cet avantage nous est utile car les contrats servent alors de documentation très claire.

Plus tard, nous pourrons contourner les deux défauts identifiés, donc nous sommes gagnant-gagnant !
Les contournements sont schématiquement:
1. réutiliser ce code va pouvoir, par une librairie de spécification
2. ne pas l'exécuter en production (paradoxal et intriguant, n'est-ce-pas ?)

Conclusion

Nous avons vu l'ensemble des techniques et mis en place des fonctions de base pour pouvoir contractualiser l'usage de Pycairo. Cette approche nous permet une bonne factorisation et modularisation des contrats, indispensable à leur maintenabilité au long terme.

Comme vous l'avez sans doutes remarqué, l'approche est possède des particularismes qui en font une spécialité. En tant que développeur, il s'agit d'une nouvelle compétence à acquérir. Les bénéfices ? Avec le recul il sont clairs pour moi: nouveau point de vue sur le code, nouvelles techniques, permettant de tester, débugger, optimiser son code de manière plus efficace.

Dans les prochains posts, je montrerait la suite du code, en donnant un aperçu d'autres fonctions et techniques plus exotiques, et en détaillant l'usage avec un système externe: le système de fichiers.

Et vous, quelles techniques aimez vous utiliser pour prendre en main une nouvelle librairie ? J'ai activé les commentaires, alors n'hésitez pas à demander ci-dessous.

Mini Q&A

J'ai reçu d'autres questions de votre part

  • Les contrats ressemblent quand même à du code. Et du coup dépendamment de la complexité, ça reste du code. Par exemple tu fais des count() d'un mot la dedans, ça pourrait presque être une fonction du business.
    • Tout à fait. La principale différence s'il y en a une va résider dans son implémentation. Lorsqu'on a deux implémentation: celle du business va être efficace, optimisée; tandis que celle des contrats, sera lisible, expressive et ne lèvera pas d'exceptions.
  • Les contrats sont verbeux, il faut les maintenir, ils prennent de la place a l’écran
    • Oui. C'est aussi vrai des commentaires et les tests également. En ajoutant des contrats le volume de commentaires et de tests diminue en proportion. Et je pense que c'est un bon compromis d'avoir ces 3 approches ensemble, pour ne pas mettre tous ses œufs dans le même panier.

Ce ne sont pas du tout les seules questions que j'ai reçu mais ce billet est déjà bien long, je garde la réponse au reste pour un prochain billet 😉

Liens

Le code est présent sur le repository du framework de design-by-contract que j'ai crée.

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

Quelques ressources discutées dans l'article

Encapsulation (programmation) — Wikipédia
Principe de substitution de Liskov — Wikipédia
Zen de Python — Wikipédia