C2 — Structures de contrôle et fonctionsDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 1 · Programmation en C

Cours 2Structures de contrôle et fonctions

Traduire en C le pseudocode d'Algorithmique 1, puis découper un programme en fonctions et en fichiers qui se compilent séparément.

2 chapitres · 12 h de travail estimé

  1. 1. Contrôle du flux5 h
  2. 2. Fonctions et modularité7 h

Chapitre 1 · 5 h

Contrôle du flux

if et switch, while, do…while et for, break et continue ; traduire en C le pseudocode d'Algorithmique 1.

En février 2014, Apple corrige en urgence une faille présente dans iOS et macOS. Le code fautif vérifiait un certificat TLS, et il contenait ceci :

if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)    goto fail;    goto fail;                    /* ← cette ligne est en trop */if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)    goto fail;

La seconde ligne goto fail n'appartient à aucun if : l'indentation le suggère, mais il n'y a pas d'accolades, donc un if sans accolades ne gouverne que l'instruction suivante. Le saut est donc inconditionnel, toute la suite de la vérification est sautée, et la fonction rend err = 0, c'est-à-dire « certificat valide ». N'importe quel certificat.

Ce chapitre traite des structures de contrôle du C. Elles ne présentent aucune difficulté conceptuelle pour qui a suivi Algorithmique 1 — ce sont les mêmes — mais leur syntaxe recèle une poignée de pièges dont celui-ci est le plus célèbre.

Une condition est un entier

C'est le point de départ, et il explique presque tous les pièges qui suivent. Le C n'a pas de type booléen dans sa version historique : une condition est une expression entière, où zéro est faux et toute autre valeur est vraie.

if (3)        /* vrai */if (0)        /* faux */if (-1)       /* vrai : seul zéro est faux */

Trois conséquences pratiques.

if (x = 5) compile. L'affectation est une expression, dont la valeur est 5, donc la condition est vraie — et x a été modifié au passage. C'est l'erreur de frappe la plus coûteuse du C, et -Wall la signale. Certains l'évitent en écrivant if (5 == x), la constante à gauche : une faute de frappe donnerait alors une erreur de compilation.

if (strcmp(a, b)) est inversé par rapport à l'intuition. strcmp rend 0 quand les chaînes sont égales, donc cette condition est vraie quand elles diffèrent. On écrit if (strcmp(a, b) == 0) pour tester l'égalité.

Enfin, stdbool.h fournit depuis C99 bool, true et false. Ce n'est qu'un habillage — true vaut 1 — mais il rend les intentions lisibles, et on l'emploiera.

Conditionnelles

if (note >= 16) {    printf("Très bien\n");} else if (note >= 14) {    printf("Bien\n");} else if (note >= 10) {    printf("Passable\n");} else {    printf("Insuffisant\n");}

L'ordre des tests décide de tout — c'est déjà l'exercice de la cascade d'Algorithmique 1 : le test le plus exigeant en premier, sans quoi les suivants deviennent inatteignables.

Et la règle que l'introduction impose : toujours mettre les accolades, même pour une seule instruction. Le coût est nul, le bénéfice est d'éliminer une classe entière de bogues — celui d'Apple, et celui, plus banal, d'ajouter une ligne à un if sans s'apercevoir qu'elle en sort.

Le switch compare une expression entière à des constantes :

switch (choix) {    case 1:        printf("Ajouter\n");        break;                    /* sans lui, on continue sur le cas 2 */    case 2:        printf("Supprimer\n");        break;    default:        printf("Choix inconnu\n");}

Le piège est le glissement (fallthrough) : sans break, l'exécution continue dans le cas suivant. Ce n'est pas un défaut de conception mais une fonctionnalité — elle permet de regrouper plusieurs cas partageant un traitement —, seulement l'oubli est fréquent, et gcc propose -Wimplicit-fallthrough pour le signaler.

Deux limites du switch : l'expression doit être entière (pas de chaînes, pas de flottants), et les étiquettes doivent être des constantes. Un test sur un intervalle demande un if.

Boucles

Les trois boucles du C se distinguent par le moment du test.

while (condition) { ... }         /* test AVANT : zéro tour possible */ do { ... } while (condition);     /* test APRÈS : au moins un tour */ for (init; condition; incrément) { ... }

Le for du C est plus général qu'il n'y paraît : ses trois parties sont des expressions quelconques, et chacune peut être vide. for (;;) est une boucle infinie parfaitement idiomatique.

Le do...while a un usage précis : la saisie contrôlée, où l'on doit demander au moins une fois avant de pouvoir tester. C'est aussi le seul dont le point-virgule final est obligatoire, et l'oublier produit un message d'erreur particulièrement obscur.

Deux fautes classiques méritent d'être nommées.

Le point-virgule fantôme : for (int i = 0; i < n; i++); suivi d'un bloc. La boucle a pour corps l'instruction vide, elle tourne n fois sans rien faire, puis le bloc s'exécute une seule fois. Aucun avertissement par défaut.

Le compteur non modifié : oublier l'incrément dans un while donne une boucle infinie — c'est la faute déjà signalée dans le chapitre 6 d'architecture, où l'incrément est explicite en assembleur.

Enfin, break sort de la boucle la plus interne et continue passe au tour suivant. Sortir de deux boucles imbriquées demande donc un drapeau, une fonction, ou — cas où il est défendable — un goto.

Quiz · 1 question

Que fait ce code ? int x = 3 ; if (x = 0) printf('nul') ; else printf('%d', x) ;

  • Il affiche « nul », car x est comparé à 0 et vaut bien 0 après l'affectationaffiche nul
  • Il affiche 0 : l'affectation x = 0 rend la valeur 0, donc la condition est FAUSSE, on part dans le else — et x a été écrasé au passageaffiche 0
  • Il ne compile pas : on ne peut pas affecter dans une conditionne compile pas

Réponse : Trois choses se produisent en même temps. L'affectation x = 0 est une EXPRESSION, dont la valeur est celle affectée, soit 0. Une condition en C est un entier, et zéro est faux : on part donc dans le else. Et x, qui valait 3, a été écrasé — le programme affiche 0. C'est la confusion = / == , et elle est d'autant plus vicieuse que le code compile sans erreur : il est parfaitement légal. Deux parades : activer -Wall, qui signale « suggest parentheses around assignment used as truth value », et prendre l'habitude d'écrire la constante à gauche — if (0 == x) — de sorte qu'une faute de frappe donne une erreur de compilation au lieu d'un bogue silencieux.

