Le langage C

Sixième partie

par Guilhem de Wailly

Résumé

Dans cette série d'articles, nous présentons le langage C. Il ne s'agit pas de réécrire les ouvrages de référence donnés en annexe, mais de donner, au travers du C, une méthode de programmation basée sur la notion d'interface.

Au fur et à mesure de notre progression, nous décrirons les éléments indispensables du langage et conduirons le lecteur à consulter les ouvrages de références. Le but poursuivi est clairement de parvenir à construire des programmes de manière segmentée.

Dans cet article, nous précisons nos connaissances sur la chaîne de compilation et abordant la programmation avec plusieurs fichiers sources, et nous reprenons la bibliothèque des nombres rationnels en faisant clairement apparaître le paquetage.

Introduction

Le mois dernier, nous avons utilisé nos connaissances du langage C pour programmer dans un seul fichier une bibliothèque pour les nombres rationnels.

Pour cela, nous avons vu comment étendre le nombre des types du langage en définissant de nouvelles structures de données.

Ce mois-ci, nous allons examiner comment construire un programme sur plusieurs fichiers sources. Cette technique fera apparaître la nécessité d'interfaces entre les modules, ce qui nous amènera à construire un fichier d'entête. Nous verrons alors comment utiliser les fichiers d'entêtes dans les programmes.

Puis, reprenant l'exemple des nombres rationnels, nous ferons clairement apparaître les différents constituants du programme en les séparant dans plusieurs fichiers sources, reliés par un fichier d'entête.

Enfin, nous critiquerons la bibliothèque obtenue, ce qui préparera les articles suivants.

Paquetage

Dans l'article précédent, nous avons programmé notre premier paquetage. Il réalisait une bibliothèque de calcul pour les nombres rationnels.

La bibliothèque était composée d'un seul gros fichier comprenant trois parties :

L'implémentation

Les utilitaires

Cette partie rassemblent un certain nombre de fonctions de facilité utilisant directement les fonctionnalités de la partie Implémentation.

Nous avons définit la fonction d'affichage et la fonction d'addition de deux nombres rationnels. Il est possible de définir ici d'autres fonctions de l'arithmétique des nombres rationnels, comme la soustraction, la multiplication, la division, etc.

L'utilisateur

Cette partie correspond aux fonctions qui utilisent les deux parties précédentes.

La bibliothèque est constituée des deux premières parties : une couche de bas niveau et une couche de haut niveau. La couche utilisateur utilise l'ensemble des fonctionnalités des deux premières parties.

Pour l'instant, nous ne voyons pas bien apparaître la notion de paquetage car nous ne savons pas encore concevoir un programme multi-fichiers. Dès que nous saurons le faire, nous aurons bien plusieurs parties distinctes dont certaines formeront un paquetage.

Programme C multi-fichiers

Jusqu'à maintenant, pour fabriquer un programme exécutable à partir d'une source en C, nous tapons la commande :

        $ gcc o nom-exe nom.c
      

Cette commande compile par gcc le programme en C contenu dans le fichier nom.c et fabrique un binaire exécutable nom-exe.

Si nous avons plusieurs fichiers contenant des sources C, il suffit simplement de taper :

        $ gcc -o nom-exe nom-1.c nom-2.c nom-3.c
      

Ici, nom-1.c, nom-2.c et nom-3.c sont les sources C de notre programme.

Nous avons vu dans un article précédant qu'il ne pouvait exister qu'une seule fonction main dans tout le programme. Cela reste vrai dans le cas ou le programme tient sur plusieurs fichiers.

Parmi les trois fichiers, seul un seul contient la fonction main. Les autres contiennent des fonctions utilisées par la fonction main, directement ou indirectement.

Nous allons faire le premier programme C multi-fichiers. Dans le premier fichier, on définit une fonction simple de calcul. Dans le second fichier, on déclare la fonction main qui va utiliser la fonction de calcul :

Le premier fichier fct.c contient :

        /* fct.c */
        int ma_fonction (int a, int
                          b, int c) {
          return (a * b + a * c);
        }
      

Dans le second fichier main.c on écrit :

        /* main.c */
        #include <stdio.h>

        void main (void) {
          int resultat;

          resultat = ma_fonction (1, 2, 3);
          printf ("le résultat est %d\n",
                 resultat);
        }
      

Par convention, le fichier qui contient la fonction main est appelé main.c. Cela permet de le reconnaître rapidement.

Nous rappelons que la ligne include nous permet d'utiliser la bibliothèque contenant la fonction printf.

On compile le tout avec :

        $ gcc -o test fct.c main.c
      

Cette commande fabrique un binaire exécutable test. Le programme fonctionne bien et affiche la bonne valeur.

Maintenant, essayons de remplacer dans main.c :

        resultat = ma_fonction (1, 2, 3);
      

par :

        resultat = ma_fonction ();
      

