Programmation en C · C6 Qualité du code · Chapitre 1 · 4 h
Déboguer et fiabiliser
Lire méthodiquement une erreur de compilation, gdb pas à pas, détection de fuites avec valgrind, assertions et jeux de tests, conventions d'écriture.
Un programme qui fonctionne du premier coup n'enseigne rien. Un programme qui plante, si l'on sait pourquoi, enseigne la moitié du cours — et le C offre pour cela un terrain d'une richesse inégalée, puisqu'il ne protège de rien.
Ce dernier chapitre n'ajoute aucune notion du langage. Il donne les quatre outils sans lesquels tout ce qui précède reste inexploitable : lire une erreur, dérouler au débogueur, traquer une fuite, et se prémunir par des tests.
Un préalable de méthode, qui vaut mieux que tous les outils : diagnostiquer plutôt que deviner. Modifier le code au hasard jusqu'à ce que le symptôme disparaisse ne corrige rien — cela déplace la faute. La démarche est toujours la même : reproduire de façon fiable, réduire au plus petit cas qui échoue, formuler une hypothèse, la vérifier par une mesure.
Lire une erreur de compilation
Le chapitre 1 a donné les trois règles : corriger la première erreur, lire le numéro de ligne comme un indice et non comme une adresse, et ne jamais ignorer un avertissement. Voici comment elles s'appliquent.
calcul.c:12:5: error: expected ';' before 'return'Le compilateur signale la ligne 12, et le point-virgule manquant est à la ligne 11 — il ne s'en aperçoit qu'en rencontrant le mot suivant. La règle : quand une erreur porte sur un point-virgule ou une accolade, regarder la ligne précédente.
Trois familles se distinguent par leur émetteur, comme au chapitre 1.
error: 'truc' undeclared vient du compilateur : identifiant jamais déclaré, souvent une
faute de frappe ou un #include manquant.
error: expected ... vient du parseur : la syntaxe. La cascade est ici la plus trompeuse —
une accolade non fermée engendre des dizaines de messages à partir de la ligne suivante.
undefined reference to 'truc' vient de l'éditeur de liens, et n'a rien d'une erreur de
syntaxe : le fichier objet manque, la bibliothèque n'est pas passée, ou la fonction est déclarée
sans être définie.
Enfin, deux avertissements qui sont presque toujours de vrais bogues :
warning: 'x' is used uninitialized — le chapitre 2 — et
warning: control reaches end of non-void function, qui signale un chemin d'exécution sans
return, dont la valeur rendue sera quelconque.
gdb, pas à pas
Le débogueur exécute le programme sous contrôle : on l'arrête où l'on veut, on inspecte, on avance d'une ligne.
La condition préalable est de compiler avec -g, qui conserve les noms de variables et les
numéros de ligne. Sans lui, gdb n'affiche que des adresses.
gcc -Wall -g -o prog prog.cgdb ./prog| Commande | Effet |
|---|---|
break 42 / break maFonction | poser un point d'arrêt |
run | lancer |
next | ligne suivante, sans entrer dans les appels |
step | ligne suivante, en entrant dans les appels |
print x / print *p / print T[3] | afficher une valeur |
backtrace | la pile d'appels — qui a appelé qui |
continue | reprendre jusqu'au prochain arrêt |
watch x | s'arrêter dès que x change |
Deux usages valent d'être connus précisément.
Le post-mortem d'une erreur de segmentation. On lance simplement run ; au plantage, gdb
s'arrête sur la ligne fautive. backtrace donne le chemin d'appels qui y a mené, et print sur
chaque pointeur montre lequel vaut NULL ou une adresse absurde. C'est trente secondes de
travail, contre une heure de printf disséminés.
Le watch. Quand une variable prend une valeur aberrante sans qu'on sache où, watch x
arrête le programme à l'instruction exacte qui la modifie. C'est l'outil des corruptions à
distance du chapitre 5 — celles où un débordement de tableau écrase une variable voisine.
Le débogueur ne remplace pas la réflexion : il répond à une question précise. Y arriver sans hypothèse revient à regarder défiler des valeurs.
valgrind
gdb traite les plantages ; valgrind traite ce qui ne plante pas encore. Il exécute le
programme dans une machine virtuelle et surveille chaque accès mémoire.
valgrind --leak-check=full ./progIl détecte exactement les quatre fautes du chapitre 8, et sa sortie se lit ainsi :
Invalid write of size 4 at 0x109189: main (prog.c:12) Address 0x4a47054 is 0 bytes after a block of size 20 alloc'd at 0x484A...: malloc by 0x109165: main (prog.c:9) LEAK SUMMARY: definitely lost: 40 bytes in 1 blocksTrois choses à y lire. La nature : Invalid write est un débordement, Invalid read une
lecture hors bloc ou après libération, definitely lost une fuite. Le lieu de la faute :
prog.c:12. Et surtout le lieu de l'allocation : prog.c:9 — l'information qui manque
toujours, puisqu'elle relie le symptôme à l'origine.
Deux nuances utiles. definitely lost désigne un bloc dont plus aucun pointeur n'existe ;
still reachable désigne un bloc non libéré mais encore atteignable à la fin — souvent bénin.
Et valgrind ralentit l'exécution d'un facteur dix à cinquante : on l'emploie sur un jeu de
tests réduit, pas sur la charge réelle.
Une alternative moderne mérite d'être citée : gcc -fsanitize=address, qui instrumente le
programme à la compilation. Deux à trois fois plus lent seulement, donc utilisable en continu
pendant le développement.
Quiz · 1 question
Un programme affiche des valeurs aberrantes mais ne plante jamais. Quel outil employer en premier, et pourquoi ?
- gdb, en posant un point d'arrêt sur chaque fonction jusqu'à trouver la fautive — gdb d'abord
- valgrind : il détecte les accès mémoire invalides et les lectures de données non initialisées MÊME quand le programme ne plante pas, et il donne la ligne de la faute et celle de l'allocation d'origine — valgrind d'abord
- Ajouter des printf partout, ce qui reste la méthode la plus rapide — des printf
Réponse : Un programme qui produit des valeurs fausses sans planter est le symptôme typique d'une corruption silencieuse : lecture d'une variable non initialisée, débordement d'un octet, usage d'un bloc après libération. Toutes échappent à gdb tant qu'on ne sait pas où regarder, puisqu'il n'y a pas de plantage sur lequel s'arrêter. valgrind, lui, surveille CHAQUE accès mémoire et signale l'anomalie au moment où elle se produit — avec deux informations décisives : la ligne fautive et la ligne de l'ALLOCATION concernée, ce qui relie enfin le symptôme à son origine. Une fois le lieu connu, gdb prend le relais pour comprendre le comment. Les printf ne sont pas indignes, mais ils modifient le minutage et le contenu des tampons, et font parfois disparaître le symptôme.
Assertions et jeux de tests
Les outils précédents cherchent une faute déjà commise. Les deux qui suivent l'empêchent d'arriver jusqu'à la mise en service.
Une assertion vérifie une condition qui doit être vraie par construction. Si elle est fausse, le programme s'arrête immédiatement, en indiquant le fichier et la ligne.
#include <assert.h> int diviser(int a, int b) { assert(b != 0); /* précondition */ return a / b;}Le contrat est précis : une assertion documente une erreur de programmation, pas une
situation attendue. Une saisie utilisateur invalide n'est pas une assertion — c'est un cas à
traiter. Un pointeur NULL là où l'invariant l'interdit, si.
Deux propriétés à connaître. La macro NDEBUG désactive toutes les assertions à la
compilation, ce qui est l'usage en production. Conséquence immédiate : on ne met jamais
d'effet de bord dans une assertion — assert(i++ < n) cesse d'incrémenter en production, et
le programme se comporte différemment selon le mode de compilation, ce qui est le pire des
bogues.
Un jeu de tests est un programme qui exécute des cas et vérifie les résultats. Il n'a pas besoin d'une bibliothèque pour commencer :
static int echecs = 0;#define VERIFIER(cond) do { \ if (!(cond)) { printf("ÉCHEC %s:%d %s\n", __FILE__, __LINE__, #cond); echecs++; } \} while (0) VERIFIER(maximum(3, 7) == 7);VERIFIER(maximum(-1, -5) == -1);VERIFIER(maximum(4, 4) == 4);Ce qui distingue un bon jeu de tests n'est pas leur nombre mais le choix des cas : les bornes — zéro, un élément, tableau vide, valeur maximale du type —, les cas dégénérés, et chaque bogue corrigé, transformé en test pour qu'il ne revienne pas.
Conventions
Elles ne sont pas de la décoration : elles réduisent le nombre de fautes possibles.
Compiler avec -Wall -Wextra, et traiter tout avertissement comme une erreur. C'est la
mesure la plus rentable du chapitre, et de loin.
Nommer ce que fait la chose, en évitant les abréviations sauf pour les compteurs de boucle. Un nom exact vaut trois lignes de commentaire.
Écrire des fonctions courtes, qui font une chose. Une fonction qui tient à l'écran se relit ; au-delà, on ne relit plus, on parcourt.
Employer const partout où c'est vrai. Sur un paramètre pointeur, const documente que la
fonction ne modifiera pas la donnée — et le compilateur le vérifie.
Initialiser à la déclaration, et poser les pointeurs à NULL après free — les deux règles
des chapitres 2 et 8.
Indenter de façon cohérente. Peu importe le style ; ce qui compte est qu'il soit le même
partout, et clang-format s'en charge sans discussion.
Quiz · 1 question
Pourquoi ne doit-on jamais écrire assert(i++ < n) ?
- Parce que assert n'accepte pas les expressions contenant des opérateurs d'incrémentation — syntaxe refusée
- Parce que la macro NDEBUG supprime les assertions en production : l'incrémentation disparaîtrait avec elle, et le programme se comporterait différemment selon le mode de compilation — effet de bord supprimé
- Parce que l'incrémentation rend l'assertion toujours vraie — toujours vraie
Réponse : assert est une MACRO, et définir NDEBUG à la compilation la remplace par du vide — c'est l'usage courant en production, où l'on ne veut ni le coût du test ni un arrêt brutal. Tout ce qui se trouve à l'intérieur disparaît donc avec elle : un i++ placé là cesse d'être exécuté. Le programme compilé en production suit alors une exécution DIFFÉRENTE de celle testée en développement, ce qui produit la pire catégorie de bogues — ceux qui n'existent que là où l'on ne peut pas déboguer. La règle est donc absolue : une assertion ne contient qu'une vérification, sans le moindre effet de bord. Elle rappelle aussi ce qu'une assertion est censée exprimer : une erreur de PROGRAMMATION, une condition vraie par construction — pas une saisie utilisateur invalide, qui est un cas à traiter.
À vous
L'exercice donne un programme bogué et l'outillage pour le diagnostiquer, plutôt que la correction.
Trois temps. Un débogueur miniature : vous posez un point d'arrêt, vous inspectez l'état à
chaque pas, et vous localisez l'instruction exacte où une variable prend une valeur aberrante —
c'est le watch du cours. Puis un rapport de type valgrind sur l'allocateur du chapitre 8,
qu'il faut apprendre à lire : nature de la faute, ligne fautive, ligne de l'allocation.
Enfin l'écriture d'un jeu de tests sur une fonction dont trois cas limites sont faux — vous les
trouverez en cherchant les bornes, pas en multipliant les cas ordinaires.
Exercice de code
Localisez une faute au watch, lisez un rapport valgrind, puis écrivez un jeu de tests par les bornes.
Point de départ
// ── 1. Un débogueur miniature ─────────────────────────────────────────────
// Chaque « instruction » est une fonction qui modifie l'état. Le débogueur
// exécute pas à pas, affiche l'état, et surveille une variable.
function executer(programme, surveillee) {
const etat = {};
let precedente;
programme.forEach((instruction, ligne) => {
instruction.faire(etat);
const v = etat[surveillee];
const change = v !== precedente;
console.log(" ligne " + String(ligne + 1).padStart(2) + " " +
instruction.texte.padEnd(30) + JSON.stringify(etat) +
(change && precedente !== undefined ? " << " + surveillee + " a changé" : ""));
precedente = v;
});
return etat;
}
// Un calcul de moyenne, avec une faute quelque part.
const PROGRAMME = [
{ texte: "somme = 0", faire: (e) => { e.somme = 0; } },
{ texte: "n = 0", faire: (e) => { e.n = 0; } },
{ texte: "somme += 12", faire: (e) => { e.somme += 12; e.n++; } },
{ texte: "somme += 15", faire: (e) => { e.somme += 15; e.n++; } },
{ texte: "somme += 9", faire: (e) => { e.somme += 9; } }, // ← n oublié
{ texte: "moyenne = somme / n", faire: (e) => { e.moyenne = e.somme / e.n; } },
];
// ── 2. Lire un rapport de type valgrind ───────────────────────────────────
const RAPPORT = [
"Invalid write of size 4",
" at 0x109189: remplir (tri.c:23)",
" Address 0x4a47054 is 0 bytes after a block of size 40 alloc'd",
" at 0x484A899: malloc",
" by 0x109165: main (tri.c:11)",
"",
"LEAK SUMMARY:",
" definitely lost: 40 bytes in 1 blocks",
" still reachable: 128 bytes in 2 blocks",
];
// ── 3. Un jeu de tests ────────────────────────────────────────────────────
let echecs = 0;
function verifier(condition, description) {
if (!condition) { console.log(" ÉCHEC : " + description); echecs++; }
}
// La fonction à tester : rend l'indice du maximum d'un tableau.
function indiceDuMax(T, n) {
let meilleur = 0;
for (let i = 1; i < n; i++) if (T[i] > T[meilleur]) meilleur = i;
return meilleur;
}
// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Lancez executer() en surveillant « n » : à quelle ligne l'état
// diverge-t-il de ce que vous attendiez ? C'est le watch de gdb.
// 2. Lisez RAPPORT : quelle est la nature de la faute, où se produit-elle,
// et où le bloc concerné a-t-il été alloué ? Quelle ligne corriger ?
// 3. Écrivez des tests pour indiceDuMax : cherchez les BORNES (tableau d'un
// élément, valeurs égales, tableau vide) plutôt que des cas ordinaires.
console.log("— exécution pas à pas, surveillance de « n » —");
executer(PROGRAMME, "n");
Solution
function executer(programme, surveillee) {
const etat = {};
let precedente;
programme.forEach((instruction, ligne) => {
instruction.faire(etat);
const v = etat[surveillee];
const change = v !== precedente;
console.log(" ligne " + String(ligne + 1).padStart(2) + " " +
instruction.texte.padEnd(30) + JSON.stringify(etat) +
(change && precedente !== undefined ? " << " + surveillee + " a changé" : ""));
precedente = v;
});
return etat;
}
const PROGRAMME = [
{ texte: "somme = 0", faire: (e) => { e.somme = 0; } },
{ texte: "n = 0", faire: (e) => { e.n = 0; } },
{ texte: "somme += 12", faire: (e) => { e.somme += 12; e.n++; } },
{ texte: "somme += 15", faire: (e) => { e.somme += 15; e.n++; } },
{ texte: "somme += 9", faire: (e) => { e.somme += 9; } },
{ texte: "moyenne = somme / n", faire: (e) => { e.moyenne = e.somme / e.n; } },
];
console.log("— 1. exécution pas à pas, surveillance de « n » —");
const fin = executer(PROGRAMME, "n");
console.log(" moyenne obtenue : " + fin.moyenne + " attendue : " + (36 / 3).toFixed(2));
console.log(" Le watch le dit : n change aux lignes 3 et 4, PAS à la ligne 5.");
console.log(" La faute n'est pas dans la division mais trois lignes plus haut —");
console.log(" c'est exactement l'écart entre le symptôme et sa cause.");
console.log("");
console.log("— 2. lecture du rapport —");
const RAPPORT = [
"Invalid write of size 4",
" at 0x109189: remplir (tri.c:23)",
" Address 0x4a47054 is 0 bytes after a block of size 40 alloc'd",
" at 0x484A899: malloc",
" by 0x109165: main (tri.c:11)",
"",
"LEAK SUMMARY:",
" definitely lost: 40 bytes in 1 blocks",
" still reachable: 128 bytes in 2 blocks",
];
for (const l of RAPPORT) console.log(" | " + l);
console.log("");
console.log(" NATURE : Invalid write — une ÉCRITURE hors d'un bloc alloué.");
console.log(" OÙ : tri.c ligne 23, dans remplir().");
console.log(" QUEL BLOC : celui alloué à tri.c ligne 11, de 40 octets — soit");
console.log(" 10 int. « 0 bytes after » signifie qu'on écrit à");
console.log(" l'indice 10 d'un tableau qui s'arrête à 9 : un");
console.log(" débordement d'exactement UNE case, la faute type.");
console.log(" FUITE : definitely lost 40 octets — plus aucun pointeur ne");
console.log(" désigne le bloc. Les 128 octets « still reachable »");
console.log(" sont encore atteignables à la fin : souvent bénin.");
console.log(" À CORRIGER : la borne de la boucle en tri.c:23, pas l'allocation.");
console.log("");
console.log("— 3. jeu de tests —");
let echecs = 0;
function verifier(condition, description) {
if (!condition) { console.log(" ÉCHEC : " + description); echecs++; }
else console.log(" ok : " + description);
}
function indiceDuMax(T, n) {
let meilleur = 0;
for (let i = 1; i < n; i++) if (T[i] > T[meilleur]) meilleur = i;
return meilleur;
}
// Les cas ordinaires passent toujours : ce ne sont pas eux qui informent.
verifier(indiceDuMax([3, 17, 8], 3) === 1, "cas ordinaire, max au milieu");
verifier(indiceDuMax([42, 3, 8], 3) === 0, "max en tête");
verifier(indiceDuMax([3, 8, 42], 3) === 2, "max en queue");
// Les BORNES, elles, révèlent.
verifier(indiceDuMax([7], 1) === 0, "un seul élément");
verifier(indiceDuMax([5, 5, 5], 3) === 0, "valeurs toutes égales : le PREMIER indice");
verifier(indiceDuMax([-3, -1, -7], 3) === 1, "que des négatifs (un 0 initial casserait tout)");
// Le cas dégénéré : que doit rendre la fonction sur un tableau vide ? La
// question n'a pas de réponse — c'est le signe qu'il fallait une PRÉCONDITION,
// donc une assertion, et non une valeur de retour inventée.
console.log(" n = 0 : indiceDuMax rend " + indiceDuMax([], 0) +
", un indice qui n'existe pas -> assert(n > 0) s'impose");
console.log("");
console.log(" " + echecs + " échec(s). Le bogue corrigé devient un test de plus,");
console.log(" pour qu'il ne revienne jamais.");
En travaux pratiques
Travaux pratiques 10 · 3 h
Trouver le bogue, pas le deviner
Remplacer la relecture et les affichages par une méthode : reproduire, réduire, localiser — avec les outils qui font le travail à votre place.
Avant de commencer
- Le fil rouge journal, complet
- gdb, valgrind, les détecteurs, et de quoi écrire des tests
Énoncé
- Un programme sabotté — Échangez votre fil rouge avec un binôme après y avoir introduit trois bogues : un plantage, un résultat faux, une fuite. Chacun doit trouver les trois de l'autre, en chronométrant.
- Reproduire d'abord — Pour chaque bogue trouvé, écrivez la plus petite entrée qui le déclenche. Réduisez-la jusqu'à ce qu'enlever une ligne fasse disparaître le problème. Indice : Un bogue qu'on ne sait pas reproduire ne peut pas être corrigé, seulement masqué.
- Le débogueur — Placez un point d'arrêt conditionnel qui ne se déclenche qu'au tour de boucle fautif. Inspectez les variables, remontez la pile, et identifiez l'appelant responsable.
- Le point d'observation — Utilisez un point d'observation sur une variable qui change sans raison apparente. Notez la ligne exacte qui l'a modifiée.
- Les assertions — Ajoutez des assertions aux entrées et sorties de vos fonctions, exprimant les contrats écrits au TP 4. Vérifiez qu'une assertion se déclenche sur une entrée invalide, puis compilez avec NDEBUG.
- Les tests — Écrivez une dizaine de tests couvrant les cas limites : fichier vide, une seule ligne, ligne tronquée, très longue ligne, caractères accentués. Faites-les tourner par make test.
- Le filet complet — Faites passer tout le fil rouge sous les trois outils, avec les avertissements en erreurs. Corrigez jusqu'à ce que tout soit propre.
- Le bogue par recherche — Introduisez un bogue plusieurs commits en arrière dans votre dépôt, puis retrouvez le commit fautif par recherche dichotomique automatisée.
C'est réussi quand
- Vous trouvez les trois bogues de votre binôme, et vous savez lequel a été le plus long
- Votre entrée réduite tient en trois lignes
- make test passe, et échoue si vous cassez une seule fonction
- Compilation propre sous -Wall -Wextra -Werror et les trois outils
Correction
1. REPRODUIRE — sinon on ne peut rien vérifier 2. RÉDUIRE — la plus petite entrée qui échoue 3. LOCALISER — bissection dans le code, ou l'outil qui le dit 4. COMPRENDRE — pourquoi, pas seulement où 5. CORRIGER 6. TESTER — un test qui échouait avant, réussit après l'étape 4 est celle qu'on saute, et c'est celle qui évite de « corriger » un symptôme en déplaçant la cause
La tentation est de sauter directement à 5. Une correction dont on ne comprend pas pourquoi elle marche est un déplacement du bogue, pas une correction — et il reviendra sous une autre forme. C'est la même exigence que le post-mortem des cours de DevOps : distinguer le symptôme de la cause.
break analyse.c:42 if i == 9999 # arrêt conditionnel run acces.log bt # pile d'appels frame 1 # remonter à l'appelant print *l # afficher une structure entière print t[i]@10 # dix éléments à partir de i watch total # ARRÊT dès que total change finish # exécuter jusqu'au retour
Le point d'arrêt CONDITIONNEL et le point d'OBSERVATION sont les deux commandes qui changent tout. Sans le premier, on presse « continuer » dix mille fois. Sans le second, on cherche à la main qui modifie une variable — alors que gdb peut le dire exactement, en s'appuyant sur les registres de débogage du processeur.
#include <assert.h>
int somme(const int *t, size_t n) {
assert(t != NULL); /* précondition */
long s = 0;
for (size_t i = 0; i < n; i++) s += t[i];
assert(s >= 0 || t_contient_des_negatifs); /* postcondition */
return s;
}
gcc -DNDEBUG : toutes les assertions DISPARAISSENT du binaireUne assertion documente une hypothèse ET la vérifie : elle transforme une supposition tacite en échec bruyant, au plus près de la cause. Deux règles absolues — jamais d'effet de bord dans une assertion, puisqu'elle peut disparaître ; et jamais d'assertion pour valider une entrée EXTERNE, qui doit être testée par du code réel. assert sert à détecter les bogues du programmeur, pas les erreurs de l'utilisateur.
#!/bin/sh
set -e
verifier() { # attendu, entrée, description
obtenu=$(./journal "$2" 2>&1) || true
[ "$obtenu" = "$1" ] || { echo "ÉCHEC : $3"; exit 1; }
}
: > vide.log ; verifier "0 lignes" vide.log "fichier vide"
printf 'a\n' > un.log ; verifier "1 lignes" un.log "une ligne"
printf 'tronq' > tr.log ; verifier "1 lignes" tr.log "sans saut final"
echo "tous les tests passent"Les cas limites sont toujours les mêmes : rien, un seul, le maximum, l'invalide, et la limite exacte. Un fichier sans saut de ligne final est le cas oublié dans neuf implémentations sur dix. Ces tests coûtent une heure et remboursent au premier changement — ils sont le seul moyen de modifier son code sans le craindre.
CFLAGS = -Wall -Wextra -Werror -std=c17 -g verifier: journal valgrind --leak-check=full --error-exitcode=1 ./journal essai.log $(CC) $(CFLAGS) -fsanitize=address,undefined *.c -o journal-san ./journal-san essai.log ./test.sh
Les trois outils sont complémentaires et ne se remplacent pas. -Werror empêche l'accumulation d'avertissements, qui finissent toujours par cacher le seul qui comptait. --error-exitcode fait échouer la cible plutôt que d'imprimer un rapport que personne ne lira : c'est ce qui transforme un outil en garde-fou. Vous retrouverez exactement cette bascule — de l'information vers le blocage — dans le pipeline d'intégration continue du cours de DevOps.
git bisect start git bisect bad # la version actuelle échoue git bisect good v1.0 # cette version-là fonctionnait git bisect run ./test.sh # git teste automatiquement → 7 essais pour 100 commits (log2), commit fautif désigné avec son auteur, sa date et son message
La dichotomie sur l'historique est le même raisonnement que sur les données : diviser l'espace de recherche par deux à chaque essai. Cent commits en sept tests. C'est pour cela qu'on fait des commits petits et qui compilent — un commit énorme fait perdre tout l'intérêt de la méthode en désignant un coupable de trois mille lignes.
Ce que ce cours laisse
Dix chapitres plus tôt, la question était de savoir écrire, compiler et déboguer en C. Le parcours a été le suivant.
Les trois premiers blocs ont transposé en C ce qu'Algorithmique 1 avait posé : types, contrôle, fonctions, tableaux. Rien de conceptuellement neuf, mais un compilateur exigeant et un langage qui ne pardonne pas. Le bloc IV a introduit la seule notion réellement nouvelle — l'adresse, la durée de vie, la propriété d'un bloc — et c'est elle qui justifie d'enseigner le C. Les blocs V et VI ont donné la forme et l'outillage.
Ce qu'il faut en garder tient peut-être en une phrase. En C, tout ce que le langage ne vérifie pas, c'est vous qui devez le vérifier : les bornes d'un tableau, la validité d'un pointeur, la libération d'un bloc, le retour d'une lecture. Ce n'est pas une faiblesse du langage, c'est son contrat — il vous donne la machine du cours d'architecture, telle quelle, et ne s'interpose pas.
C'est aussi ce qui rend intelligible ce que les autres langages font à votre place. Un
ramasse-miettes, une vérification de bornes, un type string : après ce semestre, ce ne sont
plus des évidences invisibles mais des services dont vous savez le prix — et ce que l'on paie
quand on choisit de s'en passer.
À retenir
Flashcards · 5 cartes
- Quelle est la démarche de diagnostic, avant tout outil ?
- Diagnostiquer plutôt que deviner. Modifier le code au hasard jusqu'à ce que le symptôme disparaisse ne corrige rien — cela DÉPLACE la faute. La démarche : reproduire de façon fiable, réduire au plus petit cas qui échoue, formuler une hypothèse, la vérifier par une mesure. Le débogueur ne remplace pas la réflexion, il répond à une question précise ; sans hypothèse, on regarde défiler des valeurs.
- Quels sont les deux usages décisifs de gdb ?
- Compiler d'abord avec -g, sans quoi gdb n'affiche que des adresses. 1) LE POST-MORTEM d'une erreur de segmentation : run, puis au plantage gdb s'arrête sur la ligne fautive, backtrace donne le chemin d'appels et print révèle quel pointeur vaut NULL — trente secondes contre une heure de printf. 2) WATCH x : le programme s'arrête à l'instruction EXACTE qui modifie x, ce qui est l'outil des corruptions à distance du chapitre 5.
- Que faut-il lire dans une sortie de valgrind ?
- Trois choses. LA NATURE : Invalid write (débordement), Invalid read (hors bloc ou après libération), definitely lost (fuite — à distinguer de still reachable, non libéré mais encore atteignable, souvent bénin). LE LIEU DE LA FAUTE. Et surtout LE LIEU DE L'ALLOCATION du bloc concerné, qui relie enfin le symptôme à son origine. valgrind ralentit d'un facteur 10 à 50 : on l'emploie sur un jeu de tests réduit. Alternative : gcc -fsanitize=address, 2 à 3 fois plus lent seulement.
- Qu'exprime une assertion, et pourquoi n'y met-on jamais d'effet de bord ?
- Une condition vraie PAR CONSTRUCTION, donc une erreur de PROGRAMMATION — pas une situation attendue : une saisie utilisateur invalide est un cas à traiter, pas une assertion. La macro NDEBUG supprime toutes les assertions à la compilation en production : tout ce qui est à l'intérieur disparaît avec elles. assert(i++ < n) cesserait d'incrémenter, et le programme livré suivrait une exécution différente de celle testée — la pire catégorie de bogues.
- Qu'est-ce qui distingue un bon jeu de tests, et quelles conventions réduisent le plus les fautes ?
- Pas le NOMBRE de cas mais leur CHOIX : les bornes (zéro, un élément, tableau vide, valeur maximale du type), les cas dégénérés, et chaque bogue corrigé transformé en test pour qu'il ne revienne pas. Conventions, par ordre de rentabilité : compiler avec -Wall -Wextra et traiter tout avertissement comme une erreur ; nommer ce que fait la chose ; écrire des fonctions courtes ; mettre const partout où c'est vrai ; initialiser à la déclaration et poser les pointeurs à NULL après free.