Le langage C

Dixième partie : Les pointeurs, 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.

Dans cet article, nous continuerons à étudier les pointeurs du langage C. Véritable bête noire des débutants, nous espérons montrer que, manipulés avec précaution et attention, les pointeurs ne sont pas si difficiles à appréhender. Ce mois-ci, nous reprenons les nombres rationnels et nous terminons la mise en forme de la bibliothèque afin de rendre la structure de donnée opaque à l'utilisateur et à utiliser l'allocation dynamique en mémoire.

Introduction

Le mois dernier, nous avons introduit les pointeurs. Ces fameux pointeurs qui ont coûtés quelques heures de débogage à toute personne se lançant dans le langage C. Même pour les informaticiens confirmés, il arrive encore de chercher et de commettre des erreurs sur les pointeurs. Il faut dire qu'ils sont facilement utilisables, très puissants, et dangereux. Facilement utilisables car la syntaxe est accessible et compréhensible ; puissants car ils permettent d'accéder potentiellement à la totalité de la mémoire, et même aux zones ou il n'y a pas de mémoire ou interdites par le système d'exploitation, enfin dangereux car le langage C ne contrôle et ne limite absolument pas leur utilisation.

On retrouve donc là une règle assez générale avec le langage C : c'est un langage très puissant qui laisse au programmeur beaucoup de libertés et donc de responsabilités. Cette abondance de liberté doit être contenue avec des règles de programmation destinées à limiter et maîtriser cette liberté. Par exemple, il est possible d'écrire un programme volumineux sur une seule ligne; cependant, on utilise des règles d'écritures afin de rendre les programmes lisibles, donc faciles à maintenir. Ces règles d'écritures ne sont pas destinées à aider le compilateur C, mais à faciliter la programmation et la maintenance du code. Le noyau Linux fait à peu près 1 million de lignes de code, ce qui est énorme ; sans certaines conventions, il serait difficile de maintenir un tel volume de code. Tout au long de cette série d'articles, nous utilisons constamment des règles de syntaxes issues des conventions de fait et de mon expérience avec ce langage. L'avantage à utiliser ces règles, c'est qu'on n'y pense même plus, et qu'elles permettent de détecter les erreurs plus facilement.

On retrouve la même idée avec les pointeurs. On les utilise dans 90% des cas pour manipuler des structures de données par référence. On a donc établi pour cela un certain nombre de règles d'écriture qui seront utilisées dans ces articles. Pour les 10% des cas restants, il n'y a pas de règles, et ces portions de code 'hors norme' seront abondamment commentées par le programmeur.

Les nombres rationnels

Dans les articles précédents, nous avons présenté une bibliothèque pour manipuler les nombres rationnels. Cette bibliothèque était constituée d'un fichier d'interface, rationnel.h, d'un fichier d'implémentation bas niveau, _rationnel.c, d'un fichier d'implémentation haut niveau, rationnel.c et enfin d'un fichier utilisateur, main.c.

La bibliothèque utilisant l'allocation statique sur la pile de l'appelant pour réserver l'espace nécessaire aux structures de données. L'interface proposait un constructeur et un destructeur, puis deux sélecteurs. Elle a été conçue dès le départ pour permettre le passage, non pas aux 35 heures, mais à l'allocation dynamique, sans entraîner de changements dans les programmes utilisateurs rationnel.c et main.c, bien que le fonctionnement allait changer radicalement. Ce mois-ci, nous tenons nos promesses, non pas en ce qui concerne les 35 heures, mais en ce qui concerne l'allocation dynamique.

A la fin de l'article, le lecteur aura dans les mains l'une des clefs permettant une programmation efficace en langage C, la modularisation. Il pourra alors se constituer des bibliothèques de fonctions manipulant des objets opaques qui pourra conserver tant que le langage C sera utilisé. Ce type de programmation est très en vogue dans les logiciels libres, où les composants sont des bibliothèques réutilisables par la multitude.

Les nombres rationnels sont représentés par une structure de donnée dans le fichier rationnel.h :

/* type de donnée */
typedef struct rationnel {
int num;
int den;
} rationnel;

Cette écriture définit un nouveau type de donnée appelé rationnel, constitué d'une structure de donnée contenant deux entiers.

Sans pointeur

Pour déclarer un nombre rationnel, nous écrivons :

rationnel un_rationnel;

Cette écriture provoque une allocation implicite de mémoire dans la pile capable de recevoir les deux entiers du nombre rationnel. Le constructeur de l'objet est sa déclaration elle-même. La structure est allouée, mais les valeurs qu'elle contient sont aléatoires et dépendent des valeurs qu'il y a sur la pile au moment de l'allocation. C'est une particularité du langage C où les variables ne sont pas initialisées lorsqu'elles sont déclarées et qui contiennent donc des valeurs aléatoires.

