Programmation C · L2 · Section 6/12
Modularité et compilation
Progression
#Modularité et compilation
Un projet C grandit mal si tout vit dans un fichier. Ce chapitre pose l’architecture en modules : un en-tête qui expose l’interface, un .c qui garde l’implémentation, des gardes d’inclusion qui rendent les double-inclusions inoffensives. Puis nous suivons la chaîne de compilation à l’échelle d’un projet : compiler chaque module séparément, lier, mesurer l’effet des optimisations. L’ossature finale est un Makefile avec cibles de test et sanitizers, prête pour Vaλisp.
#1. Modules : l’en-tête est un contrat
Le fichier d’en-tête ne décrit que ce que l’appelant doit connaître : types exportés, prototypes, constantes. Tout le reste vit dans le .c.
1/* compteur.h — contrat du module */2#ifndef COMPTEUR_H3#define COMPTEUR_H4 5typedef struct Compteur Compteur; /* type opaque : la structure est privée */6 7Compteur *compteur_init(void);8void compteur_incremente(Compteur *c);9long compteur_valeur(const Compteur *c);10void compteur_destroy(Compteur *c);11 12#endif /* COMPTEUR_H */La garde #ifndef COMPTEUR_H / #define / #endif rend le fichier inoffensif inclus plusieurs fois : la deuxième inclusion trouve COMPTEUR_H défini et saute tout le contenu. Sans elle, deux inclusions d’un même en-tête (fréquent via des en-têtes intermédiaires) redéclarent tout et cassent la compilation. #pragma once est une alternative non standard mais universellement supportée ; la garde portable reste la référence.
Le .c correspondant détient la structure et inclut son propre en-tête (pour que le compilateur vérifie leur cohérence) :
1/* compteur.c */2#include <stdlib.h>3#include "compteur.h"4 5struct Compteur { long n; };6 7Compteur *compteur_init(void) {8 Compteur *c = malloc(sizeof *c);9 if (c) c->n = 0;10 return c;11}12void compteur_incremente(Compteur *c) { if (c) ++c->n; }13long compteur_valeur(const Compteur *c) { return c ? c->n : 0; }14void compteur_destroy(Compteur *c) { free(c); }Deux règles d’hygiène : "" pour les en-têtes du projet, <> pour les en-têtes système ; et ne jamais #include un .c : cela duplique les définitions et provoque des symboles définis plusieurs fois à l’édition de liens.
#2. Compiler séparément, lier ensuite
1cc -std=c17 -Wall -Wextra -Wpedantic -c compteur.c -o compteur.o2cc -std=c17 -Wall -Wextra -Wpedantic -c main.c -o main.o3cc main.o compteur.o -o appChaque .c se compile indépendamment : changer compteur.c ne recompile pas main.c. Le linker assemble ensuite les fichiers objets. Les deux erreurs canoniques se lisent ainsi :
undefined reference to 'compteur_init': le symbole est utilisé mais aucun objet ne le définit. Cause :.ooublié sur la ligne de lien, ou fonction déclarée dans le.hmais jamais écrite.multiple definition of 'compteur_init': deux objets définissent le même symbole. Cause typique : un.cinclus par un autre.
La déclaration static sur une fonction ou une variable globale la restreint à son fichier : c’est le mécanisme d’encapsulation en C, à utiliser pour tout ce qui n’est pas dans l’en-tête.
#3. Optimisations : ce que l’on achète et à quel prix
-O0: aucune optimisation, compilation rapide, débogage pas à pas fidèle à la source. Le mode par défaut.-O1/-O2: inlining, élimination de code mort, meilleure allocation de registres.-O2est le standard de production.-O3: ajoute vectorisation et déroulage agressifs ; parfois plus rapide, parfois plus gros et pas plus rapide.-Os: optimise en taille, utile pour l’embarqué.-flto: optimisation à l’édition de liens, permet d’inliner à travers les frontières de fichiers. Se place à la compilation et au lien.
L’ordre de grandeur à retenir : entre -O0 et -O2, un calcul intensif gagne couramment un facteur 2 à 10 ; le code dominé par des entrées/sorties ne bouge pas. Mesurez toujours (time ./app) avant de conclure. Et souvenez-vous du chapitre d’introduction : un comportement indéfini latent peut se manifester précisément quand l’optimiseur commence à supposer des choses.
#4. Un Makefile incrémental avec cibles de test
1CC := cc2CFLAGS := -std=c17 -Wall -Wextra -Wpedantic3SAN := -g -fsanitize=address,undefined4OBJS := main.o compteur.o5 6app: $(OBJS)7 $(CC) $(OBJS) -o $@8 9%.o: %.c compteur.h10 $(CC) $(CFLAGS) -c $< -o $@11 12test: app13 ./app --selftest14 Points qui font la différence : %.o: %.c compteur.h recompile aussi quand l’en-tête change (l’oublier, c’est livrer des objets désynchronisés, bug des plus sournois) ; les recettes commencent par une tabulation, pas des espaces ; .PHONY évite qu’un fichier nommé test ne capture la cible.
#Atelier : structurer Vaλisp
Reprenez le code de Vaλisp (les jalons des chapitres précédents) et découpez-le en modules :
value.h/value.c: représentation des valeurs, constructeurs ;arena.h/arena.c: allocateur ;reader.h/reader.c: tokenizer et parseur ;interp.h/interp.c: environnement et évaluation.
Chaque en-tête porte sa garde, chaque .c inclut son propre .h, toute fonction non exportée est static. Écrivez ensuite le Makefile ci-dessus adapté à cette liste d’objets.
Jalon observable :
1make # recompile uniquement ce qui a changé2touch value.h && make # recompile value.o ET reader.o ET interp.o (ils incluent value.h)3make test # exécute ./app --selftest et affiche OK4make asan # même test sous AddressSanitizer + UBSan, sans fuite ni erreurVérifier que la dépendance aux en-têtes fonctionne
Après touch value.h; make, la sortie doit montrer la recompilation de tous les .c qui incluent value.h, pas seulement value.c. Si un module n’est pas recompilé, sa règle %.o: %.c ne déclare pas value.h comme dépendance : ajoutez-la. C’est exactement le bug de désynchronisation que la règle doit empêcher.