Programmation modulaire

jeu. 22 octobre 2009

Cette page est destinée à un public tel que les étudiants du premier cycle universitaire, elle est donc volontairement concise. Son but est de détailler les méthodes et les outils qui permettent d'obtenir un programme en C réutilisable et d'une maintenance facilité. Par un effet de bord désiré, cet objectif est atteint en respectant les principes de base de visibilité et d'encapsulation des objets. L'ensemble du propos est articulé autour du système d'exploitation Linux et de l'environnement de programmation GNU.


Les outils

  • un compilateur C : gcc
  • un éditeur de liens : gcc (ld)
  • un gestionnaire de projets simple : make
  • un éditeur de texte : emacs par exemple
  • un projet
  • un papier et un crayon
  • un cerveau et un périphérique d'entrée type clavier

c'est tout.


Ce qu'il ne faut pas faire

S'installer sans plus attendre devant son clavier et écrire l'ensemble du code d'un programme dans un grosse bouse unique, en déclarant tout les objets en global, ayant une durée de vie égale à celle du programme et le tout sans commentaire.

Ce qu'il faut faire

Tout d'abord prendre le temps de la réflexion, et durant celle-ci décomposer le problème initial en sous-problèmes plus simples. Une fois cette étape, indispensable, effectuée se renseigner sur l'existant (bibliothèques). Concevoir chaque module séparément afin de pouvoir systématiquement les réutiliser dans le futur. Pour cela, commencer par écrire le fichier des en-têtes (.h) en commentant chaque fonction et chaque structure, puis le programme de test du module (comportant une fonction principale main()) et enfin le fichier source (.c) dont chaque point délicat se doit d'être commenté. Pour un projet comportant au moins deux modules, concevoir le fichier de définition du projet (makefile). Lancer le gestionnaire de projet, patientez 5 mn, lancer le programme, c'est prêt.


Précompilateur, compilateur et éditeur de liens

Késako ? Kiceki ? Le passage du code source au programme exécutable ne se fait pas en une seule passe. C'est un travail parfois trivial souvent délicat.

Le premier outil utilisé est le précompilateur. Comme son nom l'indique, il traite chaque fichier source avant le compilateur. Quel est son rôle ? Simplement et uniquement d'effectuer des opérations du type "copier-coller" dans votre fichier source. Ses instructions sont aisément identifiables au fait qu'elles commencent systématiquement par le symbole #. Listons quelques unes des instructions les plus utilisées dans ce contexte :

#include : c'est l'instruction la plus grossière, elle se contente d'insérer le contenu du fichier dont le nom lui est passé en argument. Une petite subtilité : selon que le nom est encadré entre des guillemets ou des chevrons, le fichier sera local ou non local (voir l'option -I du compilateur)

#define : cette instruction permet d'effectuer une substitution lexicale, en d'autres termes elle remplace un identificateur par l'expression qui lui est associée et ce sans contrôle syntaxique. Exemple académique : #define CARRE(X) X*X permet d'apréhender brutalement les limites d'une simple substitution de texte, essayez en l'appliquant à l'expression CARRE(3-1).

#ifdef, #ifndef, #else, #endif : ces instructions sont indispensables car elles ont plusieurs utilités. Elles permettent d'éviter les inclusions mutuelles des fichiers d'en-têtes (voir le paragraphe "Le fichier d'en-têtes" sur ce sujet), d'effectuer du débogage hiérarchique ou d'adapter son programme à plusieurs environnements. Qu'entend-on par "débogage hiérarchique" ? C'est la capacité à afficher, ou non, des messages pertinents sur l'évolution des données durant le cyle de programmation / correction. La plupart du temps, on affiche une trace (par exemple fprintf(stderr, "x = %f", x);) que l'on s'empresse d'effacer lorsque le bloc d'instructions est supposé fiable. C'est une erreur. Il est préférable d'encadrer systématiquement les traces par les macro-instructions #ifdef et #endif ce qui permet d'offrir un exécutable avec ou sans trace à moindre coût. Exemple :