Pour accéder aux champs du nombre rationnel, nous écrivons :

/* accès en écriture */
un_rationnel.num = 3;
un_rationnel.den = 65

/* acces en lecture */
printf ("num=%d, den=%d\n",
un_rationnel.num,
un_rationnel.den);

L'espace alloué à la structure de donnée disparaît lorsque l'on quitte la fonction ayant déclaré la variable. Si cette fonction est main, la structure est désallouée lorsque l'on quitte le programme. Il n'y a donc pas besoin de destructeur explicite.

Que se passe-t-il si nous utilisions les pointeurs ?

Avec pointeur

Déclarons une variable de type 'pointeur sur une structure de nombre rationnel' avec :

/* déclaration du pointeur */
rationnel * ptr_rationnel;

Cette écriture alloue dans la pile un espace capable de recevoir un pointeur sur une structure. La valeur de la variable est aléatoire et elle dépend de la valeur qu'il y a sur la pile au moment de la déclaration. En matière de pointeur, cela signifie que le pointeur ptr_rationnel pointe n'importe où en mémoire. Attention, la structure de donnée n'est pas allouée avec la déclaration du pointeur. Si maintenant nous tentions d'écrire une valeur dans la structure, nous avons une chance sur deux de provoquer une erreur mémoire.

Pour allouer un zone de la mémoire capable de recevoir la structure de donnée, nous utilisons la fonction malloc :

/* allocation de l'espace mémoire */
ptr_rationnel = malloc (sizeof (rationnel));

Cette fonction retourne l'adresse d'une zone de la mémoire dont la taille est spécifiée en argument ; ici, la taille souhaitée pour cette zone est retournée par l'opérateur sizeof. Sizeof retourne un entier qui est la taille en mémoire de toute variable C.

La fonction malloc alloue donc un espace mémoire. Mais si la totalité de la mémoire est occupée, malloc peut retourner la valeur d'adresse 0. 0 est une adresse que malloc retourne lorsqu'il n'y a plus de place en mémoire. Ceci est maintenant normalisé par la norme ANSI du langage C. Avant cette normalisation, malloc pouvait retourner -1 ou 0 pour signifié l'absence d'espace mémoire. Cette valeur était placée dans une constante appelée NULL. Le test portait sur NULL en non pas sur 0.

Dans un prochain article, je vous propose d'écrire notre propre allocateur de mémoire. Ceci démystifiera cette fonction, et l'allocation dynamique en général.

Nous devons donc tester la valeur retournée par malloc et effectuer un traitement en cas d'erreur :

/* test */
if (! ptr_rationnel) {
printf ("Erreur: no more memory\n");
exit (1);
}

Si le pointeur vaut 0, alors il n'y a plus de mémoire disponible. Dans ce cas, nous affichons un message d'erreur (nous verrons plus tard qu'il faudra préférer le flux d'erreur standard), puis nous appelons la fonction exit. Cette fonction quitte le programme en cours et lui donne comme valeur de retour 1. Par convention, exit (0) signifie qu'il n'y a pas erreur, et les autres valeurs indiquent une erreur. Je vous invite à regarder la page de manuel de exit pour connaître les valeurs d'erreur possible (man 3 exit).

Le traitement des erreurs en cours de fonction reste un problème ouvert. Il y trois solutions : soit prendre l'initiative de déclarer l'erreur, soit retourner un code d'erreur qui sera traité par l'appelant, soit invoquer une fonction spéciale de récupération sur erreur. La difficulté de la récupération sur erreur est de défaire ce qui à été fait ; là, les langages ayant un garbage collector sont très à l'aise (voir plus bas).

L'espace alloué par malloc reste alloué jusqu'à ce qu'on appelle la fonction free avec la valeur retournée par malloc. L'espace alloué reste donc alloué même si on quitte la fonction ayant appelé malloc. Attention, free ne peut être appelée qu'une seule fois pour la même adresse, et elle ne peut pas être appelée avec l'adresse 0.

Pour manipuler la structure, nous utilisons donc la syntaxe vue dans l'article précédent :

/* accès en écriture */
ptr_rationnel->num = 3;
ptr_rationnel->den = 65

/* acces en lecture */
printf ("num=%d, den=%d\n",
ptr_rationnel->num,
ptr_rationnel->den);

Pour détruire l'espace alloué, nous appelons la fonction free :

/* libération de l'espace */
free (ptr_rationnel);

Pourquoi c'est difficile ?

