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 :

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 pile
				Pile init ();
				// empile un entier sur la pile
				void push (Pile*, int);
				// depile  et retourne un entier de la pile
				int pop (Pile*);
				// vérifie si la pile est vide
				bool isEmpty (Pile);
				// vérifie si la pile est pleine
				bool isFull (Pile);
				// affiche la pile
				void 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 :

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:

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 -g
			  	
			  	npi : npi.o pile.o           
			  		cc $(CFLAGS) -o npi npi.o pile.o    # édition de liens
			 	
			 	npi.o : npi.c pile.h
			 		cc $(CFLAGS) -c npi.c    # compilation du module npi
			 	
			 	pile.o : pile.c pile.h
			 		cc $(CFLAGS) -c pile.c    # compilation du module pile
			 	
			 	clean :
			 		rm *.o
			 		rm 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:

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:

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).