Le langage C

Base de donnée: (suite)



Par :

Guilhem de Wailly (gdw@erian-concept.com)



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.

Ce mois-ci, nous allons nous continuer à nous intéresser aux tables de hachage réalisées en C. Nous avons déjà pu aborder ce sujet avec le langage Scheme dans le numéro 6 de Linux Magazine. Cette réalisation permettra de réaliser de petites bases de données.

Nous allons plus particulièrement nous pencher sur la sauvegarde et la restauration d'une table sur disque. Nous examinerons aussi sommairement les modes de stockage des valeurs de plus de huit bits dans la mémoire et son influence sur la compatibilité binaire des fichiers de données lorsqu'ils sont partagés sur un réseau hétérogène.

Description

Nous étions en train de construire une bibliothèque de manipulation de tables de hachages et nous étions arrivés au point où nous allions les sauvegarger sur disque, lorsque nous nous sommes attachés à décrire les fonctions d'éntrées / sortie de la bibliothèque standard du langage C.

Il est maintenant temps de revenir aux tables de hachages en programmant les dernières fonctions.

Les sources de la bibliothèque sont disponibles à l'adresse:

http://www.erian-concept.com/pub/hash.tgz

L'interface de la bibliothèque est :

#ifndef ___HASH_H
#define ___HASH_H

/* TYPES DE DONNEES */
/* Table de hachage */
typedef struct Hash * Hash;

/* codes de retour */
typedef enum {
  HASH_OK,         /* pas d'erreur */
  HASH_ERREUR,     /* erreur indéfinie */
  HASH_DOUBLON,    /* existe déjà */
  HASH_INTROUVABLE /* n'existe pas */
} HashStatus;

/* PROTOTYPES DES FONCTIONS */
Hash       hash_cree    (int    taille_table,
                         int    taille_ident);
HashStatus hash_detruit (Hash   hash_table);
HashStatus hash_ajoute  (Hash   hash_table,
                         char * identificateur,
                         void * donnee,
                         int    taille_donnee);
HashStatus hash_enleve  (Hash   hash_table,
                         char * identificateur);
int        hash_taille  (Hash   hash_table,
                         char * identificateur);
HashStatus hash_donne   (Hash   hash_table,
                         char * identificateur,
                         void * donnee,
                         int  * taille_donnee);
HashStatus hash_tous    (Hash   hash_table,
                         void * paramètre,
                         int (* proc) (void * parametre,
                                       char * identificateur,
                                       void * donnee,
                                       int    taille_donnee));
HashStatus hash_sauve   (Hash   hash_table,
                         char * fichier);
Hash       hash_charge  (char * fichier);

#endif /* ___HASH_H */

Nous allons nous occuper des fonctions hash_sauve et hash_charge qui prendront place dans le fichier hash.c. Nous rappellons que les structures de données manipulées sont :

/* cellule */
typedef struct Cellule * Cellule;

typedef struct Cellule {
Cellule suivante;
char * identificateur;
int taille_donnee;
void * donnee;
} _Cellule;

et :

/* table de hashage (hash.h) */
typedef struct Hash * Hash;

/* table de hashage (hash.c) */
typedef struct Hash {
int taille_table;
int taille_identificateur;
Cellule * table;
} _Hash;

Sauvegarde

Pour la sauvegarde, nous allons utiliser la fonction de parcourt des cellules hash_tous qui prend en argument une table de hachage, un paramètre libre et une fonction de rappel ; pour toutes les entrées de la table de hachage, la fonction de rappel est appelée avec comme arguments le paramètre libre, l'identificateur, la donnée stockée dans la table et la taille de cette donnée.

La fonction de sauvegarde est donc :

/* Mot magique pour information et contrôle */
static char _mot_magic[] = "HashTable 1.0 pour LinuxMag\n";