Traduire le pseudocode d'Algorithmique 1

C'est l'exercice central du chapitre, et il est presque mécanique.

PseudocodeC
Lire(x)scanf("%d", &x);
Écrire(x)printf("%d\n", x);
x ← 5x = 5;
Si c Alors ... FinSiif (c) { ... }
Pour i de 1 à nfor (int i = 1; i <= n; i++)
Tant que c Fairewhile (c) { ... }
T[i] (indices de 1 à n)T[i-1] (indices de 0 à n−1)

La dernière ligne est la seule vraie difficulté, et elle vaut un avertissement. Le pseudocode du cours numérote souvent les tableaux à partir de 1 ; le C numérote à partir de 0. Une traduction distraite produit soit un décalage d'un rang, soit un débordement d'une case — les deux fautes les plus fréquentes du bloc III, et le C ne signalera ni l'une ni l'autre.

La bonne pratique n'est pas de traduire T[i] en T[i-1] mais de réécrire les bornes : for (int i = 0; i < n; i++), avec un < strict. Cet idiome donne exactement n tours et des indices valides de 0 à n−1 ; il vaut mieux l'adopter une fois pour toutes que de compter à chaque boucle.

Quiz · 1 question

Un switch sur un menu affiche « Ajouter » puis « Supprimer » quand l'utilisateur choisit 1. Quelle est la cause ?

  • Le case 1 est mal écrit et intercepte aussi la valeur 2case mal écrit
  • Il manque un break à la fin du cas 1 : sans lui, l'exécution GLISSE dans le cas suivant, ce qui est le comportement normal du switch en Cglissement
  • Le default est placé avant les cas, donc tous les cas sont exécutésdefault mal placé

Réponse : Le switch du C ne choisit pas un bloc : il calcule un POINT D'ENTRÉE. Une fois entré au bon case, l'exécution continue en séquence jusqu'à un break ou jusqu'à la fin du switch, en traversant les étiquettes suivantes comme si elles n'existaient pas. Ce n'est pas un défaut mais une fonctionnalité — elle permet d'écrire case 1: case 2: case 3: pour un traitement commun, sans rien répéter. Seulement l'oubli est fréquent et silencieux : gcc propose -Wimplicit-fallthrough pour le signaler, et la convention est de commenter explicitement les glissements volontaires. À noter aussi que la place du default est libre : il n'est atteint que si aucune étiquette ne correspond, où qu'il soit écrit.

À vous

L'exercice traduit du pseudocode en C — simulé en JavaScript, dont la syntaxe de contrôle est assez proche pour que la traduction reste fidèle — puis reproduit les trois pièges du chapitre.

D'abord la classe de bogue d'Apple : un bloc conditionnel sans accolades auquel on ajoute une ligne, et l'effet sur le résultat. Ensuite le glissement de switch, avec le cas où il est volontaire et celui où il est fautif. Enfin le point-virgule fantôme après un for.

La dernière partie est la plus utile : la traduction d'un algorithme d'Algorithmique 1 — somme, maximum, recherche — avec des indices à partir de 0, et un test qui vérifie qu'aucune case n'a été lue hors bornes.

Exercice de code

Reproduisez le bogue « goto fail », le glissement de switch, et corrigez une traduction d'indices.

Point de départ

// ── 1. La classe de bogue « goto fail » ───────────────────────────────────
// Un if sans accolades ne gouverne QUE l'instruction suivante.
function verifier(etapes) {
  let erreur = 0;
  for (const e of etapes) {
    if ((erreur = e.resultat) !== 0)
      return "ÉCHEC à l'étape " + e.nom;
      // ← quelqu'un a dupliqué la ligne ci-dessus lors d'une fusion.
      //   Ajoutez « return "ÉCHEC (ligne en trop)"; » ici et observez.
  }
  return "certificat valide";
}

// ── 2. Glissement de switch ───────────────────────────────────────────────
function menu(choix) {
  const sortie = [];
  switch (choix) {
    case 1:
      sortie.push("Ajouter");
      // ← break manquant
    case 2:
      sortie.push("Supprimer");
      break;
    case 3:
    case 4:
      sortie.push("Lister");   // glissement VOLONTAIRE : 3 et 4 font pareil
      break;
    default:
      sortie.push("Choix inconnu");
  }
  return sortie.join(" puis ");
}

// ── 3. Traduire le pseudocode d'Algorithmique 1 ───────────────────────────
// Pseudocode, indices de 1 à n :
//     max ← T[1]
//     Pour i de 2 à n Faire
//         Si T[i] > max Alors max ← T[i]
//
// Traduction en C, indices de 0 à n−1. Le tableau est instrumenté : tout
// accès hors bornes est enregistré au lieu de passer inaperçu.
function tableauSurveille(valeurs) {
  const hors = [];
  return {
    n: valeurs.length,
    lire(i) {
      if (i < 0 || i >= valeurs.length) { hors.push(i); return undefined; }
      return valeurs[i];
    },
    horsBornes: () => hors,
  };
}

