Adloun

La discipline de programmation

Cours complet · informatique (tronc commun des prépas scientifiques), chapitre 1 · prépas scientifiques, tronc commun

Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre

<i class="fa-solid fa-compass mr-2" style="color:#9A563B"></i>1.1 Introduction et motivation

Au lycée, on apprend à faire marcher un programme. En classes préparatoires, on apprend à savoir pourquoi il marche — et à le dire. La différence n'est pas un raffinement d'esthète : un programme qui semble fonctionner sur deux exemples peut échouer sur le troisième, et l'histoire de l'informatique est jalonnée de catastrophes nées d'un code non spécifié, non testé, non relu. Ce premier chapitre met en place les habitudes qui serviront pendant deux ans (et toute une vie professionnelle) : un environnement de travail maîtrisé, des fonctions spécifiées avec précision, des programmes annotés et commentés à bon escient, et des jeux de tests construits méthodiquement. Il s'achève sur une première rencontre avec les deux outils de validation qui structureront tout le cours : le variant, qui garantit qu'une boucle s'arrête, et l'invariant, qui garantit qu'elle calcule la bonne chose.

Le mot d'ordre du programme officiel est limpide : faire bien plutôt que beaucoup. Une fonction de cinq lignes, spécifiée, testée et justifiée, vaut mieux que cinquante lignes dont personne — pas même leur auteur — ne sait exactement ce qu'elles font.

1.2 L'environnement de travail

1.2.1 L'interpréteur et les scripts

Définition 1.1Interpréteur Python

L'interpréteur Python est le programme qui lit du code Python et l'exécute. On l'utilise de deux façons complémentaires :

  • en mode interactif (la console, reconnaissable à son invite &gt;{>{}>}) : chaque expression saisie est immédiatement évaluée et son résultat affiché — idéal pour expérimenter ;
  • en mode script : les instructions sont enregistrées dans un fichier .py, exécuté d'un bloc — c'est la forme de tout programme durable.

>>> 2 + 3
5
>>> [k ** 2 for k in range(6)]
[0, 1, 4, 9, 16, 25]
iRemarque

La console n'affiche spontanément que les résultats du mode interactif. Dans un script, rien ne s'affiche sans print : un calcul dont le résultat n'est ni affiché, ni renvoyé, ni enregistré est tout simplement perdu.

1.2.2 Le cycle de travail et les trois familles d'erreurs

Programmer, c'est itérer le cycle écrire → exécuter → observer → corriger. Les incidents rencontrés en chemin se classent en trois familles, qu'il faut apprendre à distinguer car elles ne se traitent pas du tout de la même façon.

Définition 1.2Les trois familles d'erreurs
  • Erreur de syntaxe (SyntaxError) : le texte n'est pas du Python valide — deux-points oublié, parenthèse non fermée. L'interpréteur refuse même de commencer l'exécution et désigne la ligne fautive.
  • Erreur d'exécution (exception) : le programme démarre puis s'interrompt sur une opération impossible — division par zéro (ZeroDivisionError), indice hors borne (IndexError), types incompatibles (TypeError), nom inconnu (NameError).
  • Erreur de logique : le programme s'exécute sans broncher… et rend un résultat faux. C'est la plus dangereuse, car rien ne la signale : seuls la spécification et les tests peuvent la débusquer.
Exemple 1.3Trois erreurs sur la même fonction

On veut calculer la moyenne d'une liste non vide de nombres.


def moyenne(t):
    s = 0
    for x in t
        s = s + x          # SyntaxError : il manque « : » après « for x in t »
    return s / len(t)

Une fois la syntaxe corrigée, moyenne([]) lève ZeroDivisionError : erreur d'exécution. Et la variante suivante, syntaxiquement correcte et qui ne plante jamais sur une liste non vide, est pourtant fausse :


def moyenne(t):
    s = 0
    for x in t:
        s = s + x
        return s / len(t)   # erreur de LOGIQUE : le return est DANS la boucle

