Cours 2 · Structures de contrôle et fonctionsLeçon 2 sur 2
Fonctions et modularité
7 h de lecture9 sections Version PDF
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 retourLa 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 où un nom est visible. La durée de vie dit combien de temps la donnée existe.
| Déclaration | Portée | Durée de vie | Initialisation par défaut |
|---|---|---|---|
| Variable locale | le bloc | l'exécution du bloc | aucune (contenu indéfini) |
| Paramètre | la fonction | l'appel | la valeur de l'argument |
| Variable globale | tout le fichier, voire le programme | tout le programme | zéro |
Locale static | le bloc | tout le programme | zé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); #endifLa 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 programmeDeux 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.
Pourquoi la fonction void echanger(int a, int b) ne modifie-t-elle pas les variables de l'appelant ?
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.
Rendez visibles les cadres d'appel, opposez locale et static, puis écrivez un mini-make.
// ── 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);
En travaux pratiques
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.
- Les TP 1 à 3
- Le fil rouge journal, en deux fichiers
- 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.
- 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. 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. 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 fichier
Rendez privée une fonction utilitaire de votre module d'analyse. Tentez de l'appeler depuis main et lisez l'erreur.
- 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
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.