On recompile le tout, et on remarque qu'aucune erreur n'est signalée par le fait que ma_fonction est appelée sans argument. On peut même exécuter le programme test et on remarque que le résultat affiché est aléatoire.

Cela provient du fait que le compilateur C compile séparément chacun des fichiers sources, puis rassemble le tout dans le binaire. Au moment ou il compile le fichier main.c, le compilateur ne connaît rien de la fonction ma_fonction. Il ne connaît rien car nous ne lui avons rien dit !

Nous avons vu dans le numéro 8 les prototypes de fonctions. Un prototype de fonction se déclare comme une fonction, mis à part que l'on remplace le bloc entre parenthèses par un point-virgule.

Ainsi, le prototype de la fonction ma_fonction est :

        int ma_fonction (int a, int b, int c);
      

Le prototype de fonction se place n'importe ou dans le fichier C et n'importe ou parmi les déclarations de variables d'un bloc en accolades. Le prototype déclaré est "visible" partout en dessous de sa déclaration. Un prototype de fonction ne "génère" pas de code : il aide simplement le compilateur à vérifier que les appels à la fonction ainsi prototypée sont corrects.

Le prototype semble être l'outil qu'il nous faut !

Aussi, nous nous empressons de taper le prototype de ma_fonction au sommet du fichier main.c contenant le mauvais appel à la fonction, sous le include, et nous recompilons le programme.

Le compilateur émet alors un message d'erreur du type :

        $ gcc -o test fct.c main.c
        main.c: in function `main':
        main.c:8: too few arguments to function `f'
      

La déclaration du prototype permet au compilateur de vérifier que l'appel à ma_fonction a doit avoir trois arguments entiers et que le résultat doit être utilisé comme un entier. Ici, ce n'est pas le cas car l'appel n'a aucun argument, et le compilateur émet une erreur.

Mais cela signifie-t-il qu'il faille placer le prototype de toutes les fonctions utilisées dans un fichier et non déclarées dans ce fichier ? La réponse est oui et la bonne nouvelle est qu'il existe un outil pour ça !

Fichiers d'entête

Un fichier d'entête est un fichier de source C contenant principalement des déclarations de types de données et des prototypes de fonctions. Entête se dit header en Anglais, aussi l'extension des fichiers d'entête est .h. Ceci est une convention et il est possible de donner n'importe quelle extension au nom de fichier des entêtes.

Dans votre système Linux, vous avez en général beaucoup de fichiers d'entêtes. Ils sont situés principalement dans le répertoire /usr/include. On y trouve un tas de fichier dont le fameux stdio.h  ! Les fichiers d'entêtes sont associés aux bibliothèques du système qui se trouvent généralement dans le répertoire /lib ou /usr/lib.

Le compilateur reconnaît l'utilisation d'un fichier d'entête avec la directive #include (nous verrons plus tard que le compilateur est intrinsèquement constitué de plusieurs outils, dont l'un traite ces directives).

La directive #include a comme argument un nom de fichier qui peut être spécifié de deux manières :

Il est possible de faire précéder le nom du fichier par un chemins, comme #include "/usr/include/stdio.h". Ceux qui possède (encore) un compilateur C sous Windows s'apercevront que les chemins séparés par / sont reconnus : c'est peut être l'origine et le début de l'unixification de Windows !

Pour ma_fonction, le fichier d'entête pourrait être :

        /* fct.h */
        int ma_fonction (int, int, int);
      

Nous modifions le fichier main.c en :

        /* main.c */
        #include <stdio.h>
        #include "fct.h"

        void main (void) {
          int resultat ;

          resultat = ma_fonction (1, 2, 3);
          printf ("le résultat est %d\n",
                   resultat);
        }
      

Nous compilons l'ensemble :

        $ gcc -o test fct.c main.c
      

Cette fois-ci, nous avons bien un paquetage constitué de fct.c et fct.h. Nous verrons plus tard comme fabriquer de vraies bibliothèques binaires.

Si on remplace la directive #include "fct.h" par #include <fct.h>, il faut alors taper :

        $ gcc -I. -o test fct.c main.c
      

L'option -I. indique au compilateur d'ajouter le répertoire point (répertoire courant) parmi les répertoires standard.

Les nombres rationnels

Reprenons l'exemple des nombres rationnels en préparant un fichier d'entête rationnel.h :

        /* rationnel.h */

        /* type de donnée */

        typedef struct rationnel {
          int num;
          int den;
        } rationnel;

        /* constructeur/destructeur */
        rationnel r_construit (int num,
                              int den);
        voidr_detruit   (rationnel x);

        * sélecteurs */
        int      r_den       (rationnel x);
        int      r_num       (rationnel x);
        
        /* utilitaires */
        rationnel r_plus      (rationnel x,
                               rationnel y);
        void      r_affiche  (rationnel x);
      