/* Sauve une table de hachage dans un fichier */
HashStatus hash_sauve (Hash hash_table,
                 char * nom_fichier) {
FILE     * fichier;
  HashStatus status = HASH_ERREUR;

  /* vérification des arguments */
  assert (hash_table);
  assert (nom_fichier && * nom_fichier);

  /* Ouverture du fichier en écriture. Le contenu
   * précédent, s'il existait, est effacé */

fichier = fopen (nom_fichier, "w");
if (! fichier) {
goto Erreur;
}
 /* écriture du mot magique */
if (fwrite (_mot_magique,
         strlen (_mot_magic) + 1,
        1,
         fichier) != 1) {
goto Erreur;
}
 /* écriture de la taille de la table */
if (fwrite (& hash_table->taille_table,
         sizeof (int),
        1,
         fichier) != 1) {
goto Erreur;
}
/* écriture de la taille des identificateurs */
if (fwrite (& hash_table->taille_identificateur,
         sizeof (int),
         1,
         fichier) != 1) {
goto Erreur;
}
  /* la fonction de rappel _sauve_cellules
   * est appelée pour toutes les entrées
   * de la table */

status = hash_tous (hash_table,
                      (void *) fichier,
                      _sauve_cellules);

Erreur:
if (fichier) fclose (fichier);
  if (status != HASH_OK) {
    /* en cas d'erreur, effacer le fichier */
    unlink (nom_fichier);
  }
  return (status);
}

Le fichier est ouvert et on commence par écrire un mot magique qui servira pour informer de la nature du fichier une fois qu'il sera écrit et pour contrôler la nature du fichier, à l'ouverture.

Puis on sauvegarde la taille de la table et la taille des identificateurs. La fonction _sauve_cellules définie ci-dessous est ensuite invoquée pour toutes les entrées de la table de hachage. Si cette fonction retourne le code d'erreur pour l'une des cellule, l'itération est interrompue et la fonction hash_tous retourne le code d'erreur, qui devient le résultat de la fonction hash_sauve. Si l'itération se poursuit jusqu'à la fin, hash_tous retourne HASH_OK, qui devient le résultat de l'appel à hash_sauve.

Le traitement des erreurs est grandement simplifié par l'utilisation de l'instruction goto ; il faut cependant faire très attention à ce que les variables soient correctement initialisées.

La fonction _sauve_cellules est invoquée pour toutes les cellules de la table ; elle est responsable de la sauvegarde de l'entrée dans le fichier. Le fichier est le paramètre libre passé à la fonction hash_tous dans hash_sauve.

On a :

/* Fonction de rappel de la fonction hash_tous.
* Sauve l'entrée de la table à la fin du fichier
* passé en paramètre libre.
*/

static int _sauve_cellules (void * parametre,
                         char * identificateur,
                         void * donnee,
                         int taille_donnee) {
int status;
/* récupération du fichier */
FILE * fichier = (FILE *) parametre;
int longeur = strlen (identificateur) + 1;

/* écriture de la longueur de l'identificateur */
status = fwrite (& longeur,
                 sizeof (int),
                 1,
                 fichier);
if (status != 1) return (0);

/* écriture de l'identificateur */
status = fwrite (identificateur,
                 longeur,
                 1,
                 fichier);
if (status != 1) return (0);

/* écriture de la longueur de la donnée */
status = fwrite (& taille_donnee,
                 sizeof (int),
                 1,
                 fichier);
if (status != 1) return (0);

/* écriture de la donnée */
status = fwrite (donnee,
                 taille_donnee,
                 1,
                 fichier);
if (status != 1) return (0);

return (1);
}

La fonction de sauvegarde des cellules se contente d'écrire à la fin du fichier les informations concernant la cellule en cours. Le pointeur de fichier est toujours position à la fin des données écrites ou lues. Elle reçoit en argument un argument libre sous la forme d'un pointeur anonyme, un identificateur sous la forme d'une chaîne de caractères en ASCIIZ, une données sous la formes d'un tableau d'octet, et la longueur de la donnée. L'argument libre est, dans notre cas, un pointeur vers la structure du fichier où écrire les données.