Le niveau de difficulté vient de monter d'un cran. Maintenant, les risques d'erreurs sont nombreux, et sans appel : c'est le crash pur et simple, par ce que l'on touche à la mémoire qui est géré par des mécanismes de gestion matériels provoquant des interruptions en cas d'erreurs.

Quelles sont les erreurs classiques dues à l'utilisation des pointeurs et à l'allocation dynamique ? En voici quelques unes:

ï‚· On oublie d'appeler malloc : A terme, le crash est assuré parce qu'on fini tôt ou tard par tenter d'écrire sur une zone de la mémoire interdite.

ï‚· On oublie de tester la valeur de retour de malloc : la taille des mémoires actuelles nous met plus à l'abri de ce genre de problème qu'auparavant, mais ils peuvent encore subsister dans le cas des programmes gourmands en mémoire et dans le cas des allocations récursives.

ï‚· free est appelée sur l'adresse 0 : lorsque l'on ne teste pas le retour de malloc, on oublie généralement de tester le pointeur avant d'appeler free. Là aussi, c'est sans appel : boum !

ï‚· free n'est pas appelée : cela provoque, à terme, une saturation de la mémoire. Certains programmes destinés à traiter des flots de donnée peuvent fonctionner en allouant de la mémoire sans se préoccuper de la libérer, car le programmeur sait que la durée de vie du programme est limitée. Le système d'exploitation libère automatiquement toute la mémoire allouée à un processus, donc, dans ce cas, l'absence d'appel à la fonction free est sans danger.

ï‚· free est appelée deux fois pour la même adresse : ceci se présente lorsque l'on alloue des structures récursives, comme les arbres. Dans ce cas, il devient assez complexe de ne libérer la mémoire qu'une seule fois. On tombe là sur des erreurs très difficiles à détecter car l'erreur survient souvent bien après le free fautif, sans qu'il soit possible de faire le lien facilement.

Ce sont là les principales sources d'erreur. A première vue, il semble facile de les éviter, mais dans certains cas, les problèmes de libération sont extrêmement complexes. Les habitués des écrans bleus doivent cette habitude, sans doute, à l'une des causes énumérée ci-dessus.

Les langages actuels tendent à s'affranchir des problèmes d'allocation en utilisant un garbage collector (littéralement : ramasse miettes) qui gère automatiquement le malloc et le free. Dans ces langages, on a Lisp, Java, Eiffel, et Scheme, mon préféré !

Pour le langage C, il existe des bibliothèques d'allocation facilitant la détection des erreurs. L'une d'elles est ElectricFence, dans toutes les bonnes distributions Linux. Il suffit de lier notre programme avec cette bibliothèque, et malloc et free sont gérés par la bibliothèque qui signale assez bien les erreurs.

Uniformisation

Pour pallier aux difficultés inhérentes à l'utilisation de l'allocation dynamique, nous proposons un ensemble de règles destiné à éviter les erreurs. Ces règles sont applicables lorsque l'on manipule des structures de donnée allouées dynamiquement.

Nous proposons le fichier rationnel.h :

/* rationnel.h */

/* type de donnée */
typedef struct
Rationnel {
int num;
int den;
} _Rationnel;

/* pointeur */
typedef _Rationnel * Rationnel;

/* constructeur/destructeur */
Rationnel r_construit (int num,
int den);
void r_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 déclarons la structure de donnée _Rationnel, avec un tiret devient, et la première lettre en majuscule. Puis le pointeur est déclaré de la même manière, sans le tiret devant. Nous avons les sélecteurs, puis les destructeurs.

Reprenons maintenant le fichier main utilisant les nombres rationnels. Nous avons maintenant :

#include <rationnel.h>

void main (void) {
/* déclarations */
Rationnel u, v, w;

/* construction */
u = r_construit (12, 31);
v = r_construit (7, 3);

/* addition */
w = r_plus (u, v);

/* affichage */
r_affiche (u);
printf (" + ");
r_affiche (v);
printf (" = ");
r_affiche (w);

/* destruction */
r_detruit (u);
r_detruit (v);
r_detruit (w);
};

Ce fichier est identique au fichier que nous avions lorsque nous n'utilisions pas l'allocation dynamique. La seule chose qui a changé, c'est de remplacer le type de donnée rationnel par Rationnel. Nous sommes donc passés de l'allocation statique à l'allocation dynamique sans obliger les utilisateurs de notre bibliothèque à modifier leur code. Cela a été rendu possible par l'utilisation d'un fichier d'entête.

Attachons nous maintenant à écrire le fichier d'implémentation _rationnel.c :

/* _rationnel.c */

#include
<rationnel.h>

/* constructeur */
Rationnel r_construit (int num, int den) {
Rationnel r;

r = malloc (sizeof (_Rationnel));
if (! r) {
printf ("Erreur: plus de mémoire\n");
exit (1);
}
r->den = den;
r->num = num;

return (r);
}

