Déboguer son projet

CS-108

1. Introduction

Lors du développement d'un programme, il est fréquent de constater que celui-ci contient une ou plusieurs erreurs qui provoquent un comportement incorrect et que l'on nomme bogue(s) en informatique (bug(s) en anglais). Ces bogues doivent être identifiés puis supprimés en déboguant (debug) le programme. Ce guide décrit une procédure permettant de faire cela de manière systématique et efficace.

2. Procédure

Lorsqu'on constate un problème à l'exécution d'un programme, il peut être tentant d'essayer de le faire disparaître rapidement, par exemple en masquant ses symptômes, ou en modifiant le code de manière plus ou moins arbitraire. Une telle approche est toutefois à proscrire absolument, car elle est à la fois inefficace et dangereuse, et mène généralement à l'introduction d'autres erreurs dans le programme.

Au lieu de cela, nous vous suggérons de suivre la procédure suivante :

  1. reproduisez systématiquement le problème,
  2. simplifiez, au besoin, la reproduction du problème,
  3. comprenez la cause du problème,
  4. corrigez la cause du problème.

Les différentes étapes de cette procédure sont décrites dans les sections suivantes.

3. Reproduire

Pour pouvoir corriger la cause d'un problème, la première chose à faire est de s'assurer que ce dernier peut être reproduit systématiquement.

Si le problème a été constaté au moyen d'un test unitaire qui échoue, et que ce test échoue à chaque exécution, le test en question constitue une manière de reproduire le problème, et il n'y a rien d'autre à faire.

Si le problème a été constaté lors d'une utilisation interactive du programme, il faut essayer de retrouver la séquence exacte d'opérations à effectuer pour le provoquer. Une fois cela fait, il peut être utile d'essayer d'écrire un test unitaire le provoquant, car un tel test est plus simple à exécuter qu'une suite d'opérations manuelles. Cela n'est toutefois pas toujours facile.

4. Simplifier

Il arrive que la procédure permettant de reproduire le problème soit relativement complexe. Par exemple, le test unitaire qui échoue peut être long ou difficile à comprendre, ou le nombre d'étapes nécessaires à la reproduction du problème lors d'une utilisation interactive peut être important. Dans un tel cas, il vaut la peine d'essayer de réduire la complexité de la procédure de reproduction du problème, pour deux raisons principales :

  1. cela permet de reproduire le problème plus rapidement — ce qui est particulièrement important lorsque la reproduction n'est pas automatique,
  2. cela peut diminuer la quantité de code exécutée pour reproduire le problème, et donc réduire la taille de la portion du programme à examiner pour trouver sa cause.

De plus, le processus de simplification en lui-même peut donner des indications intéressantes quant au problème, qui peuvent aider à le comprendre. Par exemple, lors de la simplification, il arrive de constater que le problème ne se produit que dans une situation bien particulière, et cela peut donner une indication quant à sa cause.

5. Comprendre

Lorsqu'on est capable de reproduire systématiquement le problème au moyen d'une procédure aussi simple que possible, il est temps de le comprendre. Cette phase est généralement la plus complexe et nécessite l'utilisation de différentes stratégies, décrites dans les sections suivantes.

5.1. Lire les messages d'erreur

Un problème se manifeste souvent par un plantage du programme qui, en Java, est généralement dû à une exception non traitée par le programme. Le message affiché lors du plantage montre :

  • le type de l'exception levée ainsi que l'éventuel message qui lui est attaché,
  • la pile des appels de méthodes ([call] stack trace) qui étaient en cours lorsque l'exception a été levée.

Ces messages constituent une source d'information cruciale. Il est donc très important de savoir comment les lire, et de passer le temps nécessaire à les comprendre avant de continuer.

Le programme d'exemple ci-dessous permet d'illustrer le contenu d'un message d'exception.

 1: package cs108;
 2: 
 3: public final class Main {
 4:   static double circleArea(double radius) {
 5:     if (radius < 0)
 6:       throw new IllegalArgumentException("radius < 0");
 7:     return Math.PI * radius * radius;
 8:   }
 9: 
10:   static void printCircleArea(double radius) {
11:     System.out.println("Rayon : " + radius);
12:     System.out.println(" Aire : " + circleArea(radius));
13:   }
14: 
15:   public static void main(String[] args) {
16:     printCircleArea(-2);
17:   }
18: }

Lorsqu'on l'exécute, il plante et affiche alors le message suivant :

La première ligne de ce message, qui commence par Exception in thread […] indique qu'une exception a été levée mais n'a pas été traitée par le programme. Le type de l'exception (ici IllegalArgumentException) est donné, de même que l'éventuel message associé à l'exception (ici radius < 0). Ce dernier comporte souvent des informations importantes, et doit donc être lu avec soin.

La pile d'appel, qui débute à la seconde ligne, se lit de haut en bas. La première ligne qui commence par « at » indique le nom de la méthode qui a levé l'exception (ici circleArea) et la position du code l'ayant levé (ici la ligne 6 du fichier Main.java). Dans IntelliJ, un simple clic sur la position permet d'afficher le code correspondant dans l'éditeur.