Nous écrivons la longueur de l'identificateur, puis l'identificateur, suivit de la longueur de la donnée pour terminer par la donnée elle-même. Les écritures sont effectuées avec la fonction fwrite vue dans l'article précédent ; elle retourne le nombre d'items écrits. Ici, nous testons que la valeur de retour de chaque fwrite est 1, pour un item. Dans le cas où cette valeur est différente, nous interrompons l'itération en retournant zéro.

Il ne nous reste maintenant qu'à décrire la fonction de chargement.

Chargement

La fonction de chargement procède de manière inverse à la fonction de sauvegarde. Elle est aussi bâtie en deux fonction, une fonction principale qui ouvre le fichier et initialise une table et une fonction qui lit chaque entrée de la table à partir du fichier :

/* Lecture d'une table à partir d'un fichier */
Hash hash_charge (char * nom_fichier) {
int taille_table;
int taille_identificateur;
  char       tampon[100];
Hash hash_table = 0;
FILE * fichier;

  /* vérification des arguments */
  assert (nom_fichier && * nom_fichier);

/* Ouverture du fichier en lecture */
fichier = fopen (nom_fichier, "r");
if (! fichier) {
goto Erreur;
}
  /* lecture du mot magique */
if (fread (tampon,
         strlen (_mot_magic) + 1,
         1,
         fichier) != 1) {
goto Erreur;
}
/* vérification du mot magique */
if (strcmp (tampon, _mot_magique)) {
    goto Erreur;
  }
/* lecture de la taille de la table */
if (fread (& taille_table,
         sizeof (int),
         1,
         fichier) != 1) {
goto Erreur;
}
/* lecture de la taille des identificateurs */
if (fread (& taille_identificateur,
         sizeof (int),
         1,
        fichier) != 1) {
goto Erreur;
}
/* création de la table */
hash_table = hash_cree (taille_table,
                          taille_identificateur);
if (! hash_table) {
goto Erreur;
}
/* lecture des enregistrements jusqu'à
   * la fin de fichier */
while (! feof (fichier)) {
if (! _charge_cellule (fichier, hash_table)) {
goto Erreur;
}
}
fclose (fichier);
return (hash_table);

Erreur:
/* en cas d'erreur, fermer le fichier
   * et détuire la table */
if (fichier) fclose (fichier);
if (hash_table) hash_detruit (hash_table);
return (0);
}

La fonction commence par ouvrir le fichier dont le nom est donnée en argument ; en cas d'erreur, on utilise un branchement goto pour simplifier le traitement. En général, le traitement des erreur doit défaire tout ce qui a été fait, c'est à dire, généralement, fermer les fichiers ouverts, déallouer des blocs de mémoires et détruire des structures de données. Dans notre cas, nous fermons le fichier s'il est ouvert et nous détruisons la structure de la table de hachage s'il elle est créée. Ce mode de fonctionnement impose que les variables testées soient initialisées, ce qui est le cas pour la variable fichier et la variable hash_table.

Nous lisons ensuite le mot magique. Le tampon doit être déclaré suffisamment grand pour contenir le mot qui est lu. On compare le mot lu au mot magique afin de vérifier que le fichier ouvert est bien un fichier de table de hachage.

Nous lisons ensuite la taille de la table et la taille des identificateurs. Ces dernières informations nous permettent de créer une table de hachage vierge que nous remplissons avec les entrées sauvée dans le fichier avec une itération. Cette itération prend fin lorsque la fin de fichier est atteinte.

Enfin, nous retournons la table.

La fonction _charge_celule qui lit chaque entrée de la table est :