moyenne([2, 4, 6]) renvoie au lieu de : la fonction s'arrête dès le premier passage. Aucun message d'erreur — seule la confrontation à un résultat attendu révèle le problème.

Important

Lire un message d'erreur de bas en haut : la dernière ligne donne la nature de l'erreur, les lignes au-dessus (la traceback) localisent l'endroit exact, fichier et numéro de ligne. Un message d'erreur n'est pas une sanction : c'est l'information de débogage la plus précieuse qui soit.

1.2.3 Le bagage du lycée

Le présent cours suppose acquis le noyau du langage vu au lycée. Le tableau suivant le récapitule — chacune de ces constructions sera réutilisée dès ce chapitre.

ConstructionExempleRôle
Affectation`x = 3`lier un nom à une valeur
Types de base`int, float, bool, str`entiers, flottants, booléens, chaînes
Conditionnelle`if ... elif ... else`exécution selon un test
Boucle bornée`for k in range(n)`répéter un nombre connu de fois
Boucle conditionnelle`while c`répéter tant qu'une condition tient
Fonction`def f(x): ... return y`nommer et réutiliser un calcul
Liste`t = [1, 2, 3]` ; `t[i]` ; `len(t)`tableau d'éléments indexés de à
Parcours`for x in t`visiter les éléments un à un
Attention

En Python, les indices d'un tableau de longueur vont de à , et range(a, b) énumère les entiers de inclus à exclu. La moitié des erreurs d'indices d'une année de prépa se cache dans cette convention : on prendra l'habitude de relire chaque borne en se demandant « inclus ou exclu ? ».

1.3 Spécifier une fonction

1.3.1 Le contrat

Définition 1.4Spécification

La spécification d'une fonction est le contrat qui la lie à ses utilisateurs. Elle précise :

  • la signature : le nom de la fonction, le nombre, l'ordre et le type de ses paramètres, le type de son résultat ;
  • les préconditions : les hypothèses faites sur les arguments, que l'appelant s'engage à respecter (« la liste est non vide », « ») ;
  • les postconditions : ce que la fonction garantit en retour si les préconditions sont satisfaites (« renvoie le plus grand élément de t »).

Si l'appelant viole une précondition, la fonction ne promet plus rien : le contrat est rompu.

Exemple 1.5Un contrat complet

def maximum(t):
    """Renvoie le plus grand élément de la liste t.

    Précondition : t est une liste non vide de nombres.
    Postcondition : le résultat m appartient à t, et m >= x
    pour tout élément x de t.
    """
    m = t[0]
    for x in t:
        if x > m:
            m = x
    return m

Le texte entre triples guillemets est la docstring : c'est la spécification embarquée dans le code, accessible par help(maximum). Noter la double exigence de la postcondition : majore tous les éléments et appartient au tableau — la seconde clause interdit de renvoyer, par exemple, .

Important

Une spécification décrit quoi, jamais comment. « Renvoie le plus grand élément de t » est une spécification ; « parcourt la liste en mettant à jour un maximum courant » est une implémentation. Le contrat doit rester vrai si l'on remplace l'algorithme par un autre — c'est précisément ce qui rend le code remplaçable, testable et réutilisable.

Méthode : Écrire une spécification

Avant d'écrire la moindre ligne du corps d'une fonction, répondre par écrit à quatre questions :

  • Quelles sont les entrées, et de quels types ?
  • Quelles hypothèses fais-je sur elles (préconditions) ?
  • Que renvoie la fonction, et de quel type ?
  • Quelle propriété précise relie le résultat aux entrées (postcondition) ?

Si l'on ne sait pas répondre à la question 4 sans ambiguïté, on ne sait pas encore quel programme on veut écrire — et il est inutile de commencer à taper.

1.3.2 Les cas limites font partie du contrat

Exemple 1.6Une spécification qui tranche les cas limites

