[E.5] Mise au point des programmes, gestion des bugs

Programme

Contenus

Capacités attendues

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ébordement dans les tableaux, instruction conditionnelle non exhaustive, choix des inégalités, comparaisons et calculs entre flottants, mauvais nommage des variables, etc.

Commentaires

On prolonge le travail entrepris en classe de première sur l’utilisation de la spécification, des assertions, de la documentation des programmes et de la construction de jeux de tests. Les élèves apprennent progressivement à anticiper leurs erreurs.

Causes typiques de bugs

Typage

Introduction

Python est dynamiquement typé : une variable peut changer de type → source d’erreurs silencieuses

Mélange de types incompatibles

age = input("Âge ? ")   # age est une str !
print(age + 1)           # TypeError: can only concatenate str (not "int") to str
# Correction :
print(int(age) + 1)

Changement de type en cours de programme

total = 0
for val in [10, "20", "30"]:
    total += val          # TypeError à la 2ère itération
    # Correction : total += int(val)

Écrasement accidentel d’une variable

i = 42                    # variable importante
for i in range(5):        # i est écrasé !
    print(i)
print(i)                  # affiche 4, pas 42
# Correction : choisir un autre nom de variable

Effets de bord non désirés

Introduction

Un effet de bord : une fonction modifie quelque chose en dehors de son périmètre.

Paramètre mutable comme valeur par défaut

def ajouter(val, liste=[]):   # PIÈGE : [] créé une seule fois
    liste.append(val)
    return liste

print(ajouter(1))   # [1]
print(ajouter(2))   # [1, 2]  ← pas [2] !

# Correction :
def ajouter(val, liste=None):
    if liste is None:
        liste = []
    liste.append(val)
    return liste

Mutation d’un argument

def doubler(lst):
    for i in range(len(lst)):
        lst[i] *= 2 # modifie la liste
    return lst

notes = [10, 15, 8]
dnotes = doubler(notes)
print(notes)              # [20, 30, 16] — notes a été modifié !

# Correction : travailler sur une copie
def doubler(lst):
    return [x * 2 for x in lst]

Débordement dans les tableaux

Introduction

Tri à bulles — erreur classique

def tri_bulles(lst):
    n = len(lst)
    for i in range(n):
        for j in range(n - i):      # BUG : 1ère itération
            if lst[j] > lst[j + 1]:
                lst[j], lst[j + 1] = lst[j + 1], lst[j]

# Correction : réduire la borne intérieure
        for j in range(n - 1 - i):  # on ne déborde pas

Instructions conditionnelles non exhaustives

Introduction

Exemple — classification d’une note

def mention(note):
    if note >= 16:
        return "Très bien"
    elif note >= 14:
        return "Bien"
    elif note >= 12:
        return "Assez bien"
    # Oubli : que retourne la fonction pour note < 12 ?
    # → retourne None implicitement
print(mention(8))   # None — pas d'erreur, mais résultat faux !
# Correction : ajouter un else
    else:
        return "Passable ou insuffisant"

Flottants

Introduction

Les flottants sont des approximations (norme IEEE 754) → jamais comparer avec ==

Comparaison directe

0.1 + 0.2 == 0.3    # False !
print(0.1 + 0.2)    # 0.30000000000000004

Comparaison correcte — avec un seuil

EPSILON = 1e-9
abs((0.1 + 0.2) - 0.3) < EPSILON   # True
# Ou avec le module math :
import math
math.isclose(0.1 + 0.2, 0.3)       # True

Accumulation d’erreurs d’arrondi

total = 0.0
for _ in range(1000):
    total += 0.1
print(total)          # 99.9999999999986...  ≠ 100.0

Nommage des variables

Introduction

Exemple

# Illisible, non typé
def f(l):
    r = 0
    for x in l:
        r += x
    return r / len(l)

# Lisible, typé
def moyenne(notes: list[float]) -> float: 
    total = 0.0
    for note in notes:
        total += note
    return total / len(notes)

Bonnes pratiques : typage

Annotations de types

Types de base

def aire_rectangle(largeur: float, hauteur: float) -> float:
    return largeur * hauteur

age: int = 17
nom: str = "Alice"

Union et optionnel

def premier(lst: list[int]) -> int | None:
    return lst[0] if lst else None

def age_humain(age: int | float) -> str:
    return f"{int(age)} ans"

# Ancienne syntaxe (Python < 3.10) :
from typing import Optional, Union
def premier(lst: list[int]) -> Optional[int]: ...
def age_humain(age: Union[int, float]) -> str: ...

Listes, tuples, ensembles

notes: list[float] = [12.5, 15.0, 9.0]
coords: tuple[float, float] = (48.85, 2.35)   # taille fixe
rgb: tuple[int, ...]  = (255, 128, 0)          # taille variable
identifiants: set[int] = {1, 2, 3}

Dictionnaires

# dict simple
scores: dict[str, int] = {"Alice": 17, "Bob": 14}

# valeurs hétérogènes → TypedDict
from typing import TypedDict

class Eleve(TypedDict):
    nom: str
    age: int
    notes: list[float]

alice: Eleve = {"nom": "Alice", "age": 17, "notes": [15.0, 12.5]}

Literal et Callable

from typing import Literal, Callable

# Literal : valeur parmi un ensemble fini
Direction = Literal["nord", "sud", "est", "ouest"]

def avancer(d: Direction) -> None: ...

