cursus.

Cours 2 · Structures de contrôle et fonctionsLeçon 2 sur 2

Fonctions et modularité

7 h de lecture9 sections Version PDF

À la fin de cette leçon, vous saurez

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 · vérifiez votre compréhension Sans réponse

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

Quiz · vérifiez votre compréhension Sans réponse

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

À 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 · JavaScript · à vous de jouer

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

En attente
// ── 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);

Console de sortie
Le résultat s'affiche dans la console

En travaux pratiques

Travaux pratiques 4 · sur machine

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.

3 h
Avant de commencer
  • Les TP 1 à 3
  • Le fil rouge journal, en deux fichiers
  1. 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. 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. 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.

  4. 4. Découper le fil rouge

    Réorganisez journal en trois modules : lecture, analyse, affichage. Chaque module a son en-tête. Aucune variable globale ne doit subsister.

  5. 5. Le contrat d'une fonction

    Pour 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. 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. 7. Static au niveau fichier

    Rendez 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

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 · 1 / 5Toucher pour retourner
Fin de la leçon

Vous avez parcouru les 9 sections.

Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.