Que doit renvoyer la recherche d'un élément absent ? La spécification doit le dire, sinon chaque utilisateur l'imaginera à sa façon :


def indice(v, t):
    """Renvoie le plus petit indice i tel que t[i] == v,
    ou None si v n'apparaît pas dans t.

    Précondition : t est une liste.
    """
    for i in range(len(t)):
        if t[i] == v:
            return i
    return None

Trois décisions de contrat sont prises ici : l'indice renvoyé est le plus petit (et non un indice quelconque), l'absence est signalée par None (et non par ou par une erreur), et la liste vide est licite (la fonction renvoie alors None). Aucune n'est « la bonne » dans l'absolu — mais toutes doivent être écrites.

1.4 Annotations et commentaires

1.4.1 Les annotations de type

Définition 1.7Annotations

Python permet d'annoter les paramètres et le résultat d'une fonction par leurs types attendus :


def maximum(t: list) -> float:
    ...

Ces annotations sont de la documentation vérifiable : l'interpréteur ne les fait pas respecter à l'exécution, mais elles fixent la signature d'un coup d'œil et des outils externes peuvent les contrôler. Dans ce cours, on annotera systématiquement les fonctions des énoncés et des corrigés.

1.4.2 Commenter : le pourquoi, pas le quoi

Méthode : Bons et mauvais commentaires

Un commentaire ne doit jamais paraphraser le code — il doit dire ce que le code ne peut pas dire :


i = i + 1        # on incrémente i           <- inutile : le code le dit déjà
i = i + 1        # on saute le séparateur     <- utile : la RAISON du geste