Nous créons ensuite le fichier des primitives _rationnel.c contenant le constructeur et le destructeur :

        /* _rationnel.c */
        #include <rationnel.h>

        rationnel r_construit (int num, int den) {
          rationnel r;

          r.den = den;
          r.num = num;
        
          return (r);
        }

        void r_detruit (rationnel x) {
          return;
        }
        
        int r_den (rationnel x) {
          return (x.den);
        }

        int r_num (rationnel x) {
          return (x.num);
        }
      

Par convention en C, ce qui est primitif est préfixé par le caractère de soulignement. Ainsi, la fonction f() est ""moins"" primitive que la fonction _f(). Il en va de même pour les noms de fichiers.

Nous écrivons le fichier des fonctions de haut niveau rationnel.c de la même manière, en plaçant la directive d'inclusion, contenant les fonctions r_plus et r_affiche vues le mois précédent.

Enfin, le programme principal est identique à celui du mois dernier, à la différence qu'il contient la directive #include <rationnel.h>. Le nom du fichier est main.c.

Nous nous trouvons finalement en présence de trois fichiers ;

Pour compiler l'ensemble, on tape sur la même ligne :

        $ gcc -I. -o rationnel _rationnel.c
          rationnel.cmain.c
      

Nous sommes donc parvenus à faire apparaître le paquetage constitué des fichiers _rationnels.c, rationnel.c et rationnel.h. Le fichier utilisateur est main.c.

Paquetage et interface

Lorsque nous saurons le faire, il sera possible de compiler _rationnel.c et rationnel.c et de les rassembler dans une bibliothèque binaire que l'on pourra appeler rationnel.a. Ainsi, au lieu de lier le fichier main.c aux sources de la bibliothèque, il sera possible de le lier à la bibliothèque binaire.

Cela offre plusieurs avantages. Le premier est d'accélérer le temps de compilation, par l'utilisation de fichiers déjà compilés.

Le second avantage est de cacher la manière avec laquelle la bibliothèque est réalisée. D'une part cela nous laisse tout loisir de modifier de manière interne la façon de réaliser la bibliothèque, mais aussi, cela peut aussi protéger un algorithme dans un environnement commercial.

Dans ce contexte, le fichier rationnel.h devient une sorte de contrat entre l'utilisateur et le réalisateur de la bibliothèque. Les fonctionnalités contenues dans ce fichier, données, types de données et fonctions, sont l'interface de la bibliothèque. Tans que cette interface ne change pas, les versions successives de la bibliothèque restent compatibles entre elles.

L'interface est la partie publique de la bibliothèque : tout le monde peut lire l'interface et tout ce quelle contient est lisible. Par contre le binaire la bibliothèque est privé : seul le réalisateur de la bibliothèque y a accès. La manière de réaliser les fonctionnalités de l'interface n'est pas publique.

Ces remarques concernant le publique et le privé ne doit pas faire bondir le lecteur, qu'il soit enseignant ou partisan du mouvement OpenSource : quel que soit le contexte, c'est une question d'attitude ! L'utilisateur d'une bibliothèque, OpenSource ou non, ne devrait pas vouloir savoir comment les fonctionnalités sont réalisées afin de modifier son programme en conséquence. Il ne peut utiliser que les fonctionnalités des fichiers d'entête, car les fonctionnalités "privées" de la bibliothèque sont susceptibles d'être modifiées sans préavis !

Critique du paquetage

Le résultat obtenu avec les rationnels est satisfaisant, mais un certain nombre de remarques peuvent être émises.

La structure interne des données rationnelles est publique : ainsi, l'utilisateur peut utiliser le fait qu'un rationnel est une structure de donnée contenant deux entiers, dont le premier est le dénominateur et le second le numérateur.

De ce fait, il n'a aucune raison d'utiliser les sélecteurs fournis dans l'interface. De plus, il s'aperçoit vite que le destructeur "ne fait rien", donc pourquoi l'utiliser ?

Si, dans une version ultérieure de la bibliothèque, nous souhaitons changer radicalement la manière de représenter les données (c'est bien ce qui va se passer dans les articles suivants), l'interface actuelle nous enlève toute possibilité de modification car elle nous lie trop. Mais dans l'état actuel de nos connaissances, nous ne pouvons pas faire autrement que d'utiliser la structure de donnée.

Les fonctions utilisent le passage des arguments "par valeur" au lieu d'utiliser le "passage par référence". Nous verrons plus tard ce que cela signifie mais nous pouvons d'hors et déjà dire que cela n'est pas très performant. Il nous faudra optimiser cette manière de faire afin d'obtenir de bonnes performances.

Nous sommes obligés de donner les sources de la bibliothèque au lieu de donner un fichier de bibliothèque pré-compilé. Là encore, nous ne savons pas encore faire autrement.

L'objectif des articles suivants est donc :

L'auteur

Guilhem de Wailly (gdw at free dot fr)

Références