Mise au point logicielle
Introduction :
La mise au point logicielle repose sur la connaissance d'un certain nombre de principes et de bonnes pratiques de développement, mais aussi sur la prise en compte des spécificités du langage choisi pour l'implémentation. Aussi, dans ce cours, nous présenterons les bonnes pratiques logicielles en soulignant certaines spécificités du langage Python.
Nous aborderons dans un premier temps les effets de bord, puis nous nous intéresserons aux traitements conditionnels et enfin aux comparaisons, chaque fois sous l'angle de la mise au point logicielle.
Effets de bord
Effets de bord
Un langage à typage dynamique n'impose pas que le type des variables soit défini. Le type est alors déterminé à la volée au moment de l'interprétation du code.
Typage dynamique
Typage dynamique
Python étant un langage à typage dynamique, il n'impose pas de spécifier le type d'une donnée, et on peut à loisir changer le type de donnée d'une variable.
De tels changements seraient en revanche impossibles dans les langages à typage statique qui imposent de définir précisément le type de données d'une variable et de le respecter ensuite.
Le typage dynamique nous permet d'envisager un même traitement applicable à plusieurs types de données.
Illustrons-le avec une procédure simple qui imprime chacun des éléments d'un itérable.
Testons ensuite notre procédure avec plusieurs types d'itérables.
On obtient l'affichage suivant :
On peut obtenir le même affichage d'autres types de données.
Ces quatre appels de procédure produisent tous l'affichage suivant :
Mais dans certains cas un traitement différencié peut s'avérer nécessaire en fonction de la nature des données traitées. Dans l'exemple qui précède, nous avons traité indifféremment des types mutables (liste, set, dictionnaire) et des types non mutables (chaîne de caractères, tuple).
- Les types mutables autorisent l'affectation ou la suppression d'éléments mais ce n'est pas le cas des types non mutables qui génèrent une erreur si on tente une affectation ou une suppression d'élément.
Si nous voulions apporter des modifications aux itérables, il faudrait prévoir des traitements adaptés en fonction de leur mutabilité. De même certains d'entre eux sont ordonnés (chaîne de caractères, liste, tuple) et d'autres ne le sont pas (dictionnaire, set). La souplesse apportée par le typage dynamique ne dispense donc pas d'adapter les traitements aux types de données possiblement traités.
Le choix du type de structure de donnée doit s'effectuer en fonction des besoins et des caractéristiques désirées, notamment d'ordre et de mutabilité.
Mutabilité
Mutabilité
Certaines structures de données ont été conçues avec la caractéristique d'être mutables, c'est-à-dire modifiables.
Il est nécessaire d'être vigilant·e sur cette caractéristique en programmation fonctionnelle où une fonction ne doit pas créer d'effet de bord, c'est-à-dire qu'elle ne doit pas modifier d'éléments extérieurs à son environnement local.
Voici un exemple de mutation appliquée à un dictionnaire par une fonction.
Nous disposons d'un dictionnaire composé de clés associées à des valeurs numériques entières, positives ou négatives. Nous souhaitons créer une fonction qui reçoive en entrée un tel dictionnaire et retourne en sortie un dictionnaire où les valeurs négatives ont été remplacées par zéro.
On constate que la fonction
a bien retourné le résultat attendu, mais elle a aussi modifié le dictionnaire qui a servi de base au traitement.Pour éviter cette mutation notre fonction doit travailler sur une copie et pas sur l'original.
- La version réécrite de notre fonctiona bien effectué le traitement demandé, mais elle n'a pas modifié le dictionnaire des entrées qui a servi de base au traitement.
La copie de structures de données mutables avec la méthode
consiste en une copie superficielle des données (« shallow copy » en anglais). Si ces structures de données mutables contiennent elles-mêmes des données mutables, telles que des listes ou des dictionnaires, il est nécessaire d'effectuer ce qu'on appelle une copie profonde (« deep copy » en anglais).La bibliothèque
faisant partie de la bibliothèque standard de Python, elle propose les méthodes correspondantes.Débordements
Débordements
La notion de débordement est associée à la notion de pile.
Pile :
Une pile est un conteneur dans lequel il est possible d'ajouter (empiler) et de retirer (dépiler) des éléments.
La logique de fonctionnement d'une pile est résumée par le sigle LIFO (pour « Last In, First Out », c'est-à-dire « dernier entré, premier sorti »).
Avec une pile on ne peut retirer que le dernier élément empilé. Si on veut accéder à des éléments situés en-dessous, il faut d'abord retirer (dépiler) un par un ceux qui se trouvent au-dessus.
On peut faire l'analogie avec une pile de cartons lourds qui ne pourraient être manipulés qu'individuellement.
Des débordements peuvent survenir dans deux cas :
- débordement positif quand la pile, limitée en taille, est pleine et qu'on tente d'ajouter un nouvel élément ;
- débordement négatif quand la pile est vide et qu'on tente de retirer un élément.
En Python on peut assez facilement modéliser une pile en s'appuyant sur une liste. Si certains langages imposent de définir à l'avance une taille de tableau, et donc un nombre fixe d'éléments, ce n'est pas le cas de Python dont les listes peuvent par défaut être étendues à loisir. Mais on pourrait facilement implémenter une telle restriction sur une liste pour disposer d'une pile à capacité limitée.
On peut constater un débordement négatif sur une liste si on appelle la méthode
sans nous assurer au préalable que la liste contient au moins un élément.
Un test préalable est donc nécessaire pour s'assurer que la liste n'est pas complètement vide avant de tenter de la dépiler.
On peut implémenter ce test en exploitant la différence de valeur booléenne entre une liste vide et une liste qui ne l'est pas.
Cette différence nous permet de produire facilement un code qui dépile complètement une liste sans erreur à la fin.
Les effets de bord peuvent perturber la mise au point logicielle. Il est important de les connaître afin d'éviter de les subir de manière involontaire.
Les effets de bord ne sont pas les seules sources d'erreurs logicielles. Les instructions conditionnelles doivent elles aussi être employées de manière adéquate.
Instructions conditionnelles
Instructions conditionnelles
Les instructions conditionnelles permettent à nos algorithmes d'effectuer des traitements distincts en fonction de conditions particulières. Nous évaluons si certaines conditions sont remplies et si c'est le cas nous effectuons le traitement correspondant.
Ces instructions conditionnelles sont articulées en Python autour d'au moins un
(« si »), optionnellement associé à un ou plusieurs (signifiant « sinon si ») et optionnellement à un (« sinon »).Nous allons présenter plusieurs cas de figures d'erreurs fréquemment commises avec des instructions conditionnelles.
Séquence incomplète
Séquence incomplète
Voyons tout d'abord une séquence incomplète.
Mais si nous relançons ce même code en affectant la valeur $10$ à
, nous obtenons une erreur.
Cet ensemble d'instructions conditionnelles ne couvre pas tous les cas de figure. En effet le cas où
est égal à $10$ n'est traité par aucune des branches : aucune valeur n'est affectée à .Une clause
permet de fournir des instructions quand aucune autre condition n'a été remplie, mais dans le cas présent où toutes les valeurs de autres que $10$ ont été traitées, l'erreur viendrait plutôt d'un comparateur qui aurait été saisi en dans le premier ou le deuxième .Séquence complète avec ordre incorrect des conditions
Séquence complète avec ordre incorrect des conditions
On peut avoir envisagé tous les cas de figure mais avoir commis une erreur dans l'ordre de traitement des conditions.
On cherche à implémenter un algorithme qui affecte une valeur $y$ en fonction d'une valeur $x$ selon le tableau suivant :
Valeurs de $x$ | Valeurs de $y$ |
$x <= 20$ | $y = - 20$ |
$x > 20$ | $y = 21$ |
$x > 30$ | $y = 35$ |
$x > 50$ | $y = 37$ |
La transposition littérale du tableau par ordre chronologique nous fournit le code suivant :
Ce code fonctionnera uniquement pour certaines valeurs car dès que
dépasse $20$, la condition du premier devient vraie, et les branches suivantes ne seront jamais traitées. Il faudrait donc soit réordonner les inégalités existantes, soit créer des conditions sous forme d'encadrement, du type : .La façon dont le problème est défini n'est pas toujours directement transposable en algorithme de manière littérale.
Traitement conditionnel d'égalités
Traitement conditionnel d'égalités
Le problème d'ordre décrit précédemment avec des inégalités disparaît avec des égalités, puisque par définition une variable ne peut pas simultanément être égale à plusieurs valeurs. Toutefois, on peut être confronté·e à des problématiques proches avec l'évaluation simultanée d'égalités portant sur plusieurs valeurs.
Énoncé du problème :
- si $x = 2$ ou $y = 5$, alors $z = 35$
- si $x = 2$ et $y = 5$, alors $z = 15$
- sinon $z = 20$
Avec un tel énoncé, qui s'apparente à du pseudo-code, la transposition paraît assez immédiate.
Pourtant ce code ne fonctionnera pas comme prévu.
Il est certain qu'une valeur sera affectée à
grâce à la clause qui permet de traiter le cas où aucune des évaluations d'expressions antérieures n'est vraie.On comprend par ailleurs à la lecture de ce code l'intention recherchée d'affecter la valeur $15$ à
quand vaut $2$ et vaut $5$.Mais l'embranchement correspondant, celui du premier
, n'est jamais atteint.En effet, dans ce cas de figure, la condition évaluée dans le
est vraie : il suffit qu'une seule des deux variables soit égale à la valeur testée, mais avec le « ou » logique la condition reste vraie aussi quand les deux expressions sont vraies. Comme cette condition est évaluée avant la condition comportant le « et » logique, cette dernière n'est pas évaluée.Pour obtenir le résultat souhaité, on doit donc inverser l'ordre des deux évaluations : on commence par vérifier si les deux égalités sont vraies simultanément, et si ce n'est pas le cas, on évalue ensuite si au moins une des deux l'est.
Sécurité des traitements
Sécurité des traitements
Lorsqu'un algorithme doit déterminer selon un ensemble de conditions si une permission est accordée ou non, il est préférable de toujours considérer comme point de départ le niveau minimum de permission, plutôt que de faire le contraire. Ce niveau minimum pourra éventuellement être augmenté si les conditions sont réunies.
Ainsi dans le cas où on cherche à déterminer si un mot de passe est, ou non, conforme à un certain nombre de critères, on commencera par considérer qu'il n'est pas conforme. On évaluera ensuite individuellement chaque condition (par exemple longueur du mot de passe, présence conjointe de majuscules et de minuscules, présence de caractères spéciaux etc.), et on ne validera le mot de passe que si toutes les conditions sont bien réunies.
De la même manière lorsqu'on implémente une fonctionnalité d'ajout d'utilisateur·rice·s pour l'administrateur·rice d'une plateforme, et que ces visiteur·se·s peuvent avoir plusieurs rôles correspondant à des niveaux de permissions croissants (par exemple :visiteur·se, membre inscrit·e, modérateur·rice, administrateur·rice), on fera en sorte que le rôle par défaut d'un·e nouvel·le utilisateur·rice soit « visiteur » plutôt qu'« administrateur ». De cette manière si l'administrateur·rice oublie de changer les droits par défaut pour en faire un usager plus privilégié, celui-ci obtiendra seulement les droits d'un visiteur : ses droits seront trop restreints, ce qui est assurément moins problématique en termes de sécurité que des droits trop étendus.
Les traitements conditionnels nécessitent une vigilance particulière afin de s'assurer que tous les cas de figure possibles sont traités, et que les traitements prévus pour chaque cas spécifique sont bien effectués comme prévu.
Nous allons maintenant nous intéresser aux problèmes pouvant survenir en effectuant des comparaisons.
Comparaisons
Comparaisons
De nombreux algorithmes nécessitent de comparer des valeurs entre elles. Nous avons montré précédemment qu'il était important de prévoir tous les cas de figure de manière exhaustive et sans commettre d'erreur dans l'ordre des évaluations pour les inégalités. Si l'ordre n'a pas d'importance pour les égalités simples, la notion même d'égalité doit être nuancée.
Égalité des nombres réels
Égalité des nombres réels
La comparaison d'entiers (type
) ne pose pas de problèmes particuliers.
Il convient en revanche d'être prudent avec les nombres flottants (type
).
Ce résultat surprenant et inattendu tient à la manière dont les ordinateurs gèrent les nombres flottants. Sans entrer dans les détails techniques, il faut retenir que certaines valeurs sont seulement des valeurs approchées. Nous pouvons l'illustrer en demandant à Python d'afficher la valeur du nombre réel $0.3$ avec un très grand nombre de décimales.
Les tests d'égalité stricte sont donc à éviter lorsqu'on manipule des nombres réels. On passera par des arrondis ou par des comparaisons prenant en compte un faible écart que l'on aura choisi de tolérer.
La bibliothèque de tests unitaires
Cette méthode permet de préciser un ordre de grandeur de l'écart (appelé conventionnellement delta) acceptable pour la comparaison.
Identité et équivalence
Identité et équivalence
Lorsqu'on compare deux variables, il est important de comprendre sur quoi porte la comparaison.
On doit distinguer deux notions :
- l'égalité structurelle, qui indique si le contenu des objets est identique ;
- l'égalité physique, qui indique s'il s'agit ou non du même objet.
Illustrons ces deux notions avec un exemple.
Les variables
, et ont bien le même contenu.
Les variables
et font référence à un même objet commun. En revanche est un objet distinct. On peut le matérialiser en consultant l'adresse mémoire pointée par les trois variables. On constate que et désignent bien la même adresse, contrairement à .
Toute modification effectuée sur
affectera obligatoirement puisqu'il s'agit d'un seul et même objet. En revanche étant un objet distinct, il pourra être modifié de manière indépendante.En fonction de l'objectif recherché, on vérifiera soit l'identité soit l'équivalence entre deux objets comparés.
La connaissance des sources d'erreur évoquées précédemment permet au·à la programmeur·se d'anticiper ses erreurs. En ayant en tête ces sources d'erreur au moment de l'écriture du programme, il·elle gagnera un temps précieux dans la mise au point de son programme. Cependant, on n'échappera pas forcément à la manifestation d'un bug. Il est alors nécessaire de procéder méthodiquement pour corriger celui-ci. Regardons cela de plus près.
Démarche de correction d'un bug
Démarche de correction d'un bug
Identifier le bug
Identifier le bug
Lorsqu'un bug apparaît, il importe, dans un premier temps, de procéder à son identification. Pour cela, il est nécessaire de procéder en trois étapes :
- être en mesure de reproduire le bug, donc d'identifier les circonstances dans lesquelles celui-ci apparaît ;
- bien cerner le résultat normalement attendu, ce qui impose en particulier de reprendre connaissance du détail des exigences relatives au programme. Le but étant de corriger le programme dans le strict respect de celles-ci ;
- les étapes 1 et 2 étant passées, il est alors temps de localiser le bug, c'est-à-dire la partie de code fautive, sans rentrer au niveau de la ligne de code à ce stade-là.
On commence à identifier le module qui génère le bug. Le bug peut être dû à une erreur de programmation dans le module lui-même, ou au fait qu'il travaille avec des données en entrée qui sont elles-mêmes erronées. Une fois le module fautif identifié, on resserre le champ de recherche en se focalisant sur ce module et en cherchant à identifier le sous-module, puis la fonction ou la portion de code fautive.
Corriger le bug
Corriger le bug
Une fois le bug identifié on procède en quatre étapes :
- corriger le bug ;
- enrichir le jeu de tests du programme pour couvrir le cas qui a généré le bug ;
- profiter de son intervention dans le code pour l'améliorer, le rendre plus clair ;
- tester la correction du bug et vérifier également que l'on n'a pas commis de régression, c'est-à-dire engendré d'autres bugs.
En français : Ce n'est pas un bug, c'est une fonctionnalité
Conclusion :
La mise au point logicielle nécessite de respecter un certain nombre de bonnes pratiques de développement et aussi de connaître les spécificités du langage utilisé pour effectuer ce développement.
Nous l'avons illustré avec le langage Python en abordant successivement les effets de bords, les traitements conditionnels et les comparaisons. Nous avons pu constater que l'écriture d'algorithmes corrects reposait sur des choix éclairés de structures de données prenant en compte leurs différentes caractéristiques, mais aussi sur notre capacité à implémenter une problématique qui n'est pas toujours littéralement transposable, même quand elle est exprimée de manière proche du code. Cependant, si le développeur est malgré tout confronté à un bug, il saura le corriger efficacement en suivant une démarche rigoureuse qui assurera capitalisation et non régression.
Pour terminer ce cours avec un peu d'humour, voici (ci-contre) une citation bien connue des développeurs.