Trois usages légitimes : expliquer une intention non évidente, signaler une subtilité (piège d'indice, cas limite), énoncer un invariant (section suivante). Tout le reste est du bruit qui vieillit mal : quand le code change, le commentaire-paraphrase ment.

iRemarque

La meilleure documentation reste un bon nommage : nb_voyelles se passe de commentaire, n2 jamais. Les noms d'une lettre sont réservés aux usages consacrés — , , pour des indices, pour une taille, pour un élément courant.

1.5 Les jeux de tests

1.5.1 Tester avec assert

Définition 1.8Jeu de tests

Un jeu de tests est une collection de couples (entrée, résultat attendu) confrontés au programme. En Python, l'instruction


assert expression

ne fait rien si expression vaut True, et interrompt le programme avec AssertionError sinon. Une suite d'assertions qui s'exécute en silence est un certificat : tous les cas prévus passent.

Exemple 1.9Jeu de tests de `indice`

assert indice(3, [1, 3, 2, 3]) == 1     # présent deux fois : le PREMIER indice
assert indice(7, [1, 3, 2]) is None     # absent
assert indice(1, [1]) == 0              # liste à un élément
assert indice(5, []) is None            # liste vide
assert indice(2, [2, 2, 2]) == 0        # tous égaux
print("indice : tous les tests passent")

Méthode : Construire un jeu de tests

Un bon jeu de tests se construit depuis la spécification, jamais depuis le code (on ne teste pas ce qu'on a écrit, on teste ce qu'on a promis). On y fait figurer systématiquement :

  • des cas nominaux — les situations ordinaires, calculables à la main ;
  • des cas limites — liste vide ou à un élément, , valeur en première ou en dernière position, éléments tous égaux, valeurs extrêmes ou négatives ;
  • des cas de chaque branche — chaque if du contrat (élément présent / absent, par exemple) doit être exercé au moins une fois ;
  • quand c'est possible, un test de propriété — vérifier une relation que tout résultat doit satisfaire (le maximum appartient à la liste), éventuellement sur des entrées tirées au hasard.
Important

Tester n'est pas prouver. Un jeu de tests ne peut exhiber que la présence d'erreurs, jamais leur absence : il y a une infinité d'entrées possibles et l'on n'en essaie qu'un nombre fini. Le test élimine les fautes grossières et protège contre les régressions ; la preuve — par variant et invariant — garantit la correction sur toutes les entrées. Les deux outils sont complémentaires, et le programme de prépa exige les deux.

Exemple 1.10Un bogue que les tests naïfs ne voient pas

La fonction suivante prétend renvoyer le maximum d'une liste non vide :


def maximum_faux(t: list) -> float:
    m = 0                      # et si tous les éléments sont négatifs ?
    for x in t:
        if x > m:
            m = x
    return m

Les tests maximum_faux([1, 5, 3]) == 5 et maximum_faux([2]) == 2 passent. Mais maximum_faux([-3, -1]) renvoie — qui n'appartient même pas à la liste. C'est le cas limite « tous négatifs » qui révèle la faute d'initialisation : il faut partir de m = t[0], pas de . Moralité : les cas limites ne sont pas une coquetterie, ce sont eux qui tuent les bogues d'initialisation.

1.6 Premiers outils de validation : variant et invariant

Tester ne suffit pas : pour garantir qu'une boucle est correcte, il faut deux arguments mathématiques. Le premier assure que la boucle s'arrête, le second qu'elle calcule la bonne chose. Ce chapitre les introduit sur des exemples simples ; ils seront notre outil quotidien dès le chapitre 3.

1.6.1 Le variant : la boucle s'arrête

Définition 1.11Variant de boucle

Un variant d'une boucle while est une quantité entière qui :

  • est positive ou nulle tant que la boucle s'exécute ;
  • décroît strictement à chaque tour.

Une suite d'entiers positifs strictement décroissante est finie : si un variant existe, la boucle termine.

Exemple 1.12Terminaison d'un compte à rebours déguisé

def nb_chiffres(n: int) -> int:
    """Renvoie le nombre de chiffres de l'écriture décimale de n.
    Précondition : n >= 1."""
    c = 0
    while n > 0:
        n = n // 10
        c = c + 1
    return c

La quantité est un variant : elle est positive tant que la boucle tourne (condition n &gt; 0), et la division entière par la fait strictement décroître dès que . La boucle termine donc — en fait en exactement autant de tours que a de chiffres.

Attention

Une boucle for k in range(n) termine toujours : son nombre de tours est fixé d'avance. Le variant n'est nécessaire que pour les boucles while, dont la condition d'arrêt dépend du calcul lui-même. C'est aussi pourquoi une faute dans un while peut produire une boucle infinie — le programme ne plante pas, il ne rend simplement jamais la main : l'erreur la plus silencieuse qui soit.

1.6.2 L'invariant : la boucle calcule juste

Définition 1.13Invariant de boucle

Un invariant d'une boucle est une propriété portant sur les variables du programme, telle que :

  • est vraie avant le premier tour (initialisation) ;
  • si est vraie au début d'un tour, elle est encore vraie à la fin de ce tour (conservation).

Par récurrence, est alors vraie à la sortie de la boucle ; combinée à la condition d'arrêt, elle livre la correction du calcul.

Exemple 1.14Correction de la somme

def somme(t: list) -> float:
    """Renvoie la somme des éléments de t."""
    s = 0
    for i in range(len(t)):
        # Invariant : s == t[0] + t[1] + ... + t[i-1]
        s = s + t[i]
    return s

Notons : « au début du tour d'indice , » (somme vide ).

  • Initialisation : avant le premier tour (), est bien la somme vide. ✓
  • Conservation : si au début du tour, l'instruction s = s + t[i] donne : c'est . ✓
  • Conclusion : à la sortie, a parcouru tous les indices et : la postcondition est établie, pour toute liste .

Méthode : Trouver l'invariant

L'invariant répond toujours à la même question : « au milieu du travail, qu'est-ce qui est déjà acquis ? ». Pour une boucle qui parcourt un tableau, la réponse a presque toujours la forme : « le résultat est correct pour la partie déjà vue ». S'entraîner à l'écrire en français précis avant de le formaliser : un invariant qu'on ne sait pas dire, on ne sait pas le prouver.

iRemarque

Variant et invariant sont les homologues informatiques de deux outils de mathématiques : le variant est une descente infinie impossible (toute partie non vide de a un plus petit élément), l'invariant est une récurrence. Le programme d'informatique et celui de mathématiques se serrent ici la main.

<i class="fa-solid fa-dumbbell mr-2" style="color:#2E7559"></i>1.7 Exercices résolus

Niveau (Application directe du cours)

Exercice 1 : Diagnostiquer trois erreurs

Pour chacun des fragments suivants, dire s'il s'agit d'une erreur de syntaxe, d'exécution ou de logique, puis corriger.


# (a)                          # (b)                    # (c) carré de n
def double(x)                  t = [1, 2, 3]            def carre(n):
    return 2 * x               print(t[3])                  return n * 2
Démonstration (Solution)

(a) Syntaxe : il manque le deux-points après def double(x) — l'interpréteur refuse le fichier avant toute exécution. Correction : def double(x):.

(b) Exécution : t[3] lève IndexError, car les indices valides d'une liste de longueur sont , , . Correction : t[2] pour le dernier élément (ou t[-1]).

(c) Logique : n 2 calcule le double, pas le carré ; le programme s'exécute sans erreur et rend un résultat faux — seul un test comme assert carre(3) == 9 le révèle. Correction : n n (ou n ** 2). (La hiérarchie du danger est croissante : (a) est signalée immédiatement, (b) à l'exécution, (c) jamais.)