#ifdef _DEBUG_LVL_1
fprintf(stderr, "x = %f\n", x);
#endif

Il suffit ensuite d'utiliser l'option de compilation -D (voir ci-dessous) pour "activer" ou non (en fait, inclure) le message d'erreur (éventuelle).

Le compilateur effectue une analyse de vos sources en tentant de détecter les erreurs d'inattention ("sxitch" au lieu de "switch" par exemple), les failles dans la logique (variables déclarées mais jamais utilisées, portion de code inaccessible, etc.) mais dans l'ensemble son traitement est assez rudimentaire du point de vue du programmeur ; n'oubliez pas que le C est un langage qui fait confiance au programmeur. En parlant de confiance, de nombreux objets (fonctions, nouveaux types) peuvent être déclarés sans être définis, le compilateur vérifiant alors simplement que leur usage correspond à leur déclaration, qui se fait dans les fichiers d'en-têtes. Charge à l'éditeur de liens de dénoncer éventuellement le "contrat de confiance" que vous venez d'établir avec le compilateur. Une fois son travail terminé, le compilateur produit un fichier en code intermédiaire (.o) pour chaque fichier source. Les options les plus utiles sont :

-c : pour produire un fichier en code intermédiaire à partir d'un fichier source

-I : pour spécifier un chemin d'accès à un ou plusieurs fichiers d'en-têtes

-D : pour définir une variable d'environnement (par exemple : -D_DEBUG_LVL_1)

Exemple : gcc -c toto.c -I/home/dupont/include

L'éditeur de liens se charge de "lier" le tout afin de produire l'exécutable. Pour cela, il lui est indispensable d'avoir connaissance non seulement du contenu des fonctions que vous avez créées mais aussi de celles que vous utilisez sans en être l'auteur (a priori grâce à des bibliothèques). Ses options les plus usitées sont :

-o : pour donner un nom à l'exécutable final

-l : pour spécifier le nom de la bibliothèque contenant des fonctions que vous utilisez

-L : pour spécifier un chemin d'accès à une ou plusieurs bibliothèques

-g : pour produire un exécutable comportant des fonctions propres au débogueur (gdb)

Exemple : gcc toto.o titi.o -o mon_projet -L/home/dupont/lib -lX11 -lm

Attention : les bibliothèques utilisées doivent être précisées dans un ordre déterminé par leur degré de dépendance. Ainsi, si la bibliothèque SDL dépend pour fonctionner de la bibliothèque X11, on écrira : -lSDL -lX11 (et surtout pas -lX11 -lSDL).


Modularité, visibilité

Un module doit respecter les grands principes d'encapsulation et de protection. Ainsi, certains objets sont propres au module, et d'autres clairement destinés à être utilisé par des tierces parties. Quels qu'ils soient, ces objets seront déclarés "static" dans le fichier source (.c) lorsqu'ils doivent rester d'un usage confidentiel, et "extern" dans le fichier des en-têtes (.h) lorsqu'ils font partie de ce qu'il est convenu d'appeler l'interface du module. Ceci autorise pratiquement toutes les "fantaisies" : déclaration d'un type connu uniquement du module, déclaration (et définition) d'une sous-fonction appelée par une fonction exportée, déclaration d'une variable globale au module mais inconnue en dehors de celui-ci, etc.

Le fichier d'en-têtes (.h)

Ce fichier comporte les déclarations de variables (pas glop !), de types (conçus par vos soins) et de fonctions de votre module.

Afin d'éviter des insertions multiples de ce fichier lors des inclusions croisées (par exemple lorsque le fichier source A inclue le fichier d'en-têtes B et que le fichier source B inclue le fichier d'en-têtes A) ce qui conduit inévitablement le compilateur à croire qu'il existe une redéfinition d'objets, il convient d'encadrer systématiquement ce fichier des macro-instructions #ifndef, #define, #endif. Exemple :

#ifndef _MON_FICHIER_H
#define _MON_FICHIER_H
... // ici déclaration de tous mes objets
#endif