Les lignes suivantes de la pile d'appel — qui commencent toutes par « at » — indiquent les méthodes ayant appelé celle qui a levé l'exception. Ici on constate que la méthode circleArea ayant levé l'exception a été appelée par printCircleArea, elle-même appelée par la méthode principale du programme (main). La dernière ligne d'une pile d'appel est donc généralement la méthode principale du programme.

Il faut noter qu'en Java, les exceptions peuvent être « chaînées », c.-à-d. qu'une exception peut en référencer une autre comme étant sa cause. Ce chaînage influence le message affiché lors du plantage du programme, et il est donc important de le comprendre.

Le programme ci-dessous est une variante du précédent dans laquelle la méthode printCircleArea a été modifiée afin d'attraper l'éventuelle exception de type IllegalArgumentException levée par circleArea. Lorsqu'une telle exception est levée, printCircleArea l'attrape puis lève une autre exception de type Error, à laquelle l'exception attrapée, nommée e, est passée comme cause.

 1: package cs108;
 2: 
 3: public final class Main2 {
 4:   static double circleArea(double radius) {
 5:     if (radius < 0)
 6:       throw new IllegalArgumentException("radius < 0");
 7:     return Math.PI * radius * radius;
 8:   }
 9: 
10:   static void printCircleArea(double radius) {
11:     try {
12:       System.out.println("Rayon : " + radius);
13:       System.out.println(" Aire : " + circleArea(radius));
14:     } catch (IllegalArgumentException e) {
15:       throw new Error(e);
16:     }
17:   }
18: 
19:   public static void main(String[] args) {
20:     printCircleArea(-2);
21:   }
22: }

Lorsque ce programme est exécuté, le message d'erreur affiché est le suivant :

Comme on le voit, la trace de l'exception originale levée par printCircleArea est affichée après celle de l'exception qui a provoqué le plantage du programme. C'est souvent l'exception originale qui permet de comprendre le problème, et il est donc utile de commencer la lecture d'un message d'erreur provoqué par une exception à la dernière ligne commençant par Caused by — en prenant garde au fait qu'il peut y en avoir plusieurs.

5.2. Lire la documentation

Lorsque le problème se manifeste sous la forme d'une exception, et que celle-ci est levée par une méthode d'une bibliothèque écrite par une tierce partie — p. ex. la bibliothèque Java — il est important de (re)lire la documentation de cette méthode. Cela permet de s'assurer que l'on a bien compris le fonctionnement de la méthode, et ses éventuelles préconditions — c.-à-d. les exigences qu'elle impose sur ses arguments. En général, les situations dans lesquelles les différents types d'exceptions sont levées par une méthode sont décrites dans cette documentation.

IntelliJ permet de consulter la documentation d'une méthode en plaçant le curseur sur son nom puis en exécutant la commande Quick documentation — CTRL-Q sur Windows/Linux, F1 sur macOS.

Si la documentation est incomplète ou peu claire, le type de l'exception peut également donner une indication quant à la raison de sa levée. Les exceptions fréquemment utilisées par les méthodes de la bibliothèque Java sont :

  • IllegalArgumentException, qui signale qu'un argument passé à la méthode a une valeur incorrecte,
  • IndexOutOfBoundsException, qui signale qu'un index passé à la méthode est hors des bornes,
  • NullPointerException, qui signale qu'une valeur null inattendue a été rencontrée — p. ex. car elle a été passée en argument,
  • UnsupportedOperationException, qui signale que l'opération demandée (la méthode appelée) n'est pas disponible sur l'objet auquel on l'applique — p. ex. car on essaie de modifier une collection immuable.

Bien entendu, en plus du type de l'exception, le message qui lui est éventuellement attaché peut être d'une grande aide, et il faut donc toujours le lire attentivement.

5.3. Identifier la cause première

Souvent, l'erreur qui est observée n'est qu'un symptôme de la cause première (root cause) du problème, qui se trouve à un autre endroit du programme et qu'il faut identifier.

Ainsi, dans le programme d'exemple plus haut, l'erreur ne se trouve pas dans la méthode qui a levé l'exception (circleArea), ni d'ailleurs dans la méthode qui l'a appelée (printCircleArea) mais bien dans la méthode principale du programme, qui passe un rayon invalide — car négatif — à printCircleArea.

De manière générale, lorsqu'une erreur se produit dans un programme, cela est dû au fait qu'une ou plusieurs valeurs qu'il manipule sont incorrectes. Par exemple, un nombre qui ne devrait pas être négatif mais qui l'est, une référence qui ne devrait pas être nulle mais qui l'est, etc. Pour trouver la cause première du problème, il faut donc comprendre pourquoi ces valeurs sont incorrectes, ce qui peut se faire en « remontant » dans le programme pour comprendre comment les valeurs incorrectes ont été créées.