Exercice 2 : Spécifier sans implémenter

Écrire la spécification complète (signature annotée, docstring avec précondition et postcondition) — sans écrire le corps — d'une fonction occurrences(v, t) qui compte le nombre d'apparitions de v dans la liste t.

Démonstration (Solution)

def occurrences(v, t: list) -> int:
    """Renvoie le nombre d'indices i tels que t[i] == v.

    Précondition : t est une liste (éventuellement vide).
    Postcondition : le résultat c vérifie 0 <= c <= len(t),
    et c est exactement le cardinal de {i : t[i] == v}.
    """

Trois points méritent attention : la liste vide est admise (et donnera ) ; la postcondition encadre le résultat (), ce qui fournira plus tard un test de propriété gratuit ; et rien n'est dit du parcours — le contrat reste muet sur le comment. (Savoir s'arrêter là est tout l'exercice : la spécification est un livrable en soi.)

Exercice 3 : Un jeu de tests complet

La fonction dernier_indice(v, t) renvoie le plus grand indice tel que , ou None si v est absent. Construire un jeu de tests d'au moins six assertions exerçant cas nominaux, cas limites et chaque branche du contrat.

Démonstration (Solution)

assert dernier_indice(3, [3, 1, 3, 2]) == 2    # présent plusieurs fois : le DERNIER
assert dernier_indice(2, [3, 1, 3, 2]) == 3    # présent en dernière position
assert dernier_indice(3, [3]) == 0             # liste à un élément
assert dernier_indice(7, [3, 1, 2]) is None    # absent
assert dernier_indice(7, []) is None           # liste vide
assert dernier_indice(5, [5, 5, 5]) == 2       # tous égaux

Le premier test est le plus discriminant : il sépare dernier_indice de indice (une implémentation qui renverrait le premier indice échouerait ici et seulement ici). Un bon jeu de tests contient toujours au moins un cas qui distingue la spécification visée de sa voisine la plus proche. (Les tests 2 et 3 traquent les erreurs de bornes, les tests 4 à 6 les cas limites du contrat.)

Niveau (Application avec raisonnement intermédiaire)

Exercice 4 : Le contrat ambigu

Deux étudiants ont implémenté « arrondi(x) : renvoie l'entier le plus proche du flottant x ». Leurs fonctions diffèrent sur arrondi(2.5) : l'une renvoie , l'autre — et chacun jure que la sienne est correcte. Qui a raison ? Réécrire une spécification qui tranche, puis donner le jeu de tests associé.

