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
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
>{>{}>}) : 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]
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.
- 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.
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.
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.
| Construction | Exemple | Rô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 |
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
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.
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, .
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
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
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.
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
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.
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
ifdu 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.
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.
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
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.
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 > 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.
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
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.
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.
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)
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.)
É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.)
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)
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.)
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.)
É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.)
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)
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é.)*
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é.)
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) < 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.)
- 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
- () Classer (syntaxe / exécution / logique) puis corriger :
if x = 3:;int("3,14"); une fonctionaire_disquequi renvoie ;print(s.lenght). - () Sans l'exécuter, prédire ce qu'affiche un script contenant
x = 5puisx + 1puisprint(x)— et expliquer la différence avec la même saisie en console. - () Écrire un fragment qui lève successivement (et volontairement)
TypeError,NameError,IndexErroretZeroDivisionError, en une ligne chacun. - ( ) La boucle
while x != 1.0: x = x - 0.1avecx = 2.0ne termine pas. Expliquer (exécuter2.0 - 0.1 - 0.1 - ...en console), corriger de deux façons (condition d'arrêt à tolérance ; compteur entier), et en tirer une règle sur les flottants dans les conditions de boucle.
B. Spécification
- () Spécifier (sans implémenter) :
minimum(t);contient(v, t);nb_pairs(t);prefixe(u, v)(la chaîneuest-elle un préfixe dev?). - () La spécification «
division(a, b)renvoie le quotient deaparb» est doublement ambiguë. Identifier les deux ambiguïtés (nature du quotient ; cas ) et écrire deux contrats distincts qui les tranchent différemment. - ( ) Spécifier
deuxieme_maximum(t)en tranchant le cas des doublons : si , le deuxième maximum est-il ou ? Écrire les deux contrats possibles et, pour chacun, le test qui le distingue de l'autre. - () Donner une spécification de
melange(t)(mélange aléatoire d'une liste) : que peut-on promettre d'un résultat aléatoire ? (Penser : mêmes éléments ; et réfléchir à ce qu'on ne peut PAS tester par assertion simple.) - () Une fonction
cherche(v, t)sur une liste triée promet un indice devouNone, « en temps rapide ». Montrer par un exemple que la précondition «test triée » est indispensable au contrat : exhiber une liste non triée où une recherche dichotomique correcte rate un élément pourtant présent.
C. Jeux de tests
- () Construire un jeu de tests (six assertions minimum) pour
nb_pairs(t), en exerçant : liste vide, aucun pair, tous pairs, mélange, nombres négatifs, zéro. - () La fonction
absolu, définie parreturn x if x > 0 else -x, contient un cas limite douteux. Lequel ? Écrire le test qui le contrôle et conclure (la fonction est-elle correcte ?). - ( ) Écrire un test de propriété pour
renverse: vérifier sur 1000 listes aléatoires querenverse(renverse(t)) == tet quelen(renverse(t)) == len(t). Montrer qu'une fonction fausse peut néanmoins passer le premier test (laquelle ?) — d'où l'intérêt de combiner plusieurs propriétés. - () Le « bogue de l'an 2000 » : une fonction
age(naissance, courante), qui reçoit deux années, a été testée uniquement sur des années 19xx. Construire le jeu de tests qui aurait révélé le problème d'une implémentation stockant les années sur deux chiffres. - () Tester une fonction
racine_entiere(n)(plus grand tel que ) par propriété : sur aléatoire, vérifier . Expliquer pourquoi ce test-là vaut une infinité de tests à valeurs attendues calculées à la main.
D. Variants et invariants
- () Exhiber un variant pour :
while n >= 10: n = n - 3;while a < b: a = a * 2(préciser les préconditions nécessaires à la terminaison !). - ( ) Énoncer et prouver l'invariant de la fonction
occurrences(comptage des apparitions devdanst) implémentée par bouclefor. - () La boucle
while n != 0: n = n - 2termine-t-elle pour toutn >= 0? Exhiber le contre-exemple, corriger la condition, et donner le variant de la version corrigée. - ( ) Prouver par invariant la correction de l'exponentiation naïve (
r = 1puis foisr = r * x) : invariant . - () Pour la division euclidienne par soustractions successives (
q, r = 0, apuis tant quer >= b:q, r = q + 1, r - b), donner le variant ET l'invariant ( et ), et conclure que le couple renvoyé est bien le quotient et le reste. - () L'algorithme du drapeau à deux couleurs : une boucle réorganise
tpour placer tous les éléments pairs avant les impairs, via deux indicesgetd. Proposer l'implémentation, le variant () et l'invariant (« pairs, impairs »), et prouver la correction. - ( ) On dispose d'un sac contenant boules blanches et boules noires. Tant qu'il reste au moins deux boules, on en tire deux : si elles sont de même couleur, on remet une noire ; sinon, une blanche. Montrer que le processus termine (variant) et que la couleur de la dernière boule est déterminée par la parité de (invariant) — le « problème des boules » de Dijkstra, ou comment un invariant prédit le résultat d'un processus apparemment aléatoire.