UKOnline

Qualité de code

Un code est correct lorsqu'il fait ce qu'il faut, c'est-à-dire qu'il calcule le résultat qu'on attend de lui. Cette notion générale est plutôt difficile à vérifier et on s'intéresse dès lors plutôt à un code correct par rapport à ses spécifications. Un tel code, une fois exécuté, satisfait toujours ses postconditions, peu importe comment on l'appelle et avec quels paramètres, pour autant qu'on respecte les préconditions. Il s'agit, en somme, d'un code dont l'implémentation respecte le contrat établi par ses spécifications. Il est parfois possible de s'assurer qu'un code est correct par rapport à ses spécifications.

Une fois qu'on a un tel code, on pourrait déjà être content. Mais il faut savoir que, le temps de sa durée de vie, un code est plus souvent lu qu'écrit. Il est dès lors important d'accorder un certain soin à sa rédaction, tout comme un auteur n'écrit pas un livre n'importe comment. Un code de qualité en est un qui respecte une série de conventions et règles de bonne pratique, le rendant facile à lire et comprendre par n'importe qui. Un autre avantage d'un tel code est qu'il sera plus facile à mettre à jour et qu'il sera moins probable d'y avoir des bugs.

Outil Pylint

Un code peut être audité pour évaluer sa qualité. Il s'agit, en bref, de parcourir un code pour vérifier qu'il respecte une série de critères et règles précises, notamment en terme de qualité. De manière générale, on peut également évaluer sa robustesse, son niveau de sécurité, identifier ses éventuelles vulnérabilité, etc.

Concernant la qualité de code, il est possible d'automatiser toute une série de vérifications. Par exemple, l'outil Pylint permet de vérifier automatiquement de nombreuses règles. Il peut notamment :

  • vérifier les conventions de codage décrites dans le PEP 0008 ;
  • détecter automatiquement des erreurs comme des variables ou imports non utilisés, etc. ;
  • repérer des duplications de code et autres mauvaises pratiques nécessitant de modifier le code pour améliorer sa qualité.

Prenons l'exemple de programme suivant :

Le programme demande à l'utilisateur d'entrer son année de naissance. Tant qu'il ne fournit pas un nombre qui est compris entre $0$ et $2016$, une boucle infinie permet de lui redemander d'entrer une valeur correcte. Une fois qu'il a donné une valeur correcte, son âge est calculé et affiché. Enfin, on a placé la partie du programme qui demande l'année de naissance dans une fonction getbirthyear.

L'exécution de l'outil Pylint sur ce programme donne le résultat suivant :

No config file found, using default configuration
************* Module test
C:  4, 0: Unnecessary parens after 'while' keyword (superfluous-parens)
C:  6, 0: Exactly one space required around assignment
            birthyear=int(input('Année de naissance ? '))
                     ^ (bad-whitespace)
C: 11, 0: Trailing whitespace (trailing-whitespace)
C: 12, 0: Final newline missing (missing-final-newline)
C:  1, 0: Missing module docstring (missing-docstring)
C:  3, 0: Missing function docstring (missing-docstring)
C:  7,39: More than one statement on a single line (multiple-statements)
W:  3,17: Unused argument 'value' (unused-argument)
C: 11, 0: Invalid constant name "b" (invalid-name)
W:  1, 0: Unused import sys (unused-import)

Chaque ligne de ce rapport contient plusieurs informations :

  • elle commence avec une lettre indiquant le type de message (C pour une convention non respectée, W pour un avertissement de mauvais style et E pour une erreur qui pourrait causer un bug) ;
  • viennent ensuite deux nombres indiquant le numéro de la ligne et le numéro de la colonne liés au message fourni ;
  • et enfin, vient une description textuelle de l'élément de mauvaise qualité trouvé.