/* Lecture d'une cellule */
static int _charge_cellule (FILE * fichier,
Hash hash_table) {
int taille_identificateur;
int taille_donnee;
char * identificateur = 0;
void * donnee = 0;
int erreur = 1;

/* lecture de la taille de l'identificateur */
if (fread (& taille_identificateur,
sizeof (int),
1,
fichier) != 1) {
goto Erreur;
}
/* allocation pour l'identificateur */
identificateur = malloc (taille_identificateur);
if (! identificateur) {
goto Erreur;
}
/* lecture de l'identificateur */
if (fread (identificateur,
taille_identificateur,
1,
fichier) != 1) {
goto Erreur;
}
/* lecture de la taille de la donnée */
if (fread (& taille_donnee,
sizeof (int),
1,
fichier) != 1) {
goto Erreur;
}
/* allocation pour la donnée */
donnee = malloc (taille_donnee);
if (! donnee) {
goto Erreur;
}
/* lecture de la donnée */
if (fread (donnee,
taille_donnee,
1,
fichier) != 1) {
goto Erreur;
}
/* ajout dans la table du couple
* identificateur-donnée */

hash_ajoute (hash_table,
identificateur,
donnee,
taille_donnee);
erreur = 0;

Erreur:
/* libération des allocations */
if (identificateur) free (identificateur);
if (donnee) free (donnee);
return (erreur);
}

La fonction lit la taille de l'identificateur et alloue une zone de mémoire de cette taille, puis elle lit l'identificateur. Ensuite, elle lit la taille de la donnée et alloue une zone de mémoire de cette taille ; elle y lit la donnée. Elle ajoute ensuite à la table le couple identificateur-donnée. Enfin, elle libère les ressources allouées.

Les indiens sont là !

En C, un tableau de caractères est un tableau de caractères ! C'est une suite d'octets dont l'adresse est chaque fois supérieure de 1 de la précédente. Lorsque l'on écrit une chaîne de caractères dans un fichier, les caractères sont écrits dans l'ordre, du premier au dernier. L'ordre dans lequel sont écrit les octets est immuable, quelque soit l'ordinateur.

Ceci n'est pas vrai pour les entiers ! En effet, il y a deux manière de ranger un entier en mémoire, soit en plaçant le poids fort en premier, soit en plaçant le poids fort en dernier. Ces modes de stockages sont appelés respectivement little endian et big endian. Par exemple, les processeurs Intel (PC) sont en mode big endian alors que les PowerPC (Machintoch) ou Sparc (Sun) sont en little endian. Si on désire qu'un fichier soit lisible sur plusieurs systèmes, il convient de faire attention à ces modes de stockage. Il existe moins deux solutions : écrire toutes les données sous la forme de chaînes de caractères (les entiers sont préalablement convertis en chaînes), soit écrire une fonction d'écriture des entiers tenant compte du mode de fonctionnement.

Pour détecter le mode de fonctionnement, considérons le programme :

#include <stdio.h>

void main (void) {
union {
int  entier;
char octet[4];
} u;

u.entier = 0x12345678;

printf ("entier=0x%x, c[0]=0x%x, c[1]=0x%x, c[2]=0x%x, c[3]=0x%x\n",
         u.entier,
         (unsigned) u.octet[0],
         (unsigned) u.octet[1],
         (unsigned) u.octet[2],
         (unsigned) u.octet[3]);
if (u.octet[0] == 0x12)
printf ("Cet ordinateur est en little endian\n");
else
printf ("Cet ordinateur est en big endian\n");
}

Le principe est de placer dans une union un entier et un tableau de caractère. On rappelle que les unions placent tous leurs camps dans la même zone mémoire. En écrivant une valeur entière dans le champs entier et en examinant la manière avec laquelle sont stockés les octets dans le tableau, on peut déterminer le mode de fonctionnement du processeur sur lequel le programme fonctionne.

Exécuté sur un PC, ce programme affiche :

(Intel) $ ./a.out
entier=0x12345678, c[0]=0x78, c[1]=0x56, c[2]=0x34, c[3]=0x12
Cet ordinateur est en big endian

Exécuté sur une stations Sun 32 bits, ce programme affiche :

(Sparc) $ ./a.out
entier=0x12345678, c[0]=0x12, c[1]=0x34, c[2]=0x56, c[3]=0x78
Cet ordinateur est en little endian

Cela se traduit par l'occupation en mémoire suivante :

Adresse

Octet


Adresse

Octet

100

0x78


100

0x12

101

0x56


101

0x34

102

0x34


102

0x56

103