avancer("nord")   # OK
avancer("haut")   # erreur détectée par mypy/pyright

# Callable : type d'une fonction
def appliquer(f: Callable[[float], float], x: float) -> float:
    return f(x)

appliquer(abs, -3.0)   # OK

Vérifications statiques

Outils

Exemple avec mypy

# fichier : exemple.py
def carre(n: int) -> int:
    return n * n

print(carre("3"))    # erreur de type
$ mypy exemple.py
exemple.py:4: error: Argument 1 to "carre" has incompatible type
                     "str"; expected "int"

Dans VS Code

Bonnes pratiques : documentation

Docstrings

Introduction

Format simple

def carre(n: float) -> float:
    """Retourne le carré de n."""
    return n ** 2

Format détaillé (reST)

def diviser(a: float, b: float) -> float:
    """Divise a par b.

    :param a: le numérateur.
    :param b: le dénominateur (doit être non nul).
    :returns: le résultat de la division a / b.
    :raises ValueError: si b vaut 0.
    """
    if b == 0:
        raise ValueError("Division par zéro")
    return a / b

Exercice 1 — annotations de base

Énoncé

Annoter les types des fonctions suivantes :

def est_pair(n):
    return n % 2 == 0

def concatener(mots, separateur):
    return separateur.join(mots)

def maximum(a, b):
    return a if a > b else b

def inverser(lst):
    return [lst[i] for i in range(len(lst) - 1, -1, -1)]

Correction

def est_pair(n: int) -> bool:
    return n % 2 == 0

def concatener(mots: list[str], separateur: str) -> str:
    return separateur.join(mots)

def maximum(a: float, b: float) -> float:
    return a if a > b else b

def inverser(lst: list[int]) -> list[int]:
    return [lst[i] for i in range(len(lst) - 1, -1, -1)]

Exercice 2 — TypedDict et arguments optionnels

Énoncé

def have_gmail(u):
    return u.get("email", "").endswith("@gmail.com")

def display_user(u, formel=False):
    if formel:
        return f"M./Mme {u['nom'].upper()}"
    return f"{u['prenom']} {u['nom'].upper()}"
    
user = {"nom": "Dupont", "prenom": "Alice"}
print(have_gmail(user))          # False
print(display_user(user))        # Alice DUPONT
print(display_user(user, True))  # M./Mme DUPONT    

Correction

from typing import TypedDict, NotRequired

class Utilisateur(TypedDict):
    nom: str
    prenom: str
    email: NotRequired[str]

def have_gmail(u: Utilisateur) -> bool:
    return u.get("email", "").endswith("@gmail.com")

def display_user(u: Utilisateur, formel: bool = False) -> str:
    if formel:
        return f"M./Mme {u['nom'].upper()}"
    return f"{u['prenom']} {u['nom'].upper()}"

user: Utilisateur = {"nom": "Dupont", "prenom": "Alice"}
print(have_gmail(user))          # False
print(display_user(user))        # Alice DUPONT
print(display_user(user, True))  # M./Mme DUPONT

Bonnes pratiques : tests

Assertions

Syntaxe et usage

def racine(n: float) -> float:
    assert n >= 0, f"n doit être positif, reçu : {n}"
    return n ** 0.5

racine(-1)   # AssertionError: n doit être positif, reçu : -1

Tests unitaires avec pytest

Mise en place

Exemple

# fichier : test_chaines.py
from chaines import inverser, concatener

def test_inverser_liste():
    assert inverser([1, 2, 3]) == [3, 2, 1]

def test_inverser_un_element():
    assert inverser([42]) == [42]

def test_concatener():
    assert concatener(["a", "b", "c"], "-") == "a-b-c"
$ pytest test_chaines.py
...                          # 3 tests passés
3 passed in 0.05s

Bonnes pratiques

import pytest

def test_inverser_vide():
    assert inverser([]) == []

def test_concatener_separateur_vide():
    assert concatener(["a", "b"], "") == "ab"

Couverture de code

Exercice — tests d’une classe avec pytest

Énoncé

class Pile:
    def __init__(self):
        self._data = []

    def empiler(self, val):
        self._data.append(val)

    def depiler(self):
        if self.est_vide():
            raise IndexError("Pile vide")
        return self._data.pop()

    def sommet(self):
        if self.est_vide():
            raise IndexError("Pile vide")
        return self._data[-1]

    def est_vide(self):
        return len(self._data) == 0

Écrire un fichier test_pile.py testant : pile vide, empilement, dépilement, sommet, et les erreurs sur pile vide.

Correction

# fichier : test_pile.py
import pytest
from pile import Pile

def test_pile_vide_au_depart():
    p = Pile()
    assert p.est_vide()

def test_empiler_rend_non_vide():
    p = Pile()
    p.empiler(1)
    assert not p.est_vide()

def test_sommet_apres_empilement():
    p = Pile()
    p.empiler(10)
    p.empiler(20)
    assert p.sommet() == 20      # sommet ne dépile pas

def test_depiler_ordre_lifo():
    p = Pile()
    p.empiler(1)
    p.empiler(2)
    p.empiler(3)
    assert p.depiler() == 3
    assert p.depiler() == 2

def test_depiler_vide_leve_erreur():
    p = Pile()
    with pytest.raises(IndexError):
        p.depiler()

def test_sommet_vide_leve_erreur():
    p = Pile()
    with pytest.raises(IndexError):
        p.sommet()