function maximum(T) {
  let max = T.lire(1);          // ← traduction distraite de T[1]
  for (let i = 2; i <= T.n; i++) {
    if (T.lire(i) > max) max = T.lire(i);
  }
  return max;
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Ajoutez la ligne en trop dans verifier() et constatez l'effet.
// 2. Complétez menu() pour que le choix 1 n'affiche que « Ajouter ».
// 3. Corrigez maximum() : bornes de 0 à n−1, avec un < strict. Vérifiez
//    qu'aucun accès hors bornes n'est enregistré.

const T = tableauSurveille([3, 17, 8, 42, 5]);
console.log("maximum :", maximum(T), "| hors bornes :", JSON.stringify(T.horsBornes()));

Solution

function verifierAvecBogue(etapes) {
  let erreur = 0;
  for (const e of etapes) {
    if ((erreur = e.resultat) !== 0)
      return "ÉCHEC à l'étape " + e.nom;
      return "ÉCHEC (ligne en trop)";   // hors du if : TOUJOURS exécutée
  }
  return "certificat valide";
}

function verifierCorrige(etapes) {
  let erreur = 0;
  for (const e of etapes) {
    // Les accolades coûtent deux caractères et suppriment la classe entière
    // de bogues : la ligne ajoutée est visiblement DANS le bloc, ou hors.
    if ((erreur = e.resultat) !== 0) {
      return "ÉCHEC à l'étape " + e.nom;
    }
  }
  return "certificat valide";
}

function menu(choix) {
  const sortie = [];
  switch (choix) {
    case 1:
      sortie.push("Ajouter");
      break;                     // le break qui manquait
    case 2:
      sortie.push("Supprimer");
      break;
    case 3:
    case 4:
      sortie.push("Lister");     // glissement volontaire, à commenter
      break;
    default:
      sortie.push("Choix inconnu");
  }
  return sortie.join(" puis ");
}

function tableauSurveille(valeurs) {
  const hors = [];
  return {
    n: valeurs.length,
    lire(i) {
      if (i < 0 || i >= valeurs.length) { hors.push(i); return undefined; }
      return valeurs[i];
    },
    horsBornes: () => hors,
  };
}

function maximumFaux(T) {
  let max = T.lire(1);
  for (let i = 2; i <= T.n; i++) if (T.lire(i) > max) max = T.lire(i);
  return max;
}

function maximumJuste(T) {
  // On ne traduit pas T[i] en T[i-1] : on RÉÉCRIT LES BORNES. L'idiome
  // « i = 0 ; i < n ; i++ » donne exactement n tours et des indices valides.
  let max = T.lire(0);
  for (let i = 1; i < T.n; i++) {
    const v = T.lire(i);
    if (v > max) max = v;
  }
  return max;
}

console.log("— 1. la ligne en trop —");
const etapes = [{ nom: "hachage", resultat: 0 }, { nom: "signature", resultat: 0 }];
console.log("   avec la ligne en trop :", verifierAvecBogue(etapes));
console.log("   avec les accolades    :", verifierCorrige(etapes));
console.log("   (le premier n'a vérifié AUCUNE étape : il sort au premier tour)");

console.log("");
console.log("— 2. switch —");
for (const c of [1, 2, 3, 4, 9]) console.log("   choix " + c + " -> " + menu(c));

console.log("");
console.log("— 3. traduction du pseudocode —");
const A = tableauSurveille([3, 17, 8, 42, 5]);
console.log("   version distraite : max =", maximumFaux(A),
            "| accès hors bornes :", JSON.stringify(A.horsBornes()));
const B = tableauSurveille([3, 17, 8, 42, 5]);
console.log("   version corrigée  : max =", maximumJuste(B),
            "| accès hors bornes :", JSON.stringify(B.horsBornes()));
// La version distraite lit T[5] — une case au-delà — et rate T[0]. Ici le
// maximum tombe juste par chance ; avec [42, 3, 17, 8, 5] elle rendrait 17.
const C = tableauSurveille([42, 3, 17, 8, 5]);
console.log("   sur [42,3,17,8,5] : distraite =", maximumFaux(C),
            " (le 42 en tête n'a jamais été lu)");
// En C, cet accès hors bornes ne provoque AUCUNE erreur : il lit l'octet
// suivant en mémoire. C'est le sujet du chapitre 5.

En travaux pratiques

Travaux pratiques 3 · 2 h

Les pièges du contrôle

Rencontrer, dans du code qui compile sans le moindre avertissement, les quatre erreurs de structure qui coûtent le plus de temps en C.

Avant de commencer

  • Les TP 1 et 2

Énoncé

  1. Le point-virguleÉcrivez une boucle qui compte de 1 à 10 en plaçant un point-virgule juste après la parenthèse fermante du for. Compilez avec -Wall et exécutez. Que se passe-t-il ?
  2. L'affectation dans le testÉcrivez un si qui compare une variable à 5 en utilisant un seul signe égal. Exécutez. Puis trouvez l'écriture qui aurait fait échouer la compilation. Indice : Mettre la constante à gauche.
  3. Le switch sans breakÉcrivez un aiguillage à quatre cas sans aucun break. Exécutez avec chaque valeur et notez les sorties. Trouvez ensuite un cas où l'absence de break est VOULUE.
  4. Le bloc manquantÉcrivez un si sans accolades suivi de deux instructions indentées identiquement. Exécutez, et expliquez pourquoi l'indentation vous a menti.
  5. Boucle et condition composéeÉcrivez une recherche dans un tableau qui s'arrête dès qu'elle trouve. Écrivez-la de trois façons : avec break, avec un drapeau, avec la condition dans le while. Comparez la lisibilité.
  6. Au fil rougeAjoutez à journal le comptage des lignes contenant un mot donné, avec un rapport final. Testez sur un fichier vide, un fichier d'une ligne, et un gros fichier.

C'est réussi quand

  • Vous savez expliquer sans exécuter ce qu'affiche une boucle terminée par un point-virgule
  • Vous écrivez vos comparaisons de façon qu'un signe égal oublié ne compile pas
  • Votre journal donne le bon compte sur un fichier vide

Correction

Le point-virgule fantôme
for (int i = 1; i <= 10; i++);      /* ← ce point-virgule */
  printf("%d ", i);

/* le corps de la boucle est l'instruction VIDE.
 Le printf est exécuté UNE fois, après la boucle —
 et ne compile même pas ici, car i n'existe plus. */

Une instruction vide est licite en C, et le compilateur n'a rien à redire. gcc signale ce cas avec -Wempty-body, qui n'est pas dans -Wall. C'est la meilleure illustration du principe du langage : le C fait ce que vous écrivez, pas ce que vous vouliez écrire.

Affectation contre comparaison
if (x = 5) …    /* AFFECTE 5 à x, puis teste 5, donc toujours VRAI */
if (x == 5) …   /* compare */

/* l'écriture qui protège : la constante à gauche */
if (5 == x) …   /* correct */
if (5 = x)  …   /* ERREUR de compilation : on ne peut affecter à 5 */

En C, une affectation est une EXPRESSION qui a une valeur — c'est ce qui rend l'erreur possible et ce qui permet d'écrire while ((c = getchar()) != EOF). L'écriture inversée est un garde-fou mécanique, et les doubles parenthèses de la seconde forme signalent au compilateur, et au lecteur, que l'affectation est voulue.

Le switch traversant
switch (n) {
case 1: printf("un ");
case 2: printf("deux ");
case 3: printf("trois ");
default: printf("fin\n");
}
n=1 → « un deux trois fin »    n=3 → « trois fin »

/* le cas où c'est VOULU */
switch (c) {
case 'a': case 'e': case 'i': case 'o': case 'u':
    voyelles++;
    break;
}

La traversée est un choix du langage, pas un accident : elle permet de regrouper des cas. Mais comme elle est implicite, l'oubli d'un break ne produit aucun avertissement par défaut. Écrire un commentaire explicite là où la traversée est voulue est la convention universelle — et gcc reconnaît -Wimplicit-fallthrough pour signaler les autres.

L'indentation qui ment
if (erreur)
  fprintf(stderr, "échec\n");
  return 1;              /* ← TOUJOURS exécuté */

/* le compilateur lit : */
if (erreur) fprintf(…);
return 1;

Le C ignore complètement l'indentation ; seules les accolades comptent. Cette faute a produit en 2014 la faille « goto fail » d'Apple, qui désactivait la vérification des certificats TLS sur des millions d'appareils. La règle qui l'élimine tient en une phrase : des accolades TOUJOURS, même pour une seule instruction.

Les trois recherches
/* 1. break — la plus directe */
for (i = 0; i < n; i++) if (t[i] == cible) break;
if (i < n) …

/* 2. drapeau — verbeuse, mais parfois nécessaire */
int trouve = 0;
for (i = 0; i < n && !trouve; i++) if (t[i] == cible) trouve = 1;

/* 3. condition dans le while — la plus dense */
i = 0;
while (i < n && t[i] != cible) i++;

Les trois sont correctes. La troisième repose sur l'ÉVALUATION PARESSEUSE : si i < n est faux, t[i] n'est jamais lu — sans cette garantie, elle sortirait du tableau. C'est une propriété du langage, pas un hasard, et elle sera le fondement de la moitié des vérifications de pointeur du TP 7.

Ce que la suite en fait

Le chapitre 4 découpe le programme en fonctions, ce qui introduit la vraie difficulté du bloc II : le passage par valeur. Une fonction reçoit des copies, donc elle ne peut pas modifier les variables de son appelant — et le premier réflexe, écrire une fonction echanger(a, b), ne fonctionne pas. La solution demande le bloc IV.

Les boucles reviendront aussi au chapitre 5 : parcourir un tableau est la construction la plus fréquente du langage, et c'est là que le décalage d'indice se paie.

À retenir

Flashcards · 4 cartes

Pourquoi if (x = 5) compile-t-il, et quelles parades ?
Parce qu'une condition en C est un ENTIER — zéro est faux, tout le reste est vrai — et qu'une affectation est une EXPRESSION dont la valeur est celle affectée. if (x = 5) affecte 5 puis teste 5 : toujours vrai, et x est écrasé. Parades : -Wall le signale, et écrire la constante à gauche — if (5 == x) — transforme la faute de frappe en erreur de compilation. Autre conséquence du même principe : if (strcmp(a,b)) est vrai quand les chaînes DIFFÈRENT.
Pourquoi faut-il toujours mettre les accolades après un if ?
Parce qu'un if sans accolades ne gouverne QUE l'instruction suivante, quelle que soit l'indentation. C'est la faille « goto fail » d'Apple en 2014 : une ligne goto fail dupliquée, hors du if faute d'accolades, sautait toute la vérification du certificat TLS et rendait « certificat valide » pour n'importe quel certificat. Coût des accolades : nul. Bénéfice : une classe entière de bogues éliminée.
Qu'est-ce que le glissement dans un switch, et est-ce un défaut ?
Le switch calcule un POINT D'ENTRÉE, pas un bloc : une fois entré au bon case, l'exécution continue en séquence jusqu'à un break ou la fin, traversant les étiquettes suivantes. Ce n'est pas un défaut mais une fonctionnalité — elle permet case 1: case 2: case 3: pour un traitement commun. Mais l'oubli est fréquent et silencieux : -Wimplicit-fallthrough le signale, et on commente les glissements volontaires.
Quel est le piège de la traduction du pseudocode vers le C, et quel idiome adopter ?
Le pseudocode numérote souvent les tableaux À PARTIR DE 1, le C à partir de 0. Traduire distraitement produit un décalage d'un rang ou un débordement d'une case, et le C ne signale ni l'un ni l'autre. La bonne pratique n'est pas d'écrire T[i-1] mais de RÉÉCRIRE LES BORNES : for (int i = 0; i < n; i++), avec un < strict — exactement n tours, indices valides de 0 à n−1.

Chapitre 2 · 7 h

Fonctions et modularité

Prototype, définition, valeur de retour ; portée des variables ; passage par valeur ; fonctions récursives ; compilation séparée, en-têtes et Makefile.

Écrivons la fonction la plus banale qui soit : échanger deux variables.

void echanger(int a, int b) {    int t = a;    a = b;    b = t;} int main(void) {    int x = 3, y = 7;    echanger(x, y);    printf("%d %d\n", x, y);      /* affiche 3 7 */}

Elle ne fonctionne pas. Le code est correct, il compile sans le moindre avertissement, et il ne fait rien. La raison tient en une règle que ce chapitre doit installer : en C, une fonction ne reçoit jamais vos variables, elle en reçoit des copies.

C'est la difficulté du bloc II, et sa résolution demandera le bloc IV — d'où la place de ce chapitre, juste avant les tableaux et juste après le contrôle du flux.

Déclarer et définir

Le C distingue deux choses que les langages récents confondent.

La définition est le code complet de la fonction. La déclaration — ou prototype — n'est que sa signature, terminée par un point-virgule.

int maximum(int a, int b);              /* déclaration : la signature */ int maximum(int a, int b) {             /* définition : le code */    return (a > b) ? a : b;}

Le prototype existe parce que le compilateur travaille de haut en bas et un fichier à la fois — c'est le chapitre 1. Pour vérifier un appel, il doit connaître la signature avant de rencontrer l'appel. Deux solutions : définir la fonction plus haut, ou la déclarer en tête de fichier. La seconde est la bonne, parce qu'elle permet de ranger les fonctions dans un ordre logique et qu'elle est indispensable dès qu'il y a plusieurs fichiers.

Sans prototype, les compilateurs anciens supposaient un retour int et ne vérifiaient rien. La norme C99 a interdit cette « déclaration implicite », mais gcc se contente parfois d'un avertissement : c'en est un qu'il ne faut jamais ignorer, car il signale que le compilateur invente une signature.

Un mot sur void, qui a deux emplois distincts. En type de retour, il signifie « ne rend rien ». En liste de paramètres, int f(void) signifie « ne prend aucun paramètre » — tandis que int f() signifie historiquement « prend un nombre non spécifié de paramètres », ce qui désactive toute vérification. Écrire (void) n'est donc pas une coquetterie.

Le passage par valeur

Voici la règle, et il faut la formuler exactement. Tout argument est copié dans un nouvel emplacement mémoire — un paramètre est une variable locale à la fonction, initialisée avec la valeur de l'argument.

main                          echanger┌──────────┐                  ┌──────────┐│ x  │  3  │  ──copie──►      │ a  │  3  ││ y  │  7  │  ──copie──►      │ b  │  7  │└──────────┘                  └──────────┘                              après l'échange : a = 7, b = 3                              …et les copies disparaissent au retour

La fonction a parfaitement échangé ses copies. Celles-ci vivent dans son cadre d'appel — la pile du chapitre 1 d'Algorithmique 2 — et ce cadre est détruit au retour. Les variables de main n'ont jamais été touchées.

Le passage par valeur a un mérite considérable, qu'il faut souligner avant d'en montrer la limite : une fonction ne peut pas modifier ce qu'on ne lui a pas explicitement donné le droit de modifier. C'est une garantie de raisonnement locale, que peu de langages offrent aussi franchement.

La limite est évidente : il faut bien pouvoir modifier. La solution du C est de passer non pas la variable, mais son adresse — le passage par adresse du chapitre 7. Vous l'avez déjà rencontré sans le nommer : c'est l'esperluette de scanf("%d", &age). Cette fonction doit modifier votre variable, donc elle en reçoit l'adresse.

Retenez la formulation exacte : le C ne connaît que le passage par valeur. Le passage par adresse n'est pas une seconde façon de passer les arguments — c'est un passage par valeur d'une adresse.

Portée et durée de vie

Ce sont deux notions distinctes, et les confondre produit des erreurs curieuses.

La portée dit un nom est visible. La durée de vie dit combien de temps la donnée existe.

DéclarationPortéeDurée de vieInitialisation par défaut
Variable localele blocl'exécution du blocaucune (contenu indéfini)
Paramètrela fonctionl'appella valeur de l'argument
Variable globaletout le fichier, voire le programmetout le programmezéro
Locale staticle bloctout le programmezéro

Les deux dernières lignes appellent des commentaires.

Une variable globale est visible partout et vit du début à la fin. C'est commode, et c'est généralement une mauvaise idée : n'importe quelle fonction peut la modifier, donc plus aucun raisonnement local n'est possible, et les bogues qui en découlent sont difficiles à localiser. On les réserve aux constantes et aux cas où l'on a une raison précise.

Une locale static conserve sa valeur d'un appel à l'autre : elle est allouée une fois pour toutes, comme une globale, mais son nom reste confiné au bloc. C'est le moyen propre de donner une mémoire à une fonction — un compteur d'appels, un cache — sans polluer l'espace de noms.

Le mot-clé static a d'ailleurs un second sens, qui n'a rien à voir : appliqué à une fonction ou à une variable globale, il en restreint la visibilité au fichier. C'est l'équivalent C du « privé », et c'est la bonne pratique pour toute fonction auxiliaire qu'on ne veut pas exposer — l'éditeur de liens du chapitre 1 ne verra même pas le symbole.

Récursivité en C

Rien de nouveau par rapport au bloc I d'Algorithmique 2 : mêmes cas de base, même variant, même pile.

unsigned long factorielle(unsigned int n) {    if (n <= 1) return 1;               /* cas de base */    return n * factorielle(n - 1);      /* cas récursif */}

Deux précisions propres au C.

La pile est finie et petite : 8 Mio par défaut sous Linux, soit quelques centaines de milliers de cadres. Un dépassement ne produit pas d'exception : le programme est tué par le système avec une erreur de segmentation, message trompeur qui suggère un problème de pointeur alors qu'il s'agit d'une récursion mal terminée.

L'optimisation d'appel terminal n'est pas garantie par la norme. gcc -O2 l'applique souvent, gcc -O0 presque jamais — donc un code qui n'y survit qu'en mode optimisé est un code fragile. En C, une récursion profonde s'écrit itérativement.

Compilation séparée

Dès que le projet dépasse quelques centaines de lignes, on le découpe — et l'on retrouve les étages du chapitre 1.

Chaque module a deux fichiers. Le .h contient l'interface : prototypes, types, constantes. Le .c contient l'implémentation. Les autres modules incluent le .h et ignorent tout du .c.

/* pile.h */#ifndef PILE_H                    /* garde d'inclusion */#define PILE_H typedef struct Pile Pile;         /* type opaque : le contenu reste caché */void empiler(Pile *p, int v);int  depiler(Pile *p); #endif

La garde d'inclusion est indispensable : le #include étant un copier-coller, un en-tête inclus par deux chemins différents serait recopié deux fois, et le compilateur signalerait des redéfinitions. Le trio #ifndef / #define / #endif fait que la seconde copie est vide.

La compilation se fait alors en deux temps — chaque .c vers son .o, puis l'édition de liens — ce qui a un avantage décisif sur les gros projets : modifier un fichier ne demande de recompiler que celui-là. C'est le travail du Makefile, qui décrit les dépendances et ne reconstruit que ce dont la source est plus récente que la cible.

programme: main.o pile.o	gcc -o programme main.o pile.o main.o: main.c pile.h	gcc -Wall -g -c main.c clean:	rm -f *.o programme

Deux règles à connaître : les lignes de commande commencent par une tabulation, pas des espaces — l'erreur la plus fréquente des débutants — et un .o doit dépendre des .h qu'il inclut, sans quoi une modification d'interface ne déclenche aucune recompilation.

Quiz · 1 question

Pourquoi la fonction void echanger(int a, int b) ne modifie-t-elle pas les variables de l'appelant ?

  • Parce qu'elle est déclarée void : une fonction sans valeur de retour ne peut rien modifierà cause de void
  • Parce que les arguments sont COPIÉS : a et b sont des variables locales à la fonction, initialisées avec les valeurs de x et y. Elle échange ses copies, qui disparaissent au retourcopie des arguments
  • Parce qu'il manque le mot-clé static devant les paramètresstatic manquant

Réponse : Le C ne connaît QUE le passage par valeur. Un paramètre est une variable locale, allouée dans le cadre d'appel et initialisée avec la valeur de l'argument — les variables de l'appelant ne sont jamais atteignables. La fonction échange donc parfaitement ses deux copies, puis le cadre est détruit au retour et le travail est perdu. Le type void n'y est pour rien : une fonction qui rend une valeur aurait exactement le même comportement sur ses paramètres. Cette règle a un mérite qu'il faut voir : une fonction ne peut pas modifier ce qu'on ne lui a pas donné le droit de modifier, ce qui rend le raisonnement local. Pour permettre la modification, on passe l'ADRESSE de la variable — c'est le & de scanf, et c'est le chapitre 7.

Quiz · 1 question

Quelle est la différence entre une variable globale et une variable locale déclarée static ?

  • Aucune : static sur une locale la transforme exactement en globaleidentiques
  • Leur DURÉE DE VIE est la même — tout le programme, initialisée à zéro — mais leur PORTÉE diffère : le nom d'une locale static reste confiné à son bloc, alors qu'une globale est visible partoutmême durée, portée différente
  • Une locale static est allouée sur la pile, une globale sur le taspile contre tas

Réponse : Portée et durée de vie sont deux notions indépendantes, et static sur une locale les dissocie. La DURÉE DE VIE devient celle du programme : la variable est allouée une fois, dans la zone des données du chapitre 3 du cours de systèmes — pas sur la pile —, initialisée à zéro, et conserve sa valeur d'un appel à l'autre. La PORTÉE, elle, reste le bloc : aucune autre fonction ne peut la nommer. C'est le moyen propre de donner une mémoire à une fonction — compteur d'appels, cache — sans polluer l'espace de noms ni exposer la variable à des modifications extérieures. Attention au second sens du mot-clé, qui n'a aucun rapport : static devant une fonction ou une variable GLOBALE en restreint la visibilité au fichier, ce qui est l'équivalent C du « privé ».

À vous

L'exercice simule le passage par valeur et la portée, en modélisant explicitement les cadres d'appel — le seul moyen de rendre visible ce que le C fait silencieusement.

Trois temps. L'échange qui ne fonctionne pas, avec l'affichage des deux cadres avant et après : on voit les copies changer et les originaux rester. Puis le compteur d'appels, écrit avec une variable locale ordinaire — qui repart de zéro — puis avec une locale static — qui se souvient. Enfin un mini-Makefile : vous décrivez les dépendances et le programme dit ce qu'il faut recompiler après modification d'un fichier.

Exercice de code

Rendez visibles les cadres d'appel, opposez locale et static, puis écrivez un mini-make.

Point de départ

// ── 1. Les cadres d'appel, rendus visibles ────────────────────────────────
function cadre(nom, variables) {
  return { nom, variables };
}
function afficher(...cadres) {
  for (const c of cadres) {
    console.log("   " + c.nom.padEnd(10) +
      Object.entries(c.variables).map(([k, v]) => k + " = " + v).join("  |  "));
  }
}

// En C, appeler echanger(x, y) COPIE les valeurs dans de nouvelles cases.
function echangerParValeur(appelant) {
  const local = cadre("echanger", { a: appelant.variables.x, b: appelant.variables.y });
  console.log("   à l'entrée :");
  afficher(appelant, local);

  const t = local.variables.a;
  local.variables.a = local.variables.b;
  local.variables.b = t;

  console.log("   après l'échange des COPIES :");
  afficher(appelant, local);
  // le cadre local disparaît ici : rien n'est rendu à l'appelant
}

// ── 2. Portée et durée de vie ─────────────────────────────────────────────
// Une locale ordinaire est recréée à chaque appel.
function compteurLocal() {
  let n = 0;      // recréée, réinitialisée
  n++;
  return n;
}

// Une locale « static » survit d'un appel à l'autre. En JS on l'imite par
// une variable de portée englobante — ce qui montre bien la dissociation
// entre PORTÉE (le bloc) et DURÉE DE VIE (le programme).
const compteurStatic = (function () {
  let n = 0;      // ← allouée UNE fois, invisible de l'extérieur
  return function () { return 0; };   // ← à écrire
})();

// ── 3. Un mini-Makefile ───────────────────────────────────────────────────
const REGLES = {
  "programme": ["main.o", "pile.o"],
  "main.o": ["main.c", "pile.h"],
  "pile.o": ["pile.c", "pile.h"],
};

// dates[fichier] = horodatage de dernière modification
function aReconstruire(cible, dates, sortie = []) {
  const deps = REGLES[cible];
  if (!deps) return sortie;                    // un fichier source : rien à faire
  // ← à écrire : reconstruire d'abord les dépendances, puis la cible si
  //   l'une d'elles est plus récente qu'elle.
  return sortie;
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Lancez l'échange et constatez que x et y n'ont pas bougé.
// 2. Écrivez compteurStatic, et comparez les deux compteurs sur trois appels.
// 3. Écrivez aReconstruire : après modification de pile.h, que faut-il
//    recompiler ? Et après modification de main.c seul ?

const principal = cadre("main", { x: 3, y: 7 });
echangerParValeur(principal);
console.log("   de retour dans main :");
afficher(principal);

Solution

function cadre(nom, variables) { return { nom, variables }; }
function afficher(...cadres) {
  for (const c of cadres) {
    console.log("   " + c.nom.padEnd(10) +
      Object.entries(c.variables).map(([k, v]) => k + " = " + v).join("  |  "));
  }
}

function echangerParValeur(appelant) {
  const local = cadre("echanger", { a: appelant.variables.x, b: appelant.variables.y });
  console.log("   à l'entrée :");
  afficher(appelant, local);
  const t = local.variables.a;
  local.variables.a = local.variables.b;
  local.variables.b = t;
  console.log("   après l'échange des COPIES :");
  afficher(appelant, local);
}

// La version qui marchera au chapitre 7 : on ne passe plus les valeurs mais
// de quoi ATTEINDRE les cases — ici l'objet du cadre et les noms.
function echangerParAdresse(appelant, nomA, nomB) {
  const t = appelant.variables[nomA];
  appelant.variables[nomA] = appelant.variables[nomB];
  appelant.variables[nomB] = t;
}

function compteurLocal() { let n = 0; n++; return n; }

const compteurStatic = (function () {
  // Allouée UNE fois, au chargement, et jamais réinitialisée. Son nom n'est
  // visible que d'ici : durée de vie du programme, portée du bloc.
  let n = 0;
  return function () { n++; return n; };
})();

const REGLES = {
  "programme": ["main.o", "pile.o"],
  "main.o": ["main.c", "pile.h"],
  "pile.o": ["pile.c", "pile.h"],
};

function aReconstruire(cible, dates, sortie = []) {
  const deps = REGLES[cible];
  if (!deps) return sortie;
  // D'abord les dépendances, en profondeur : une cible ne peut être jugée
  // à jour que si tout ce dont elle dépend l'est déjà.
  for (const d of deps) aReconstruire(d, dates, sortie);
  const dateCible = dates[cible] ?? -Infinity;
  const plusRecente = Math.max(...deps.map((d) => dates[d] ?? 0));
  if (plusRecente > dateCible) {
    sortie.push(cible);
    dates[cible] = plusRecente;   // la reconstruction la met à jour
  }
  return sortie;
}

console.log("— 1. passage par valeur —");
const principal = cadre("main", { x: 3, y: 7 });
echangerParValeur(principal);
console.log("   de retour dans main :");
afficher(principal);
console.log("   x et y n'ont pas bougé : les copies ont disparu avec le cadre.");
console.log("");
echangerParAdresse(principal, "x", "y");
console.log("   avec l'adresse (chapitre 7) :");
afficher(principal);

console.log("");
console.log("— 2. portée et durée de vie —");
for (let i = 1; i <= 3; i++) {
  console.log("   appel " + i + " : locale ordinaire = " + compteurLocal() +
              " | locale static = " + compteurStatic());
}
console.log("   La locale ordinaire est recréée à chaque appel ; la static est");
console.log("   allouée une fois, initialisée à zéro, et se souvient.");

console.log("");
console.log("— 3. mini-Makefile —");
for (const [quoi, dates] of [
  ["on modifie pile.h", { "main.c": 1, "pile.c": 1, "pile.h": 9, "main.o": 5, "pile.o": 5, "programme": 6 }],
  ["on modifie main.c seul", { "main.c": 9, "pile.c": 1, "pile.h": 1, "main.o": 5, "pile.o": 5, "programme": 6 }],
  ["rien n'a changé", { "main.c": 1, "pile.c": 1, "pile.h": 1, "main.o": 5, "pile.o": 5, "programme": 6 }],
]) {
  const r = aReconstruire("programme", { ...dates });
  console.log("   " + quoi.padEnd(24) + "-> " + (r.length ? r.join(", ") : "rien à faire"));
}
// Modifier un en-tête force la recompilation de tous les .o qui l'incluent :
// c'est pourquoi un .o doit DÉPENDRE des .h, faute de quoi make ne verrait
// rien et l'on déboguerait un binaire construit sur une interface périmée.

En travaux pratiques

Travaux pratiques 4 · 3 h

Découper, et buter sur le passage par valeur

Modulariser le fil rouge, puis rencontrer la limite qui rend les pointeurs inévitables : une fonction ne peut pas modifier son argument.

Avant de commencer

  • Les TP 1 à 3
  • Le fil rouge journal, en deux fichiers

Énoncé

  1. La fonction qui ne marche pasÉcrivez une fonction echanger qui prend deux entiers et échange leurs valeurs. Appelez-la et affichez le résultat. Constatez l'échec, puis expliquez-le avec un schéma de la pile.
  2. Portée et durée de vieÉcrivez une fonction avec une variable locale, la même en static, et une globale. Appelez la fonction trois fois et comparez les trois comportements.
  3. Le pointeur qui pendÉcrivez une fonction qui renvoie l'adresse d'une variable locale. Compilez avec -Wall, lisez l'avertissement, exécutez quand même et expliquez ce qui se passe. Indice : L'espace de la variable est repris par l'appel suivant.
  4. Découper le fil rougeRéorganisez journal en trois modules : lecture, analyse, affichage. Chaque module a son en-tête. Aucune variable globale ne doit subsister.
  5. Le contrat d'une fonctionPour chaque fonction publique, écrivez au-dessus ce qu'elle attend, ce qu'elle rend, et ce qu'elle fait en cas d'erreur. Puis vérifiez que le code respecte ce que vous avez écrit.
  6. Récursivité, en avant-goûtÉcrivez la factorielle en récursif et en itératif. Faites-les tourner sur 20, puis sur 100000. Notez ce qui se passe dans le second cas.
  7. Static au niveau fichierRendez privée une fonction utilitaire de votre module d'analyse. Tentez de l'appeler depuis main et lisez l'erreur.

C'est réussi quand

  • Vous expliquez l'échec d'echanger sans employer le mot « bogue »
  • Vos trois modules compilent séparément et aucune variable globale ne subsiste
  • La factorielle récursive de 100000 échoue, et vous savez sur quelle ressource

Correction

Pourquoi echanger ne marche pas
void echanger(int a, int b) { int t = a; a = b; b = t; }

int x = 1, y = 2;
echanger(x, y);
printf("%d %d\n", x, y);   → 1 2  (inchangés)

pile :
main      : x=1     y=2
echanger  : a=1     b=2      ← des COPIES
après     : a=2     b=1      ← les copies sont échangées
retour    : les copies sont détruites, x et y n'ont jamais bougé

En C, un argument est TOUJOURS copié — il n'existe pas de passage par référence. La fonction ne reçoit pas la variable, elle reçoit sa valeur. Pour modifier une variable de l'appelant, il n'y a qu'une voie : recevoir son ADRESSE. C'est ce constat, et non une envie d'abstraction, qui rend le chapitre 7 nécessaire.

Trois durées de vie
void f(void) {
  int         a = 0;   /* pile   : recréée à chaque appel */
  static int  b = 0;   /* statique : créée UNE fois, survit */
  a++; b++; g++;       /* g globale */
  printf("%d %d %d\n", a, b, g);
}
f(); f(); f();
→ 1 1 1
1 2 2
1 3 3

static à l'intérieur d'une fonction change la DURÉE DE VIE sans changer la portée : la variable persiste mais reste invisible ailleurs. C'est utile pour un compteur d'appels ou un cache — et c'est un piège en présence de fils d'exécution, parce que cet état est partagé. Une fonction avec un static n'est pas réentrante.

Le pointeur pendant
int *mauvais(void) {
  int local = 42;
  return &local;        /* warning: address of local variable returned */
}

int *p = mauvais();
autre_fonction();          /* réutilise le même espace de pile */
printf("%d\n", *p);       /* affiche n'importe quoi */

L'espace n'est pas effacé, il est RÉUTILISÉ : c'est pourquoi le programme affiche parfois 42 et donne l'illusion de fonctionner. Un bogue qui marche par hasard est plus dangereux qu'un bogue qui plante. Un objet doit survivre à la fonction qui le crée : d'où l'allocation dynamique du TP 8, ou le fait de recevoir de l'appelant l'espace où écrire.

Le fil rouge modularisé
lecture.h    int   lire_lignes(const char *chemin, char ***lignes);
analyse.h    Stats analyser(char **lignes, int n);
affichage.h  void  afficher(const Stats *s);

main.c :
  char **lignes; 
  int n = lire_lignes(argv[1], &lignes);
  if (n < 0) return 1;
  Stats s = analyser(lignes, n);
  afficher(&s);

Chaque module a une responsabilité et une seule, et main ne fait plus qu'orchestrer. Notez le triple pointeur de lire_lignes : la fonction doit modifier un tableau appartenant à l'appelant, donc en recevoir l'adresse — application directe de l'étape 1. Le retour négatif signale l'erreur, convention qu'il faut écrire et tenir partout.

Le contrat, et pourquoi l'écrire
/* Lit toutes les lignes de « chemin ».
* Attend  : chemin non NULL, lignes non NULL.
* Rend    : le nombre de lignes, ou -1 en cas d'erreur.
* Alloue  : *lignes, à libérer par l'appelant avec liberer_lignes().
* En cas d'échec : *lignes n'est pas modifié. */
int lire_lignes(const char *chemin, char ***lignes);

La ligne qui compte le plus est celle de l'allocation : en C, il faut dire QUI libère, sinon personne ne le fait ou deux le font. Ce commentaire n'est pas de la documentation d'agrément, c'est la seule spécification que le compilateur ne peut pas vérifier à votre place — const en dit une partie, jamais toute.

Le débordement de pile
factorielle(20)     → 2432902008176640000, correct
factorielle(100000) → Segmentation fault

100 000 appels imbriqués × ~48 octets de trame ≈ 4,8 Mo
pile par défaut : 8 Mo, mais chaque appel coûte aussi
l'adresse de retour et les registres sauvegardés

ulimit -s   → 8192 (Ko)

Ce n'est pas la mémoire qui manque, c'est la PILE, dont la taille est fixée. La version itérative n'utilise qu'une trame, quelle que soit l'entrée. C'est exactement le mécanisme du TP 6 d'Architecture, vu depuis le C, et c'est le premier chapitre d'Algorithmique 2 qui en fera la théorie.

Ce que la suite en fait

Le bloc III applique tout cela aux tableaux, et y ajoute une exception majeure : un tableau passé en paramètre n'est pas copié. C'est la première fissure dans la règle du passage par valeur, et l'explication — un tableau se convertit en pointeur sur son premier élément — demandera le chapitre 7.

Le bloc IV résoudra enfin l'échange, en trois lignes. Vous les lirez alors comme la conclusion de ce chapitre, et non comme une syntaxe nouvelle.

À retenir

Flashcards · 5 cartes

Pourquoi le C exige-t-il un prototype, et que signifie int f(void) ?
Parce que le compilateur travaille DE HAUT EN BAS et un fichier à la fois : pour vérifier un appel, il doit connaître la signature avant de le rencontrer. Le prototype permet de ranger les fonctions dans un ordre logique et devient indispensable dès qu'il y a plusieurs fichiers. int f(void) signifie « aucun paramètre » ; int f() signifie historiquement « nombre non spécifié », ce qui désactive toute vérification — écrire (void) n'est donc pas une coquetterie.
Énoncez exactement la règle du passage d'arguments en C.
Le C ne connaît QUE le passage par valeur : tout argument est COPIÉ dans un nouvel emplacement, et un paramètre est une variable locale initialisée avec cette valeur. Une fonction ne peut donc pas modifier les variables de son appelant — c'est ce qui fait échouer echanger(x, y). Mérite de cette règle : une fonction ne modifie que ce qu'on lui a explicitement donné le droit de modifier. Pour permettre la modification, on passe l'ADRESSE — le & de scanf — ce qui reste un passage par valeur, d'une adresse.
Distinguez portée et durée de vie, et les deux sens de static.
La PORTÉE dit OÙ un nom est visible ; la DURÉE DE VIE, COMBIEN DE TEMPS la donnée existe. Une locale static a la durée de vie d'une globale — allouée une fois, initialisée à zéro, conserve sa valeur entre les appels — mais la portée d'une locale : le moyen propre de donner une mémoire à une fonction. Second sens, sans rapport : static devant une fonction ou une globale en restreint la visibilité AU FICHIER — l'équivalent C du privé.
Que faut-il savoir de la récursivité, propre au C ?
La PILE est finie et petite — 8 Mio par défaut sous Linux, quelques centaines de milliers de cadres — et son dépassement ne lève pas d'exception : le processus est tué avec une ERREUR DE SEGMENTATION, message trompeur qui suggère un problème de pointeur. Et l'optimisation d'appel terminal n'est PAS garantie par la norme : gcc -O2 l'applique souvent, -O0 presque jamais. Une récursion profonde s'écrit itérativement.
Quel est le rôle d'une garde d'inclusion, et les deux règles d'un Makefile ?
GARDE D'INCLUSION (#ifndef / #define / #endif) : comme #include est un copier-coller, un en-tête atteint par deux chemins serait recopié deux fois et provoquerait des redéfinitions ; la garde rend la seconde copie vide. MAKEFILE : les lignes de commande commencent par une TABULATION, pas des espaces — l'erreur classique — et un .o doit dépendre des .h qu'il inclut, sans quoi une modification d'interface ne déclenche aucune recompilation.