Pour comprendre le genre d'éléments de mauvaise qualité que Pylint est capable de retrouver, passons en revue chaque ligne du rapport :

  1. des parenthèses inutiles ont été détectées après le mot réservé while, ce qui alourdit le code inutilement ;
  2. il manque exactement une espace avant et après le symbole = utilisé pour faire une affectation ;
  3. il y a des espaces inutiles au bout de la ligne ;
  4. il manque un retour à la ligne en fin de fichier ;
  5. il manque la documentation du module ;
  6. il manque la documentation de la fonction ;
  7. plusieurs instructions ont été placées sur la même ligne (on a en effet le corps de l'instruction if qui a été placé sur la même ligne, ce qui est autorisé par Python lorsque ce dernier n'est composé que d'une seule instruction) ;
  8. le paramètre value n'est pas utilisé (la fonction getbirthyear reçoit en effet un paramètre inutile) ;
  9. le nom de la constante b est invalide (le nom est en effet trop court) ;
  10. l'import de sys n'est pas utilisé.

Toutes ces remarques vont pouvoir être prises en compte afin de modifier le programme. Notez bien que ce dernier est correct car il fait exactement ce pour quoi il a été conçu. Le seul reproche qu'on lui fait est de ne pas avoir un certain niveau de qualité étant donné qu'une série de conventions et règles de bonne pratique n'ont pas été respectées. Voici la nouvelle version du programme, qui passe tous les tests de qualité faits par Pylint :

Pylint ne sait évidemment pas tout vérifier. Par exemple, la variable YEAR est-elle vraiment nécessaire puisqu'elle n'est utilisée qu'une seule fois. On aurait directement pu faire l'appel à la fonction getbirthyear dans la dernière instruction et remplacer les deux dernières lignes par l'unique instruction suivante :

On détaillera les conventions PEP 0008, vérifiées par Pylint, dans la deuxième section de ce chapitre.

Règles de bonne pratique

Voyons maintenant plusieurs règles de bonne pratique à respecter si l'on veut écrire un code de qualité. Ce que l'on va ici voir n'est pas nécessairement lié à Python et peut s'appliquer à des programmes écrits dans d'autres langages.

Variable

On utilise une variable pour une seule raison, qui est clairement établie. Il faut, par exemple, éviter d'avoir une variable dont la signification dépend de la valeur de son contenu. De même, on évitera d'utiliser une variable fourre-tout qui change de rôle en fonction de la partie du programme dans laquelle on se trouve.

On utilise une variable pour stocker une valeur qui est utilisée plusieurs fois, afin d'éviter une duplication de code. On peut aussi utiliser une variable pour éviter un nombre magique, c'est-à-dire un nombre qui apparait dans une instruction sans que l'on sache forcément ce qu'il représente. Le mettre dans une variable, qui aura donc un nom, permet de rendre sa signification explicite. Pour faire mieux, on va même mettre son nom en majuscules, ce qui signifie, par convention, qu'il s'agit d'une constante. L'exemple suivant comporte deux erreurs de style :

Comme on utilise plusieurs fois la valeur database['beverage']['beers'], on va la placer dans une variable. De plus, la multiplication par $1.21$ est faite pour calculer le prix incluant les taxes qui sont de $21%$ et on va le rendre explicite en définissant une constante TAX_RATE :

Après cette première amélioration du code, on se rend compte que l'accès à beers[code] est dupliqué au sein de la boucle. On pourrait donc encore l'améliorer en créant une nouvelle variable, dans la boucle, pour stocker la bière en cours de traitement :

Et qu'en est-il du chiffre magique 1 ? On pourrait vouloir le remplacer, mais c'est normalement suffisamment clair qu'il s'agit simplement d'une formule permettant de calculer le prix taxes incluses lorsqu'on connait le taux de taxation.

Enfin, vous aurez remarqué qu'on n'a pas choisi le nom des variables utilisées n'importe comment. Un bon nom de variable doit expliquer sa raison d'être, en décrivant la variable et son contenu. De manière générale, on évite des noms trop courts ou trop longs (on parle souvent d'un nombre de caractères compris entre $9$ et $15$, en moyenne).

De manière générale, on évite les lettres ambigües. Il est, par exemple, facile de confondre char1 (chiffre $1$) avec charl (lettre $L$) ou COnf (lettre $O$) et C0nf (chiffre $0$). De plus, on essaie, dans la mesure du possible, d'utiliser des noms anglais et on n'oublie pas de faire attention à l'orthographe. Enfin, on évitera d'avoir des noms proches dans le même programme (par exemple input et inputVal) ou similaires (par exemple clientRep et clientRec).

Voici encore trois autres exemples de noms à éviter, avec un exemple de bon nom à mettre à la place :

Expression booléenne

Il est très important de bien maitriser le type booléen pour éviter d'écrire du code inutile. Tout d'abord, il faut se rappeler qu'une condition d'un if est une expression booléenne. Dès lors, si on utilise une instruction if-else pour initialiser une variable booléenne, on doit en fait s'en passer et directement affecter la condition à la variable :

Si les valeurs affectées à la variable accepted étaient inversées, on aurait utilisé l'opérateur logique not pour inverser la valeur de la condition :

De plus, pour tester si une variable booléenne vaut True ou False, on ne doit pas utiliser l'opérateur d'égalité. En effet, la variable, étant de type booléen, est déjà elle-même une condition valable :

Enfin, il ne faut pas hésiter à simplifier les expressions booléennes, surtout celles utilisées comme condition, en exploitant les équivalences logiques. Par exemple, not x == y est équivalent à x != y :

Le deuxième exemple exploite les lois de De Morgan qui disent que not (x and y) est équivalent à not x or not y et que not (x or y) est équivalent à not x and not y.

Variable indexée

Lorsqu'on doit stocker plusieurs fois la même information, mais concernant des entités différentes, on doit utiliser une liste plutôt que de déclarer plusieurs variables qui auront presque les mêmes noms, ne différant que par un nombre ajouté en fin de nom, par exemple. Cela permet d'éviter de déclarer pleins de variables et permet également un traitement plus facile à l'aide de boucles :

La liste est donc adaptée lorsqu'on a une séquence de données indexées par un nombre entier. Lorsque le nom des variables cache des propriétés, on va plutôt se tourner vers un dictionnaire :

Instruction inutile

De manière générale, il faut essayer de garder un code le plus compact possible et éviter les instructions inutiles. On essaiera ainsi de limiter la longueur des lignes (en nombre de caractères) et également le niveau d'indentation. On peut, par exemple, éliminer le bloc else dans deux situations précises.

Lorsqu'on utilise une instruction if-else pour initialiser une variable, qui peut donc recevoir deux valeurs différentes, on peut éliminer le bloc else en initialisant la variable avec la valeur qu'elle aurait eu dans ce dernier, avant l'instruction if. En somme, c'est comme si on affectait une valeur par défaut à la variable, puis qu'on la changeait si une condition est satisfaite :

L'autre situation où on peut diminuer le niveau d'indentation est lorsqu'on définit une fonction qui renvoie deux valeurs possibles en fonction d'une condition. Dans ce cas, et puisque return quitte immédiatement l'exécution du corps d'une fonction, on peut simplement éliminer le else et placer le contenu de son bloc après l'instruction if :

Duplication de code

Enfin, un dernier point qu'il faut essayer d'éviter à tout prix, ce sont les duplications de code. On a déjà vu qu'on pouvait en éliminer en définissant des nouvelles variables lorsqu'on avait une duplication exacte qui faisait référence à une même valeur.

On peut en fait combattre la duplication exacte et quasi-exacte de code, et on le fait pour plusieurs raisons. Tout d'abord, si on trouve une erreur dans le code dupliqué, il faudra aller la corriger partout avec le risque d'oublier des corrections et de laisser des bugs. De plus, la duplication alourdit inutilement le code, le rendant difficile à lire. Enfin, lorsqu'on fait de la quasi-duplication, on finit par s'embrouiller et ne plus cerner les subtiles différences, rendant toute mise à jour du code périlleuse.

Une technique permettant d'éliminer de la quasi-duplication de code consiste à définir une nouvelle fonction, qui prend autant de paramètres qu'il y a de différences entre les codes similaires. Regardons l'exemple suivant pour comprendre cela :

Découpe en fonctions

Lorsqu'on code, il faut également réfléchir au futur et à la réutilisabilité. Il est ainsi parfois utile de définir une nouvelle fonction, qui est suffisamment générale que pour pouvoir servir à nouveau, éventuellement dans un autre contexte. Partons de l'exemple suivant qui a pour but de trier un tuple de deux valeurs :

On pourrait commencer par éliminer le bloc else avec la technique de la valeur par défaut. On pourrait même, avant cela, inverser les deux blocs du if-else et la condition du if, de sorte que le cas par défaut soit sorted = data :

Enfin, pour faire encore mieux, il convient d'anticiper le fait que l'on risque de devoir trier un tuple de deux éléments dans le futur. Il deviendrait dès lors intéressant de définir une fonction pour cela, qui sera réutilisable en cas de besoin. On définit donc une fonction sortpair, qui n'est pour le moment appelée qu'une seule fois, mais qui pourra l'être directement dans le futur si besoin :

Mise en page

L'aspect visuel global d'un code, à savoir sa mise en page, est également importante. Un bon layout permet de faire ressortir la logique du code et d'aider la personne qui doit le lire et le comprendre. On va pour cela utiliser les blancs et les lignes vides pour faire ressortir des blocs de code et les parenthèses pour mettre en évidence des parties d'expressions. L'exemple suivant montre un exemple de code compact et pas aéré qui, malgré qu'il fonctionne, n'est pas facile à comprendre :

Une autre élément de layout concerne l'ordre dans lequel on écrit certaines parties de code. Ainsi, dans une classe, on définira d'abord le constructeur, puis les accesseurs et mutateurs et enfin les méthodes.

Commentaire

Last but not least, on n'hésitera pas à agrémenter les programmes que l'on écrit de commentaires. On retrouve plusieurs types de commentaires dans un programme :

  • Les commentaires qui se contentent de répéter ce que fait le code, le paraphrasant, sont tout à fait à proscrire. On suppose, en effet, que les personnes qui vont lire votre code savent tout aussi bien que vous comprendre le Python.
  • Expliquer ce que fait le code, non pas en le paraphrasant, mais en détaillant son fonctionnement ou sa logique, est une bonne chose à faire. Il s'agit donc d'aider les lecteurs à comprendre la logique du code, par rapport au problème résolu.
  • Enfin, un dernier type de commentaire parfois utilisé, et dont il ne faut pas abuser, permet d'insérer des marqueurs ou des méta-informations dans le code. On ajoutera ainsi, par exemple, l'auteur et la version du code, ou des lignes de tirets pour faire ressortir des séparations logiques.

Dans l'exemple suivant, on a clairement mis trop de commentaires qui se contentent, par ailleurs, de décrire en français le code qui est écrit juste en dessous :

Un seul commentaire au début du bloc de code qui recherche la valeur dans la liste aurait été amplement suffisant, bien qu'il ne soit même pas forcément nécessaire. Si on veut vraiment en mettre un, alors on pourrait avoir quelque chose du genre :

Dans la mesure du possible, si on peut se passer de commentaires, on les évitera pour ne pas alourdir inutilement un code source. Comme le dit très bien Steve McConnell : « Good code is its own documentation ». Python étant fait pour être lisible et concis, un code bien écrit en respectant les règles de style et conventions en usage devrait pouvoir être facilement compris. Les seuls commentaires qu'il devrait y avoir dans un code Python doivent donc servir à des explications en lien avec le problème résolu par le programme. Si vous ressentez le besoin de mettre un commentaire pour expliquer un bout de code, posez-vous d'abord la question de savoir si vous n'avez pas écrit un code trop complexe, qui peut être simplifié.