Voici comment se présente la déclaration d'un nouveau type :

typedef struct _mon_type {
  ...
} mon_type;

Et la déclaration d'une fonction proposée par le module :

extern int max(int, int);

Le fichier source (.c)

Il doit inclure (#include) systématiquement le fichier d'en-têtes correspondant, ceci afin d'avoir connaissance des types qui lui sont propres et d'établir une mise en correspondance entre la déclaration d'une fonction et son implantation. On est invité à déclarer des fonctions qui ne seront connues que du module par soucis de lisibilité. Par exemple, si une fonction "complexe()" utilise les deux fonctions "etape1()" et "etape2()" pour traiter le problème qui lui est soumis, mais que son auteur ne veut proposer en interface que cette fonction "complexe()", il déclarera les trois fonctions ainsi :

static float etape1(float x) { ... }

static float etape2(float y) { ... }

float complexe(float p1, float p2)
{
  ...
  etape1();
  etape2();
  ...
}

et il aura bien entendu déclaré "extern" la fonction "complexe()" dans le fichier d'en-têtes correspondant par :

extern float complexe(float, float);

Si vous souhaitez déclarer une variable ayant une portée globale uniquement au sein d'un module et inconnue en dehors (ce qu'il faut éviter au maximum), vous procéderez de la façon suivante :

static int necessaire = 0;

Notez l'initialisation (obligatoire).


Le gestionnaire de projets

L'outil le plus utilisé pour cela se nomme "make". Ce dernier utilise un fichier d'instructions nommé par défaut "makefile" ou "Makefile" dont nous allons détailler les principes ainsi que la structure.

Un makefile comporte grossièrement des identificateurs locaux et des blocs de dépendance. La déclaration des identificateurs prend la forme classique ID=valeur, par exemple CC=gcc et la substitution syntaxique se fait par la syntaxe $(ID) par exemple $(CC). Les blocs de dépendance comporte toujours une première ligne dite règle de dépendance puis une suite de ligne décrivant les actions à effectuer pour résoudre la dépendance. Par exemple, vous souhaitez produire un exécutable "mon_projet" qui dépend de deux modules "toto" et "titi", vous écrirez :

mon_projet: toto.o titi.o
  $(CC) -o mon_projet mon_projet.c toto.o titi.o -L/home/dupont/lib -lX11 -lm

Ce qui peut se lire ainsi : pour produire mon exécutable "mon_projet"on a besoin d'avoir produit auparavant "toto.o" et "titi.o" ; si ces derniers existent, on effectue alors l'édition de liens décrite en dessous de la ligne de dépendance (et qui commence obligatoirement par une tabulation).

Attention : make est un outil particulièrement sensible à la syntaxe et ne vous sera d'aucun secours en cas de non-respect.

Make est capable de résoudre les dépendances en casquade, en s'appuyant systématiquement sur la première règle de dépendance. Ainsi, pour reprendre l'exemple précédent, si l'on a dans un makefile les règles suivantes :

CC=gcc
OBJ=mon_projet.o toto.o titi.o
mon_projet: $(OBJ)
  $(CC) -o mon_projet $(OBJ) -L/home/dupont/lib -lX11 -lm

mon_projet.o: mon_projet.c
  $(CC) -c mon_projet.c -I/home/dupont/include

toto.o: toto.c toto.h
  $(CC) -c toto.c -I/home/dupont/include

titi.o: titi.c titi.h
  $(CC) -c titi.c -I/home/dupont/include``

Celles-ci peuvent se traduire de la façon suivante : pour créer le programme "mon_projet", il est nécessaire que les modules pré-compilés "mon_projet" (qui contient la fonction principale "main()"), "toto" et "titi" existent ou soient plus anciens que le programme lui-même. Si ce n'est pas le cas, on cherche dans la suite des règles la règle de dépendance qui permet sa création ou sa mise à jour. La datation des fichiers est donc indispensable au bon fonctionnement de make.