Démonstration (Solution)

Personne n'a raison, ni tort : la spécification est ambiguë. Pour , les deux entiers et sont à égale distance — « l'entier le plus proche » n'existe pas. Le contrat doit trancher la règle des demi-entiers. Par exemple :


def arrondi(x: float) -> int:
    """Renvoie l'entier n minimisant |x - n| ; en cas d'égalité
    (x demi-entier), renvoie le plus GRAND des deux candidats.

    Postcondition : |x - arrondi(x)| <= 0.5.
    """

assert arrondi(2.3) == 2 and arrondi(2.7) == 3   # cas nominaux
assert arrondi(2.5) == 3                          # demi-entier : la règle choisie
assert arrondi(-2.5) == -2                        # demi-entier négatif (le plus grand !)
assert arrondi(4.0) == 4                          # déjà entier

Le test arrondi(-2.5) == -2 est subtil : « le plus grand » de et est . Une moitié des bogues d'arrondi du monde réel vit dans les négatifs. (Leçon : un désaccord entre deux implémentations « correctes » signale presque toujours un trou dans la spécification — c'est elle qu'il faut corriger d'abord.)

Exercice 5 : Variant non trivial — l'algorithme d'Euclide

Montrer que la boucle suivante termine, en exhibant un variant :


def pgcd(a: int, b: int) -> int:
    """Renvoie le PGCD de a et b. Précondition : a >= 0, b >= 0, (a, b) != (0, 0)."""
    while b > 0:
        a, b = b, a % b
    return a
Démonstration (Solution)

Posons comme variant la valeur de . Tant que la boucle s'exécute, (condition d'entrée) : le variant est positif. À chaque tour, le nouveau vaut , qui appartient à par définition du reste : le variant décroît strictement. Une suite strictement décroissante d'entiers positifs étant finie, la boucle termine. (Noter que , lui, ne décroît pas nécessairement au premier tour — si , le tour initial échange les deux valeurs. C'est bien et lui seul qu'il faut choisir. La correction, elle, repose sur l'invariant , conséquence de — l'identité d'Euclide vue en mathématiques.)

Exercice 6 : Invariant du maximum

Énoncer et prouver l'invariant qui établit la correction de la fonction maximum du cours :


def maximum(t: list) -> float:
    m = t[0]
    for i in range(1, len(t)):
        if t[i] > m:
            m = t[i]
    return m
Démonstration (Solution)

Invariant : « au début du tour d'indice , est le maximum de » (c'est-à-dire : et pour tout ).

Initialisation () : est bien le maximum du préfixe . ✓

Conservation : supposons vraie. Deux cas. Si , alors majore , donc tous les , : après m = t[i], est le maximum de . Sinon, et reste le maximum de . Dans les deux cas, est vraie. ✓

Conclusion : à la sortie (), est le maximum de tout entier : la postcondition est prouvée — y compris l'appartenance , que le maximum_faux du cours violait. (L'invariant dit exactement « le travail déjà fait est juste » ; la preuve se réduit alors à vérifier le dernier geste.)

Exercice 7 : Le test de propriété

On veut tester une fonction tri(t) censée renvoyer une liste triée contenant les mêmes éléments que t, sans connaître son algorithme. Proposer un test de propriété : une fonction verifie(t, r) qui vérifie que r est un résultat acceptable pour l'entrée t, puis l'utiliser sur des entrées aléatoires.

Démonstration (Solution)

La postcondition a deux clauses — triée et mêmes éléments — qu'on vérifie séparément :


def verifie(t: list, r: list) -> bool:
    """r est-il un tri acceptable de t ?"""
    croissante = all(r[i] <= r[i + 1] for i in range(len(r) - 1))
    memes_elements = sorted(t) == sorted(r)
    return croissante and memes_elements