0x12


103

0x78

Big endian




Little endian

Nous allons écrire une fonction d'écriture d'entier et une fonction de lecture, indépendante du mode de stockage. De cette manière, les fichiers seront compatibles avec les différents processeurs, quelque soit leur mode de fonctionnement. Par défaut, on choisit d'utiliser le mode little endian pour sauver les entiers. On a :

static int ecrit_entier32 (int entier, FILE * fichier) {
union {
int entier;
char octet[4];
} u;

u.entier = 0x12345678;

  /* détection du mode */
if (u.octet[0] == 0x12) {
/* little endian --> little endian */
if (fwrite (& entier, 4, 1, fichier) != 1) return (0);
}
else {
/* big endian --> little endian */
u.entier = entier;
if (fwrite (& u.octet[3], 1, 1, fichier) != 1) return (0);
if (fwrite (& u.octet[2], 1, 1, fichier) != 1) return (0);
if (fwrite (& u.octet[3], 1, 1, fichier) != 1) return (0);
if (fwrite (& u.octet[0], 1, 1, fichier) != 1) return (0);
}
return (1);
}

La fonction connexe de lecture est définie par :

static int lit_entier32 (int * entier, FILE * fichier) {
union {
int entier;
char octet[4];
} u;

u.entier = 0x12345678;

  /* détection du mode */
if (u.octet[0] == 0x12) {
/* little endian --> little endian */
if (fread (entier, 4, 1, fichier) != 1) return (0);
}
else {
/* little endian --> big endian */
if (fread (& u.octet[3], 1, 1, fichier) != 1) return (0);
if (fread (& u.octet[2], 1, 1, fichier) != 1) return (0);
if (fread (& u.octet[3], 1, 1, fichier) != 1) return (0);
if (fread (& u.octet[0], 1, 1, fichier) != 1) return (0);
* entier = u.entier;
}
return (1);
}

On pourrait décliner, en fonction des besoins, ces fonctions pour lire et écrire des entiers sur seize et soixante quatre bits.

Nous devons maintenant utiliser ces fonctions pour écrire et lire les différentes entier dans le programme des tables de hachage. Par exemples, on replace dans la fonction hash_sauve le fragment de code :

 /* écriture de la taille de la table */
if (fwrite (& hash_table->taille_table,
         sizeof (int),
        1,
         fichier) != 1) {
goto Erreur;
}

par le fragment :

 /* écriture de la taille de la table */
if (ecrit_entier32 (hash_table->taille_table,
                 fichier) != 1) {
goto Erreur;
}

et dans la fonction hash_charge, on remplace le fragment de code :

/* lecture de la taille de la table */
if (fread (& taille_table,
           sizeof (int),
         1,
         fichier) != 1) {
goto Erreur;
}

par le fragment :

/* lecture de la taille de la table */
if (lit_entier32 (& taille_table,
                fichier) != 1) {
goto Erreur;
}

De cette manière, les fichiers seront indépendant de la plate-forme y ayant accès, ce qui est important avec les possibilités de partages des fichiers qu'offre les systèmes d'exploitation évolué comme Linux, notamment avec NFS.

La bibliothèque de manipulation des tables de hachage est maintenant terminée et utilisable pour créer de petites bases de données.

L'auteur

Guilhem de Wailly, directeur de la société Erian Concept, partenaire NetUltra (www.netultra.net) et Getek (www.getek.fr).

Votre interlocuteur Linux !

Support, formations, configurations, administration, développements Linux.

http://www.erian-concept.com

Références

Langage C - norme ANSI : bon ouvrage de référence
Ph. DRIX
Masson

Programmer en C++: une bonne référence
S.C. DEWHURST et K.T. STARK
Masson

Le Langage C: la bible!
B.W. KERNIGHAN et D.M. RITCHIE
Masson

gcc: compilateur C du GNU, et tous les outils de développement
http://www.gnu.org

Kdevel: un environnement KDE de programmation C
http://samuel.cs.uni-potsdam.de/~smeier/kdevelop_new/index.html