/* destructeur */
void r_detruit (Rationnel x) {
free (x);
}


/* sélecteurs */
int r_den (rationnel x) {
return (x->den);
}

int r_num (rationnel x) {
return (x->num);
}

Passons maintenant au fichier d'extension rationnel.c :

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

/* additionneur */
Rationnel r_plus (Rationnel x,
Rationnel y) {
Rationnel r;
int n, d ;

n = r_num (x) * r_den (y)
+ r_num (y) * r_den (x);
d = r_den (x) * r_den (y);
r = r_construit (n, d);

return (r);
};

/* afficheur */
void r_affiche (Rationnel x) {
printf ("%d/%d",
r_num (x),
r_den (x));
};

Mis à part la lettre en majuscule, aucun changement.

Dernière touche !

Avons-nous atteint le nirvana de la programmation en C ? Pas encore : un petit détail résiste encore à l'envahisseur ! Notre fichier rationnel.h décrit encore explicitement la structure de donnée utilisée. Pour obtenir une opacité complète, nous devons cacher cette structure de donnée.

Avant l'utilisation de l'allocation dynamique, les programmes utilisateurs devaient connaître la nature de la structure de donnée rationnel pour pouvoir allouer statiquement l'espace nécessaire dans la pile. Les accès au contenu de la structure étant fait au travers de fonctions, la connaissance de la structure était inutile pour la manipuler.

Maintenant que les programmes utilisateurs ne sont plus responsables de l'allocation de la structure, puisqu'elle est confiée à la fonction malloc, ils peuvent se passer de cette connaissance. Le seul fichier qui doit connaître la nature de la structure de donnée, c'est le fichier _rationnel.c qui défini l'allocateur, le destructeur et les sélecteurs. C'est d'ailleurs le seul fichier à utiliser l'opérateur -> du langage C.

Eh bien, simplement, nous supprimons du fichier rationnel.h la définition de la structure _Rationnel pour la déplacer dans le fichier _rationnel.c. Nous obtenons alors:

/* rationnel.h */

/* objet */
typedef _Rationnel * Rationnel;

/* constructeur/destructeur */
Rationnel r_construit (int num,
int den);
void r_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);

Le fichier d'interface ne contient plus maintenant de déclaration du contenu de la structure de donnée. La seule information qu'il contient est la définition de Rationnel comme étant un pointeur vers une structure dont on ignore tout. Cela suffit au compilateur C qui peut effectuer tous les contrôles de typages et les allocations des variables de type Rationnel. Maintenant, l'utilisateur est obligé d'utiliser les fonctions fournies dans l'interface. La manière de stocker la structure de donnée ne regarde personne d'autre que son créateur qui écrit le fichier d'implémentation suivant :

/* _rationnel.c */

#include
<rationnel.h>

/* type de donnée */
typedef struct
Rationnel {
int num;
int den;
} _Rationnel;

/* constructeur */
Rationnel r_construit (int num, int den) {
Rationnel r;

r = malloc (sizeof (_Rationnel));
if (! r) {
printf ("Erreur: plus de mémoire\n");
exit (1);
}
r->den = den;
r->num = num;

return (r);
}

/* destructeur */
void r_detruit (Rationnel x) {
free (x);
}


/* sélecteurs */
int r_den (rationnel x) {
return (x->den);
}

int r_num (rationnel x) {
return (x->num);
}

Nous pouvons donc maintenant réaliser une bibliothèque opaque constituée de _rationnel.c et rationnel.c pour l'implémentation, et de rationnel.h pour l'interface.

Ce type de programmation s'apparente en partie (et de loin quand même) à la programmation orientée objets. Les bibliothèques proposent des objets abstraits et des fonctions pour les manipuler.

La limite principale à la généralisation de cette manière de programmer (rendre complètement opaque un objet abstrait) concerne la baisse de performances : en effet, lorsque la structure est ouverte, on peut accéder directement à son contenu, au lieu de passer par des fonctions. Cependant, il est possible de concevoir des objets dont une partie seulement est publique, permettant un accès direct, et l'autre partie restant opaque.

L'auteur

Guilhem de Wailly, directeur de la société Erian Concept, partenaire NetUltra, Idealx et Getek.

Votre interlocuteur Linux !

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

http://www.erian-concept.com

Références

Langage C - norme ANSI
Ph. DRIX
Masson

Programmer en C++
S.C. DEWHURST et K.T. STARK
Masson

Le Langage C
B.W. KERNIGHAN et D.M. RITCHIE
Masson

Kdevel
http://samuel.cs.uni-potsdam.de/~smeier/kdevelop_new/index.html