import random
for essai in range(1000):
    t = [random.randint(-50, 50) for _ in range(random.randint(0, 30))]
    assert verifie(t, tri(t)), f"echec sur {t}"

Le message d'échec affiche l'entrée fautive (forme exigible : assert nu ; message et f-string sont des commodités hors annexe). Mille entrées aléatoires exercent plus de situations qu'aucun jeu de tests manuel — et la clause memes_elements attrape le bogue classique du tri qui perd ou duplique des éléments, invisible si l'on ne vérifie que la croissance. (Oublier la seconde clause est l'erreur canonique : la liste [1, 1, 1] est parfaitement triée, quelle que soit l'entrée… Le test de propriété teste le contrat entier, pas sa moitié facile.)

Niveau (Raisonnement subtil ou plusieurs étapes)

Exercice 8 : La boucle qui ne termine pas toujours

On considère la fonction suivante :


def atteint_un(n: int) -> int:
    """Compte les étapes pour atteindre 1. Précondition : n >= 1."""
    c = 0
    while n != 1:
        if n % 2 == 0:
            n = n // 2
        else:
            n = n + 1
        c = c + 1
    return c

Montrer que cette boucle termine pour tout , bien que ne décroisse pas à chaque tour. Que se passe-t-il si l'on remplace n = n + 1 par n = 3 * n + 1 ?

Démonstration (Solution)

Le piège : augmente aux tours impairs, ce n'est donc pas un variant. Mais regardons deux tours : si est impair (et , donc ), le tour suivant donne , pair, puis . Si est pair, un seul tour donne . Ainsi la quantité décroît strictement au bout d'au plus deux tours : formellement, est un variant qui décroît strictement à chaque tour (vérification : pour pair, passe de à ; pour impair, de à … non : à — ce candidat échoue !). Reprenons : le bon variant est : pour pair, dès ; pour impair, — échec encore. La leçon est qu'un variant par tour n'existe pas toujours sous forme simple : on raisonne alors par paquets de tours. La mesure décroît strictement tous les deux tours au plus, et reste : la suite des valeurs de observées tous les deux tours est strictement décroissante dans , donc finie — la boucle termine, en au plus tours.

Avec n = 3 n + 1, on obtient la suite de Syracuse : la terminaison pour tout est une conjecture ouverte depuis près d'un siècle — personne ne sait exhiber de variant, et personne n'a trouvé de contre-exemple. (Double moralité : prouver la terminaison peut exiger de l'invention — paquets de tours, mesures composées — et il existe des boucles de quatre lignes dont la terminaison dépasse l'état actuel des mathématiques. Le variant n'est pas une formalité.)*

Exercice 9 : Spécification, tests et preuve d'un même algorithme

Traiter le problème suivant de bout en bout, en suivant la discipline du chapitre : écrire une fonction renverse(t) qui renvoie une nouvelle liste contenant les éléments de t en ordre inverse — spécification, implémentation par boucle, jeu de tests, puis preuve par invariant.

Démonstration (Solution)

1. Spécification puis implémentation.


def renverse(t: list) -> list:
    """Renvoie une nouvelle liste r de même longueur que t,
    telle que r[i] == t[n - 1 - i] pour tout i (n = len(t)).
    La liste t n'est pas modifiée."""
    r = []
    for i in range(len(t)):
        # Invariant : r == [t[n-1], t[n-2], ..., t[n-i]]  (les i derniers, renversés)
        r.append(t[len(t) - 1 - i])
    return r

2. Jeu de tests.


assert renverse([1, 2, 3]) == [3, 2, 1]      # nominal
assert renverse([]) == []                     # vide
assert renverse([7]) == [7]                   # un élément
assert renverse([1, 2, 2]) == [2, 2, 1]       # doublons
t = [1, 2, 3]; renverse(t)
assert t == [1, 2, 3]                         # t non modifiée (clause du contrat !)

3. Preuve. Invariant : « au début du tour , » (liste vide pour ). Initialisation : . ✓ Conservation : si tient, le tour ajoute en queue, donnant , soit . ✓ À la sortie () : , c'est-à-dire pour tout — la postcondition. La clause « non modifiée » tient car le corps ne contient aucune écriture dans . (Le déroulé complet — contrat, code, tests, preuve — est le geste professionnel que ce cours installera comme un réflexe ; l'avant-dernier test, souvent oublié, vérifie une clause du contrat qui ne se voit pas dans le résultat renvoyé.)

