8. La modularisation
Nous avons vu dans le chapitre précédent qu'un gros programme peut être découpé en plusieurs fonctions de manière à en simplifier la programmation et la lecture. Nous allons aborder dans ce chapitre la manière d'organiser ces fonctions en différents fichiers. Cette façon de travailler évitera au programmeur la manipulation de gros fichiers lourds à maintenir et à compiler. Cela permet aussi la réutilisation de fonctions dans plusieurs applications.
La découpe en modules
La création d'un module doit répondre à une nécessité fonctionnelle (p.e. rassembler toutes les fonctions qui manipulent une liste) ou organisationnelle (p.e. réunir dans un fichier toutes les fonctions utilitaires liées à une application). Un module constitue ainsi une boîte à outil complète, assimilable à une classe java où le header correspond à l’interface publique de la classe.
Cette découpe en plusieurs fichiers offre la possibilité de réutiliser dans d'autres applications les fonctions définies pour une application, simplement en faisant référence à ce module.
En C, un module est constitué d'un fichier source (extension .c) et un fichier entête (extension .h). Un module reprend la définition de fonctions et/ou types et/ou variables et/ou constantes traitant une sous partie du problème global.
Les fichiers d'entête (header files)
Pour appeler une fonction à l'intérieur d'un module, il faut en connaître le nom et la manière de l'utiliser type de la fonction + liste de ses paramètres, il est donc nécessaire de rappeler le prototype des fonctions dans tous les fichiers où elles sont référencées.
Mais réécrire le prototype dans tous les fichiers où cela est nécessaire est un travail ardu qui peut facilement générer des erreurs. Il est préférable d'écrire ces prototypes une fois pour toutes dans un seul fichier par module, que l'on va inclure dans tous les fichiers où se trouvent des appels aux fonctions. Le langage C a prévu ce mécanisme d'inclusion de fichiers en introduisant la notion de fichier d'entête (header file en anglais, d'où vient l'extension .h). C'est le travail du préprocesseur étape précédant la compilation de veiller à inclure ces fichiers dans les sources C.
Il existe 2 syntaxes pour donner au préprocesseur le nom d'un fichier à inclure :
-
La première - que vous avez rencontrée depuis votre premier programme - permet d'inclure un fichier d'entête lié aux librairies standard; elle spécifie le nom du fichier entre les caractères < et >, comme dans l'instruction
#include <stdbool.h>
qui permet d'utiliser le type booléen bool.
-
La seconde utilise le caractère ". Elle indique au préprocesseur d'inclure le fichier d'entête se trouvant dans le répertoire courant. En voici un exemple:
#include "pile.h"
où le fichier pile.h reprend le prototype des fonctions de gestion d'une pile, dont le code se trouve dans un module pile.c.
Un header comprend toutes les informations publiques du module, càd. toutes les informations destinées à être partagées entre différentes applications. Outre la déclaration des prototypes des fonctions (accompagnés de leurs spécifications), un fichier d'entête peut contenir des définitions de constantes (sous forme de macros, comme par exemple: #define TAILLE 100), des définitions de types (introduites par typedef), la déclaration de variables externes, mais aucune ligne de code!
Voici un exemple du fichier d'entete pile.h.
/************************************************************************ pile.h* gestion d'une pile d'entiers par une table***********************************************************************/#include <stdbool.h>#ifndef _PILE_H_ // ou: #if ! defined _PILE_H_#define _PILE_H_/* definition de constantes */#define OK 0#define KO 1/* definition de types */typedef ...... Pile;/* déclaration de fonctions et spécifications */// initialise la pilePile init ();// empile un entier sur la pilevoid push (Pile*, int);// depile et retourne un entier de la pileint pop (Pile*);// vérifie si la pile est videbool isEmpty (Pile);// vérifie si la pile est pleinebool isFull (Pile);// affiche la pilevoid print (Pile);#endif // _PILE_H_
La directive conditionnelle du préprocesseur #ifndef (ligne 8) permet d'éviter la double inclusion. Elle assure que le contenu du fichier d'entête ne sera inclus qu’une seule et unique fois lors de la compilation. Si la macro _PILE_H_ est déjà définie, c'est que le fichier a déjà été inclus dans la source et il est donc inutile de réaliser le traitement qui suit, càd. ajouter les différentes déclarations.
Notez qu'un fichier d'entête inclut uniquement les librairies qui sont nécessaires pour que le compilateur puisse "comprendre" celui-ci. Dans le cas de pile.h, seule la librairie stdbool.h doit être incluse afin que le compilateur connaisse la signification de l'identificateur bool présent dans le prototype des fonctions isEmpty et isFull. Les librairies standard nécessaires à la compilation du code source seront quant à elles incluses dans le fichier pile.c.
Les fichiers "source" des fonctions
C'est dans ces fichiers que l'on retrouve le code des fonctions (extension .c) qui complètent l'application.
Le fichier source comprend toujours l'inclusion du fichier d'entête pour bénéficier des différentes définitions de macros, types et variables.
Voici une partie du fichier source pile.c correspondant au fichier d'entête pile.h présenté ci-dessus. L'implémentation du code est laissé à votre discrétion.
/************************************************************************ pile.c* gestion d'une pile d'entiers par une table***********************************************************************/#include <stdio.h>#include <stdlib.h>#include "pile.h"/* définitions de variables globales(reprenant par exemple les types d'erreurs) *//* implémentation des fonctions */Pile init () {......}void push (Pile *p, int val) {.....}int pop (Pile *p) {......}bool isEmpty (Pile p) {......}bool isFull (Pile p) {......}void print (Pile p) {.....}
Notez qu'un fichier source contenant le programme principal - i.e. la fonction main - n'a généralement pas de fichier d'entête. En effet, un header permet de partager un module entre différentes applications. Un exécutable ne constitue pas donc pas un module à proprement parler.
Le makefile
La génération d'une application passe par plusieurs étapes, notamment :
-
La compilation des différentes sources grâce au compilateur: par exemple pour compiler le fichier module.c, il faut appeler le compilateur au moyen de la commande cc -c module.c qui va fournir un module objet, ici le fichier module.o.
-
L'édition des liens réalisée par l'éditeur de lien (appelé par la commande cc): cc -o executable *.o va générer l'exécutable en assemblant les différents modules objets *.o et librairies standard en un fichier exécutable, ici executable.
L'utilitaire make peut automatiser cette séquence de traitements, en se limitant à l'exécution de ceux qui sont nécessaires pour mettre à jour l'application. Pour réaliser ce travail, la commande make utilise un fichier, appelé par défaut Makefile, qui comprend une série de règles, traitées récursivement, en commençant par la première.
Chaque règle comprend:
-
une condition de dépendance basée sur le mtime des fichiers. La règle suivante:
cible : dépendance1 dépendance2 dépendante3
implique que l'action correspondante ne sera réalisée que si la cible est plus ancienne qu'une des dépendances (càd. si le mtime de la cible est plus petit que le mtime d'une des dépendances) ;
-
et une action:
<\t> action
commande précédée d'un caractère de tabulation. make n'exécutera l'action que si la condition le réclame.
Pour évaluer le mtime des dépendances, make recherche plus loin dans la liste des règles celles qui vont mettre à jour ces dépendances, et ainsi de suite, de manière récursive.
Voici le fichier Makefile associé à une application qui utilise notre module pile :
################################################################## Makefile# calculatrice en notation polonaise inversée#################################################################CFLAGS = -std=c11 -pedantic -Wvla -Wall -Werror -gnpi : npi.o pile.occ $(CFLAGS) -o npi npi.o pile.o # édition de liensnpi.o : npi.c pile.hcc $(CFLAGS) -c npi.c # compilation du module npipile.o : pile.c pile.hcc $(CFLAGS) -c pile.c # compilation du module pileclean :rm *.orm npi
Dans ce fichier, la première règle (8-9) construit l'exécutable npi grâce à l'édition des liens (9) qui "fusionne" les différents modules de l'application ainsi que certaines librairies standard ; la règle (14-15) vérifie le module objet pile.o et éventuellement compile (15) la source pile.c pour le mettre à jour si ce module objet est plus ancien que les fichiers dont il dépend, à savoir la source pile.c et le fichier d'include pile.h, fichier inclus dans le fichier pile.c, etc.
En résumé : make exécute l'édition des liens si un des modules objets a été mis à jour, et recompile un module objet si un des fichiers dont il dépend a été modifié.
Notez par ailleurs que, si la commande make s'utilise la plupart du temps sans paramètre, il est possible de nommer une cible particulière. Si aucune cible n'est nommée, make traitera par défaut la première règle du Makefile. Mais si une cible est spécifiée, c'est l'action correspondant à cette cible qui sera exécutée (si la condition l'exige). Par exemple:
-
make provoquera l'exécution des commandes nécessaires pour mettre à jour l'exécutable npi ;
-
make pile.o provoquera uniquement la compilation du module pile.o (si pile.h ou pile.c ont été modifiés) et
-
make clean provoquera l'effacement des modules objets et de l'exécutable.
Remarquez pour finir qu'un makefile peut générer plusieurs exécutables (il comprend dès lors plusieurs commandes cc -o). Dans ce cas, une nouvelle règle doit être ajoutée au début du makefile:
-
all : executable1 executable2 .....
Cette première règle liste l'ensemble des exécutables qui seront générés par la commande make all ou simplement make (puisque cette commande ne traite par défaut que la première cible du makefile).