1-04. Mettre au point un programme
The most effective debugging tool is still careful thought, coupled with judiciously placed print statements.
Brian Kernighan (co-auteur de "The C Programming Language")
Dans le développement, les bugs sont inévitables. Les programmeurs professionnels consacrent une partie non négligeable de leur temps au débugage. 😅 Comprendre pourquoi ces erreurs surviennent constitue la première étape pour les éviter et les résoudre efficacement. Dans ce chapitre, nous allons voir quelques outils et méthodes qui peuvent vous y aider, ainsi que des sources courantes de bugs.
Pourquoi les bugs surviennent-ils ?
Nature des erreurs de programmation
Les erreurs de programmation, communément appelées bugs, peuvent avoir diverses origines :
- Erreurs humaines : fautes d’inattention, compréhension incomplète du problème, hypothèses incorrectes.
- Complexité du code : plus un programme est complexe, plus il est susceptible de contenir des erreurs. C’est pourquoi il est important de toujours faire le plus simple possible (principe KISS – Keep It Simple, Stupid)
- Environnement d’exécution : variations des systèmes d’exploitation, versions des bibliothèques, ou configurations matérielles
- Interactions imprévues : comportements émergents lorsque différentes parties du code interagissent
Erreurs de syntaxe, d’exécution et de logique
En programmation, on distingue généralement trois types d’erreurs :1. Erreurs de syntaxe (SyntaxError)
Ce sont des erreurs dans l’écriture du code qui empêchent l’interpréteur de le comprendre.
# Erreur de syntaxe : guillements fermants manquant
if x > 10:
print("x est plus grand que 10)
L’interpréteur détecte ces erreurs avant l’exécution du programme et les signale précisément. Un bon IDE (Integrated development environment) est souvent capable de les repérer pendant l’écriture du code.
2. Erreurs d’exécution (RuntimeError)
Ces erreurs surviennent pendant l’exécution du programme lorsqu’une opération impossible est tentée.
# Erreur d’exécution : division par zéro
resultat = 10 / 0
# Erreur d’exécution : accès à un index inexistant
liste = [1, 2, 3]
element = liste[10]
Python interrompt l’exécution et affiche un message d’erreur avec une "traceback" qui aide à localiser le problème.
3. Erreurs de logique
Les plus difficiles à détecter, ces erreurs n’empêchent pas l’exécution du programme mais produisent des résultats incorrects.
# Erreur de logique : calcul de moyenne incorrect
def moyenne(liste):
return sum(liste) / len(liste) + 1 # Le "+1" est erroné
Ces erreurs ne génèrent pas de message d’erreur et nécessitent une analyse attentive du code et des résultats pour être identifiées.
Savoir reconnaître et catégoriser ces différents types d’erreurs est essentiel pour développer une méthodologie efficace de débogage et progressivement acquérir les réflexes qui permettront d’éviter ces bugs en amont.
Sources classiques de bugs
- Dans la pratique de la programmation, savoir répondre aux causes typiques de bugs : problèmes liés au typage, effets de bord non désirés, débordements dans les tableaux, instruction conditionnelle non exhaustive, choix des inégalités, comparaisons et calculs entre flottants, mauvais nommage des variables, etc.
En Python comme dans tout langage de programmation, certains types d’erreurs reviennent fréquemment. Apprendre à les reconnaître permet d’éviter nombre de problèmes.
Problèmes liés au typage
Python étant un langage à typage dynamique (c’est-à-dire qu’une variable peut changer de type – str, int – en cours d’exécution), les erreurs de type sont courantes :
# Tentative d’utiliser une méthode de chaîne sur un nombre
nombre = 42
resultat = nombre.lower() # AttributeError: 'int' object has no attribute 'lower'
# Concaténation incorrecte de types différents
age = 17
message = "J’ai " + age + " ans" # TypeError: can only concatenate str (not "int") to str
# Solution: "J’ai " + str(age) + " ans" ou mieux: f"J’ai {age} ans"
Calculs avec des flottants
Les calculs avec des nombres à virgule flottante peuvent être source de surprises :
# Problème de précision des flottants
0.1 + 0.2 == 0.3 # False, car 0.1 + 0.2 = 0.30000000000000004
# Comparaison risquée avec les flottants
x = 0.1 + 0.2
if x == 0.3: # Cette condition ne sera jamais vraie!
print("Égaux")
# Solution: utiliser une marge d’erreur (epsilon)
if abs(x - 0.3) < 1e-10:
print("Considérés comme égaux")
Problèmes de références et mutabilité
La distinction entre les types mutables et immutables est source de nombreux bugs :
# Piège des valeurs par défaut mutables
def ajouter(element, liste=[]): # liste vide créée une seule fois !
liste.append(element)
return liste
print(ajouter(1)) # [1]
print(ajouter(2)) # [1, 2] et non pas [2]!
# Solution:
def ajouter_correct(element, liste=None):
if liste is None: liste=[]
liste.append(element)
return liste
Variables globales et effets de bord
Les variables globales et les effets de bord sont fréquemment source de bugs difficiles à tracer :
total=0 # variable globale
def ajouter(x):
total +=x # UnboundLocalError: local variable 'total' referenced before assignment
return total
# Solution:
def ajouter(x):
global total # Déclarer l’intention d’utiliser la variable globale
total +=x
return total
De manière générale, il est toujours conseillé d’utiliser des variables dont la portée est limitée à la fonction, afin de ne pas modifier des variables globales du programme qui peuvent être utilisées ailleurs dans le script.
Problèmes d’indentation
En Python, l’indentation définit les blocs de code (le cauchemar pythonesque 😒), ce qui peut causer des comportements inattendus :
def recherche_element(liste, valeur):
for i in range(len(liste)):
if liste[i] == valeur:
trouve = True
else:
trouve = False
return trouve # Cette ligne devrait être indentée au même niveau que le for
# Test
print(recherche_element([10, 20, 30], 10)) # Retourne True (correct)
print(recherche_element([10, 20, 30], 30)) # Retourne False alors qu'on attendrait True
print(recherche_element([10, 20, 30], 40)) # Retourne False (correct)
Comparaisons et conditions mal formulées
Les erreurs dans les expressions conditionnelles sont fréquentes :
# Erreur classique dans les conditions
if x=10: # Erreur de syntaxe: affectation au lieu de comparaison
print("x vaut 10")
if x==5 or 6: # Ne signifie pas "x vaut 5 ou 6" mais "x vaut 5 OU 6 est True"
print("x vaut 5 ou 6") # Toujours exécuté car 6 est considéré comme True!
# Correct: if x==5 or x==6:
Identifier ces sources classiques de bugs vous aidera non seulement à déboguer plus efficacement, mais aussi à les éviter lors de l’écriture de vos programmes.
Nommage des variables
Des noms de variables (et de fonctions, de méthodes, de propriétés…) peu clairs peuvent générer des bugs difficiles à élucider, car alors le script est peu lisible.
# une fonction absconse
def test45 (x):
if x.ok:
return "ok"
else:
return "denied"
# la même fonction, version claire
def check_user_clearance(user: User):
if user.is_authorized:
log_message = "access granted"
else :
log_message = "access denied"
return log_message
Code spaghetti
Le « code spaghetti » désigne un style de programmation où le flux d'exécution est difficile à suivre. Ce type de code est souvent caractérisé par un usage excessif de branchements conditionnels complexes et imbriqués, ou d'une structure peu claire.
Le code spaghetti provoque plusieurs problèmes :
- Difficulté de compréhension : plus le temps passe, moins le code est compréhensible, même pour son auteur
- Difficulté de maintenance : modifier une partie peut affecter d'autres sections de façon imprévisible
- Difficultés de débogage : suivre l'exécution du programme devient un véritable casse-tête
Pour éviter le code spaghetti, il faut privilégier :
- Des fonctions courtes avec un objectif unique et clairement défini
- Une structure de contrôle simple et linéaire
- L’évitement des imbrications profondes de conditions
- Le découpage du code en composants logiques indépendants
Gérer les exceptions
Lorsque des erreurs surviennent pendant l’exécution d'un programme, le système génère des exceptions. Au lieu de laisser ces exceptions interrompre brutalement l’exécution, nous pouvons les « attraper » et les gérer de façon élégante.
Structure try/except/else/finally
Python offre une structure complète pour gérer les exceptions :
nombre = 2
try:
# Code susceptible de provoquer une exception
resultat = 10 / nombre
except ZeroDivisionError:
# Code exécuté si une division par zéro se produit
print("Impossible de diviser par zéro !")
except (TypeError, ValueError) as erreur:
# On peut gérer plusieurs types d'exceptions
# et récupérer l'objet exception dans une variable
print(f"Erreur de type : {erreur}")
except:
# Capture toutes les autres exceptions (à utiliser avec précaution)
print("Une erreur inattendue est survenue")
else:
# Code exécuté seulement si AUCUNE exception n'est levée
print(f"Le résultat est {resultat}")
finally:
# Code TOUJOURS exécuté, qu'une exception soit levée ou non
# Utile pour libérer des ressources (fermeture de fichiers, etc.)
print("Opération terminée")
- La clause
trycontient le code susceptible de générer une exception ; - Les clauses
exceptdéfinissent le comportement à adopter pour différentes exceptions - La clause
elses'exécute uniquement si aucune exception n'est levée - La clause
finallys'exécute dans tous les cas, même si une exception a été levée
Types d'exceptions courants
Python inclut de nombreux types d'exceptions prédéfinis :
# ZeroDivisionError : division par zéro
x = 1 / 0
# TypeError : opération sur des types incompatibles
"2" + 2
# ValueError : valeur inappropriée
int("abc")
# IndexError : index hors limites
liste = [1, 2, 3]
element = liste[10]
# KeyError : clé inexistante dans un dictionnaire
dict_exemple = {"a": 1}
valeur = dict_exemple["b"]
# FileNotFoundError : fichier introuvable
with open("fichier_inexistant.txt", "r") as f:
contenu = f.read()
# NameError : variable non définie
print(variable_non_definie)
Déclencher intentionnellement une exception
Il est parfois utile de lever volontairement une exception :
def verifier_age(age):
if age < 0:
raise ValueError("L'âge ne peut pas être négatif")
if age > 120:
raise ValueError("Âge peu probable")
return True
Création d'exceptions personnalisées
Il est possible de créer ses propres types d'exceptions.
La création d'exceptions personnalisées permet de mieux catégoriser les erreurs spécifiques à votre programme et de les traiter de façon appropriée.
En maîtrisant la gestion des exceptions, vous rendrez vos programmes plus robustes face aux situations imprévues.
Débogage et diagnostics
Interprétation des messages d'erreur
Quand Python rencontre une erreur à l’exécution, il affiche un message d’erreur appelé traceback. Savoir lire et interpréter ces messages est essentiel pour corriger rapidement les bugs.
Par exemple, le code ci-dessous :
liste = [1, 2, 3]
print(liste[5])
provoquera l’affichage suivant :
Traceback (most recent call last):
File "exemple.py", line 2, in <module>
print(liste[5])
IndexError: list index out of range
- Le dernier bloc indique le type d’erreur (
IndexError) et la cause (list index out of range). - La ligne fautive est indiquée (
print(liste[5])). - Lire attentivement le message permet souvent d’identifier rapidement la source du problème.
Techniques de traçage simples
Le traçage consiste à suivre l’exécution du programme pour comprendre ce qui se passe réellement. Vous avez plusieurs options :
- Utilisation de
print: c’est la méthode la plus simple. Elle consiste à insérer desprintà des endroits stratégiques du code pour afficher la valeur des variables ou l’avancement du programme. - Utilisation de l’assertion : l’instruction
Assertpermet de vérifier qu’une condition est vraie à un moment donné du programme. Si la condition est fausse, Python lève une exceptionAssertionErroret affiche un message. - Utilisation d’un débogueur : Certains environnements (comme Thonny, PyCharm, VSCode) proposent un débogueur qui permet d’exécuter le code pas à pas, d’inspecter les variables, et de poser des points d’arrêt (breakpoints). S’en servir n’est pas obligatoire en terminale, mais il est utile de savoir que ça existe.
Autres outils
Vous pouvez également utiliser les outils suivants :
- Outil de débogage de votre IDE (VS Code, Thony…). Cet outil étant spécifique à chaque IDE, consultez la documentation en ligne, ou une vidéo tuto, ou encore l’IA. Il s’gait d’un outil puissant mais un peu complexe à utiliser.
- Pythontutor, pour un outil plus simple à utiliser, en ligne.
- L’IA, mais attention à bien formuler vos prompts et à vérifier ce qui dit l’IA. Il lui arrive régulièrement de dire de la 💩. Ne copiez-collez jamais du code que vous ne comprenez pas. L’IA peut être un formidable outil pour apprendre et découvrir, ou elle peut vous rendre complètement déb*le… 😅
Tests
Les tests constituent une composante essentielle du développement logiciel professionnel. Ils permettent de vérifier qu'un programme fonctionne comme prévu et d’identifier les bugs avant qu’ils n’atteignent l’utilisateur final.
Un test unitaire vérifie qu’une unité de code spécifique (généralement une fonction) produit les résultats attendus pour des entrées données. L'idée est de tester chaque partie du programme de manière isolée.
Un jeu de tests complet devrait couvrir différents scénarios d'utilisation de votre fonction.
# Fonction à tester
def division_securisee(a, b):
"""Effectue une division en gérant les cas problématiques."""
if b == 0:
return None
return a / b
# Jeu de tests
def test_division_securisee():
# Tests de cas normaux
assert division_securisee(10, 2) == 5.0
assert division_securisee(7, 2) == 3.5
# Tests de cas spéciaux
assert division_securisee(0, 5) == 0.0
# Tests de gestion d'erreur
assert division_securisee(10, 0) is None
# Tests avec des nombres négatifs
assert division_securisee(-10, 2) == -5.0
assert division_securisee(10, -2) == -5.0
print("Tous les tests de division ont réussi !")
Le fait d’inclure tous les tests dans une fonction permet de les gérer et de les utiliser de manière plus facile. Si, pour une raison ou pour une autre, on modifie la fonction division_securisee, on peut facilement relancer les tests pour vérifier qu’elle se comporte toujours comme elle devrait.
Pour construire un bon jeu de tests, il faut inclure :
- Des cas d'utilisation standard
- Des cas limites ou particuliers
- Des situations d'erreur potentielles
- Des entrées de différents types ou formats
Test Driven Development
Le Test Driven Development (TDD – développement piloté par les tests) est une approche de programmation qui inverse le flux de développement traditionnel. En TDD, les tests sont écrits avant le code qu’ils testent. Cela force le développeur à réfléchir d’abord à ce que le code devrait faire plutôt qu'à comment il devrait le faire.
Avantages
- Clarté d'intention : les tests définissent clairement ce que le code doit faire
- Conception simplifiée : conduit naturellement à du code plus modulaire et moins couplé
- Régression limitée : les bugs corrigés ne réapparaissent pas
- Confiance accrue : les développeurs peuvent modifier le code sans craindre de casser des fonctionnalités
Le TDD nécessite une discipline initiale mais offre des bénéfices significatifs sur la qualité et la maintenabilité du code.
Bonnes pratiques préventives
Plutôt que de corriger les bugs après qu’ils se sont manifestés, il est préférable d'adopter des pratiques qui préviennent leur apparition. Voici trois approches essentielles utilisées par les développeurs professionnels.
Documentation du code
Une bonne documentation explique le fonctionnement du code, ses intentions et comment l'utiliser. En Python, on privilégie plusieurs niveaux de documentation :
Docstrings
Les docstrings sont des chaînes de caractères placées au début des modules, classes ou fonctions pour décrire leur usage.
def calculer_moyenne(nombres):
"""
Calcule la moyenne arithmétique d'une liste de nombres.
Args:
nombres (list): Liste des valeurs numériques
Returns:
float: La moyenne des valeurs, ou None si la liste est vide
Exemples:
>>> calculer_moyenne([1, 2, 3])
2.0
>>> calculer_moyenne([])
None
"""
if not nombres:
return None
return sum(nombres) / len(nombres)
Commentaires
Les commentaires expliquent les parties complexes ou non-évidentes du code. Une bonne pratique consiste à expliquer le « pourquoi » plutôt que le « comment » dans les commentaires, car le code lui-même devrait être assez clair sur ce qu'il fait.
Revue de code
La revue de code est une pratique où un développeur examine le code écrit par un autre. Bien qu'elle soit principalement utilisée en milieu professionnel, vous pouvez l'appliquer en contexte scolaire. 😊
- Échangez votre script avec un camarade
- Lisez le code en se demandant s'il est compréhensible
- Vérifier les cas limites et les situations exceptionnelles
- Suggérer des améliorations de façon constructive
Refactorisation
La refactorisation consiste à améliorer la structure interne du code sans en modifier le comportement externe.
Exemple de refactorisation
# Avant refactorisation
def analyser_notes(notes):
somme = 0
for i in range(len(notes)):
somme = somme + notes[i]
moyenne = somme / len(notes)
min_note = notes[0]
max_note = notes[0]
for i in range(len(notes)):
if notes[i] < min_note:
min_note = notes[i]
if notes[i] > max_note:
max_note = notes[i]
return moyenne, min_note, max_note
# Après refactorisation
def analyser_notes(notes):
"""Analyse une liste de notes et retourne moyenne, minimum et maximum."""
if not notes:
return None, None, None
# Utilisation des fonctions intégrées pour simplifier le code
moyenne = sum(notes) / len(notes)
min_note = min(notes)
max_note = max(notes)
return moyenne, min_note, max_note
Signes indiquant qu'une refactorisation est nécessaire :
- Code dupliqué (même logique répétée)
- Fonctions trop longues ou trop complexes
- Noms de variables ou fonctions peu clairs
- Multiples niveaux d'imbrication de conditions
La refactorisation régulière permet de maintenir un code propre et maintenable, ce qui facilite la détection et la correction des bugs.