Exercice 10 : Le flottant qui trahit le test

Un étudiant teste une fonction de moyenne avec assert moyenne([0.1, 0.2]) == 0.15 et l'assertion échoue, alors que sa fonction est correcte. Expliquer, proposer la bonne façon de tester des résultats flottants, et en tirer une règle générale sur les jeux de tests numériques.

Démonstration (Solution)

Les flottants sont des approximations binaires : ni ni ne sont représentables exactement en base , et la machine calcule


>>> (0.1 + 0.2) / 2
0.15000000000000002

L'égalité stricte == entre flottants issus de calculs est donc un test trop fragile : il peut échouer sur du code juste (ici) comme réussir sur du code faux (compensation accidentelle d'erreurs). La bonne pratique est la comparaison à tolérance :


assert abs(moyenne([0.1, 0.2]) - 0.15) < 1e-12

Règle générale : dans un jeu de tests numérique, on réserve == aux entiers, aux booléens et aux résultats exacts par nature (longueurs, indices, comptages) ; toute comparaison de flottants calculés passe par un écart absolu abs(a - b) &lt; eps (ou relatif pour les grandes valeurs). (Ce phénomène n'est pas un défaut de Python : c'est l'arithmétique IEEE 754, partagée par tous les langages — on le retrouvera chaque fois que l'informatique calcule sur le continu.)

Synthèse du chapitre (à retenir)
  • Environnement : mode interactif (expérimenter) / mode script (produire) ; cycle écrire exécuter observer corriger ; lire la traceback de bas en haut.
  • Trois familles d'erreurs : syntaxe (refus immédiat), exécution (exception en cours de route), logique (résultat faux en silence — la plus dangereuse, seule la spécification et les tests la voient).
  • Spécification contrat : signature annotée, préconditions (ce que l'appelant garantit), postconditions (ce que la fonction promet) ; décrit le quoi, jamais le comment ; tranche explicitement les cas limites (absent None ?, liste vide licite ?) ; docstring spécification embarquée.
  • Annotations et commentaires : annoter systématiquement les signatures ; commenter le pourquoi (intention, subtilité, invariant), jamais paraphraser ; le meilleur commentaire est un bon nom.
  • Jeux de tests (assert) : construits depuis la spécification — cas nominaux cas limites (vide, un élément, extrêmes, tous égaux, négatifs) chaque branche du contrat tests de propriété sur entrées aléatoires ; flottants comparés à tolérance, jamais par == ; tester n'est pas prouver (présence d'erreurs, jamais absence).
  • Variant (terminaison) : quantité entière strictement décroissante à chaque tour de while ; parfois par paquets de tours ; Syracuse rappelle que la terminaison peut être un problème ouvert.
  • Invariant (correction) : propriété vraie avant la boucle et conservée par chaque tour — une récurrence ; forme canonique : « le résultat est juste pour la partie déjà traitée » ; à la sortie, invariant condition d'arrêt postcondition.

1.8 Exercices d'entraînement

Cette banque d'exercices, classée par thème, couvre l'intégralité du chapitre. La numérotation prolonge celle des dix exercices résolus. Légende : application directe, raisonnement intermédiaire, approfondissement ; le symbole signale un classique incontournable.

A. Erreurs et environnement

B. Spécification

C. Jeux de tests

D. Variants et invariants

Continuer sur Adloun : animation, QCM, fiches, exercices