Par exemple, si un nombre qui ne devrait pas être négatif dans une méthode lui a été passé en argument, il convient d'examiner le code de l'appelant, afin de voir comment lui a calculé la valeur. Si le code du calcul semble correct, cela signifie qu'une des valeur utilisées dans le calcul est incorrecte. Il faut donc commencer par identifier laquelle (ou lesquelles) de ces valeurs sont incorrectes, puis continuer la procédure pour comprendre pourquoi elles le sont.

Lors de ce processus, il est important de collecter de l'information au sujet des valeurs manipulées par le programme, par exemple en les imprimant à l'écran. En Java, cela peut se faire facilement au moyen d'appels à la méthode println de System.out.

IntelliJ offre plusieurs « patrons dynamiques » (live templates) facilitant l'insertion de tels appels à println dans un programme. Le plus important d'entre eux se nomme soutv et permet d'insérer un appel à println affichant non seulement la valeur d'une expression quelconque, mais aussi la représentation textuelle de cette expression. Pour entrer ce patron, il suffit de taper soutv (sans oublier le v à la fin) dans le corps d'une méthode, puis d'appuyer sur la touche entrée (⮐) et enfin d'écrire l'expression à afficher. D'autres patrons similaires peuvent également être utiles, entre autres :

  • soutp, qui insère un appel à println affichant les noms et les valeurs des arguments passés à la méthode courante,
  • soutm, qui insère un appel à println affichant le nom de la classe et de la méthode courante.

L'utilisation de ces patrons permet de gagner beaucoup de temps.

L'affichage des valeurs manipulées par le programme permet souvent d'identifier lesquelles sont erronées. Parfois, il peut néanmoins être utile d'examiner le fonctionnement du programme en plus de détails, au moyen d'un débogueur (debugger) — un programme qui permet d'exécuter un programme pas à pas, d'examiner les valeurs qu'il manipule, d'arrêter l'exécution sous certaines conditions, etc. IntelliJ inclut un tel débogueur, mais la description de son utilisation sort du cadre de ce document ; les personnes intéressées se rapporteront donc à sa documentation.

5.4. Relire, simplifier et nettoyer le code

Rechercher la cause d'un problème implique de relire le code qui pourrait la contenir. Cette relecture doit être faite de manière très attentive, en remettant en cause chaque partie du code. Il peut être utile de l'effectuer à deux, une personne expliquant et justifiant chaque ligne à l'autre, dont le rôle est de mettre en doute ces explications.

Lors de la relecture, il n'est pas rare de constater que certaines parties du code ne sont pas aussi claires ou bien écrites qu'elles pourraient l'être. Dans ce cas, il peut valoir la peine de nettoyer et clarifier le code, par exemple en donnant de meilleurs noms aux variables, simplifiant certains algorithmes, introduisant des méthodes auxiliaires, et ainsi de suite. Cela peut rendre visible la cause du problème, voire même la faire disparaître.

Bien entendu, il faut prendre garde à ne pas introduire de nouvelles erreurs lors de ce processus.

5.5. Autres stratégies

Si les stratégies ci-dessus ne permettent pas de comprendre la cause du problème, il peut valoir la peine d'essayer d'utiliser les techniques suivantes :

Faire un dessin
De nombreux algorithmes peuvent très bien être représentés par un dessin, de même que l'organisation en mémoire d'un certain nombre d'objets liés entre eux, et ces représentations graphiques sont souvent extrêmement utiles lors de la recherche d'un problème.
Expliquer le problème
Lorsqu'un problème est difficile à comprendre, il peut être utile d'essayer de l'expliquer à quelqu'un d'autre, de manière aussi détaillée que possible, car il n'est pas rare qu'en faisant cela, on en comprenne la cause. Cette technique s'appelle la méthode du canard en plastique (rubber duck debugging) car elle fonctionne même si la « personne » à laquelle on explique le problème est un simple canard en plastique — ou n'importe quelle autre créature inanimée.
Passer à autre chose
Il arrive que même après avoir passé un temps considérable à la recherche de la cause d'un problème, celle-ci reste introuvable. Dans ce cas, il est souvent préférable d'abandonner temporairement la recherche plutôt que de persévérer. Le subconscient continue à travailler, et il n'est pas rare que la solution finisse par apparaître comme par magie, à un moment où on a cessé d'y penser consciemment.

6. Corriger

Une fois que le problème a été compris, il faut corriger l'erreur qui l'a provoqué.

Avant de faire cela, et dans la mesure du possible, il peut être utile d'écrire un test unitaire provoquant le problème — si celui-ci n'existe pas déjà. Un tel test, qui doit initialement échouer, permet d'une part de s'assurer que le problème a bien été compris, et d'autre part que le problème a bien disparu une fois que l'on pense avoir corrigé sa cause. Finalement, en relançant périodiquement ce test, on peut s'assurer que le problème n'a pas été réintroduit lors de modifications ultérieures.

Après avoir corrigé une erreur, il est également important de relancer tous les tests unitaires à disposition, afin de s'assurer qu'un nouveau problème détecté par l'un de ces tests n'a pas été introduit.

7. Références