Déboguer plutôt que deviner
Ce que ce chapitre apporte
- Lire une trace d'erreur et y trouver la ligne fautive.
- Expliquer ce qu'un débogueur apporte par rapport à un affichage bien placé.
- Employer point d'arrêt, exécution pas à pas, pile d'appels et expression surveillée.
- Poser un point d'arrêt conditionnel plutôt que d'avancer mille fois.
- Réduire un cas de reproduction jusqu'à ce qu'il tienne en quelques lignes.
- Reconnaître une recherche de panne mal conduite et la redresser.
Lire une trace avant tout le reste
Quand un programme s'arrête sur une erreur, il affiche la suite des appels qui y ont mené. C'est déjà la moitié du travail, à condition de la lire dans le bon sens.
Traceback (most recent call last):
File "rapport.py", line 42, in <module>
total = cumul(lignes)
File "rapport.py", line 31, in cumul
return somme_colonne(lignes, "montant")
File "rapport.py", line 22, in somme_colonne
return sum(float(l[nom]) for l in lignes)
ValueError: could not convert string to float: ''
Deux règles suffisent. La dernière ligne donne la nature du problème : une conversion en nombre a reçu une chaîne vide. Le bloc juste au-dessus donne l'endroit exact : ligne 22, dans somme_colonne. Le reste raconte comment on est arrivé là, ce qui devient utile quand la même fonction est appelée depuis plusieurs endroits.
Corriger à l'endroit où l'erreur éclate, par exemple en remplaçant les cellules vides par zéro, masque souvent un défaut situé bien plus haut.
Ce qu'un affichage ne peut pas faire
Ajouter un print reste une technique légitime, rapide, et parfois la seule disponible. Elle a trois limites.
Elle ne montre qu'un point du programme, celui où l'on a pensé à regarder. Si l'hypothèse de départ est fausse, l'affichage est au mauvais endroit et il faut recommencer.
Elle demande de relancer à chaque fois. Sur un programme qui met deux minutes à atteindre le point intéressant, la boucle d'essais devient interminable.
Elle laisse des traces. Les affichages oubliés finissent en production, et le jour où l'un d'eux affiche un mot de passe, la plaisanterie s'arrête.
Le débogueur
Un débogueur suspend l'exécution d'un programme à un endroit choisi et donne accès à tout son état : variables locales, variables globales, pile des appels, et la possibilité d'évaluer des expressions dans ce contexte.
Le vocabulaire est le même dans tous les outils, quel que soit le langage.
| Terme | Ce que c'est |
|---|---|
| Point d'arrêt | une ligne où l'exécution se suspend |
| Point d'arrêt conditionnel | le même, mais seulement quand une condition est vraie |
| Pas à pas principal | exécuter la ligne suivante, sans entrer dans les appels |
| Pas à pas détaillé | entrer dans la fonction appelée |
| Pas à pas sortant | finir la fonction courante et revenir à l'appelant |
| Pile d'appels | qui a appelé qui, jusqu'au point courant |
| Expression surveillée | une expression réévaluée à chaque arrêt |
Le point d'arrêt conditionnel est le raccourci le plus rentable. Pour examiner la 4 000e itération d'une boucle, on ne clique pas 4 000 fois : on pose la condition i == 4000, ou mieux, la condition qui décrit l'anomalie, par exemple total < 0. L'exécution s'arrête au moment précis où l'état devient faux, ce qui est exactement l'information cherchée.
Voir l'état changer
Un débogueur montre l'état à chaque pas. On peut obtenir la même chose en instrumentant le code, et l'exercice vaut d'être fait une fois, car il montre à quoi ressemble une recherche de panne.
Voici une recherche dichotomique qui ne trouve pas toujours ce qu'elle cherche. Le journal affiche, à chaque appel, l'intervalle examiné.
Regardez la colonne des intervalles dans la première trace. [4, 5] revient, identique, appel après appel. Une dichotomie dont l'intervalle ne rétrécit plus ne terminera jamais, et la cause est dans la ligne qui calcule le nouveau bas : reprendre milieu au lieu de milieu + 1 laisse la même case dans l'intervalle.
Une démarche, en cinq temps
Reproduire. Un défaut qu'on ne sait pas déclencher ne peut pas être corrigé, seulement deviné. Notez les données exactes, la version, les paramètres. Si l'anomalie est intermittente, cherchez d'abord ce qui varie entre deux exécutions.
Réduire. Enlevez tout ce qui n'est pas nécessaire à l'apparition du défaut : moins de données, moins d'étapes, moins de code. Un cas de reproduction de dix lignes livre souvent la cause avant même d'avoir été analysé.
Localiser. Encadrez la zone fautive. L'état est correct ici, faux là-bas ; coupez en deux et recommencez. C'est la même idée que la dichotomie précédente, appliquée au programme, et c'est aussi le principe de git bisect, qui cherche par le même procédé le commit qui a introduit le défaut.
Comprendre. Formulez une hypothèse qui explique tout ce que vous observez, y compris ce qui fonctionne. Une hypothèse qui n'explique que la moitié des symptômes est fausse.
Corriger et vérifier. Un seul changement à la fois, puis un test qui échoue avant la correction et passe après. Sans ce test, rien ne garantit que le défaut ne reviendra pas.
Un changement, un essai. Si le programme fonctionne toujours, on annule le changement et on continue de chercher.
Quelques cas courants
Le défaut disparaît quand on l'observe. Souvent un problème de temps : deux traitements concurrents dont l'ordre change quand l'un est ralenti par l'affichage. Le chapitre sur les threads du bloc génie logiciel traite le fond de la question.
Le programme marche chez vous, pas ailleurs. Ce n'est presque jamais le code. Regardez du côté de la version des dépendances, des variables d'environnement, du contenu réel des fichiers de données, des chemins relatifs et du dossier courant. Le chapitre sur les dépendances y revient.
L'erreur vient d'une bibliothèque. La trace traverse du code que vous n'avez pas écrit. Remontez la pile jusqu'à votre dernière ligne : c'est presque toujours là que se trouve l'argument mal formé qui a déclenché le reste.
Exercices type
Dans quel sens lit-on une trace d'erreur ?
On commence par la dernière ligne, qui donne le type de l'erreur et son message. C'est elle qui dit ce qui s'est passé.
On lit ensuite le bloc immédiatement au-dessus, qui donne le fichier, la ligne et la fonction où l'erreur a éclaté.
On remonte le reste seulement si nécessaire, pour savoir par quel chemin on est arrivé là. C'est utile quand la fonction fautive est appelée depuis plusieurs endroits, car la trace dit lequel a posé problème.
Quand un point d'arrêt conditionnel vaut-il mieux qu'un point d'arrêt simple ?
Dès que le passage intéressant n'est pas le premier.
Sur une boucle de milliers d'itérations, un point d'arrêt simple suspend l'exécution à chaque tour, et il faudrait relancer des milliers de fois pour atteindre celui qui pose problème.
La bonne condition n'est d'ailleurs pas toujours un numéro d'itération. Une condition qui décrit l'anomalie elle-même, par exemple solde < 0 ou nom is None, arrête l'exécution exactement à l'instant où l'état devient incohérent, ce qui répond directement à la question.
Pourquoi réduire un cas de reproduction avant de chercher la cause ?
Parce que la réduction est elle-même une recherche. Chaque élément qu'on retire et dont le retrait fait disparaître le défaut est un indice direct sur la cause.
Parce qu'un petit cas s'exécute vite, ce qui raccourcit chaque essai.
Et parce qu'un petit cas est communicable : c'est ce qu'on joint à un rapport d'anomalie, et souvent ce qui devient le test de non-régression une fois la correction faite.
Une dichotomie boucle indéfiniment. Que montre la trace des intervalles ?
Que l'intervalle cesse de rétrécir : les mêmes bornes reviennent d'un appel à l'autre.
La cause est dans la mise à jour des bornes. Si l'élément du milieu, déjà comparé, reste dans l'intervalle suivant, la même comparaison se refait indéfiniment. Il faut exclure le milieu, donc milieu + 1 d'un côté et milieu - 1 de l'autre.
C'est le type de défaut qu'on ne voit pas en relisant le code, car la ligne fautive a l'air juste, et qu'on voit immédiatement dans la suite des états.
« J'ai corrigé, ça marche. » Que manque-t-il ?
La preuve que la correction porte bien sur la cause.
Il faut pouvoir dire pourquoi le défaut se produisait, et pourquoi la modification l'empêche. Sans cette explication, le programme peut fonctionner par coïncidence.
Il faut aussi un test qui échouait avant la correction et passe après. C'est la seule chose qui empêche le défaut de revenir dans six mois, quand quelqu'un touchera à la même fonction.
Enfin, il faut avoir vérifié que le reste fonctionne toujours, ce qui suppose d'exécuter la suite de tests entière.
La méthode
- Lis la trace avant de toucher au code : dernière ligne, puis bloc du dessus.
- Reproduis le défaut de façon fiable avant de chercher à le comprendre.
- Réduis le cas jusqu'à ce qu'il tienne en quelques lignes.
- Pose un point d'arrêt conditionnel sur la condition qui décrit l'anomalie.
- Observe la suite des états, pas seulement la valeur finale.
- Un changement à la fois, et annule celui qui ne change rien.
- Écris le test qui échouait avant la correction, sinon le défaut reviendra.
En résumé
- La dernière ligne d'une trace dit la nature de l'erreur, le bloc au-dessus dit l'endroit.
- L'erreur éclate souvent loin de sa cause.
- Un débogueur suspend l'exécution et donne accès à tout l'état, sans relancer le programme.
- Le point d'arrêt conditionnel évite les milliers de passages inutiles.
- Un défaut se lit dans la suite des états : une valeur qui ne bouge pas, un intervalle qui ne rétrécit pas.
- La démarche tient en cinq temps : reproduire, réduire, localiser, comprendre, vérifier.
- Localiser par dichotomie vaut pour le code comme pour l'historique, avec
git bisect. - Une correction sans test de non-régression est provisoire.
Et ensuite ? Corriger suppose de pouvoir revenir en arrière et de savoir ce qui a changé. C'est l'objet du chapitre suivant : Git au quotidien.