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 cité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érence. Le but poursuivi est clairement de parvenir à construire des programmes de manière segmentée.
Dans cet article, nous introduisons la manière de définir de nouveaux types de données et nous utilisons immédiatement nos connaissances pour entamer la définition d'une bibliothèque manipulant des nombres rationnels.
Introduction
Ce mois-ci, nous allons étendre significativement nos connaissances en apprenant comment définir de nouveaux types de données structurées.
Ces connaissances vont nous permettre de concevoir notre première bibliothèque en utilisant la notion de paquetages.
Le paquetage permet de séparer de manière logique les composantes d'un logiciel de manière à les rendre les plus indépendantes possible. De cette façon, la modification de l'une d'entre elles n'entraîne pas de modification sur les autres parties.
Cette méthode de programmation est utilisée par l'auteur de l'article pour le logiciel OpenScheme, qui comporte près de 140 000 lignes de code. Sans une modularisation stricte, il serait quasiment impossible de faire évoluer une telle quantité de code. Que penser alors du noyau Linux qui comporte près de 950 000 lignes de code !
Cette manière de programmer utilise des concepts provenant des langages à objets, bien que la ressemblance reste éloignée car le langage C n'est pas orienté objet. Ces techniques sont très bien décrites dans le livre de P. Drix.
Enumération
Lorsqu'on programme, on a souvent affaire à des variables qui ne peuvent contenir qu'un certain nombre d'éléments délimités.
Par exemple, nous pourrions avoir une variable de type fruit, pouvant contenir l'une des valeurs pomme, poire, fraise ou mure.
Le langage C permet de définir de tels types de données appelées énumération. Pour cela, nous écrivons :
enum fruit {
pomme,
poire,
fraise,
mure
};
void f (enum fruit x) {
if (x == pomme ) printf ("pomme \n");
else if (x == poire ) printf ("poire \n");
else if (x == fraise) printf ("fraise\n");
else if (x == mure ) printf ("mure \n");}
void main (void)
{
f (fraise);
}
Ce programme commence par définir une énumération nommée fruit. Cette énumération contient les noms de fruit. Les mots pomme, poire, fraise et mure sont les valeurs possibles pour une variable de type enum fruit.
Puis il définit une fonction dont x, le paramètre, est de type enum fruit. Cette fonction effectue un certain nombre de tests destinés à afficher le nom de fruit correspondant.
Enfin, pour utiliser cette fonction f, la fonction main est définie. Elle invoque f avec l'argument fraise.
Dans cet exemple, nous voyons que les mots enum fruit sont devenus un nouveau type de donnée, comme int est le type de donnée des entiers.
Il serait possible de se passer de l'énumération en utilisant par exemple des nombres entiers. L'énumération est importante car elle permet au compilateur de vérifier que les valeurs affectées aux variables appartiennent bien aux éléments de l'énumération. De plus, les programmes sont plus lisibles.
Dans la fonction f, nous avons utilisé une série de tests pour afficher le nom de fruit de x. Le C propose, dans ce cas, une instruction d'énumération appelée switch.
Instruction de sélection
Lorsque nous souhaitons sélectionner des fragments de programmes en fonction de la valeur d'une expression, le C met à notre disposition l'instruction switch. La syntaxe générale du switch est la suivante :
switch (expression) {
case valeur1: expressions1;
case valeur2 : expressions2;
...
case valeurn: expressionsn;
default: expressionsd;
}
L'expression est n'importe quelle expression C. Les valeuri sont des constantes, comme 1, 2, 3, ou pomme, poire. Les expressionsi sont une ou plusieurs expressions C, séparées par des points-virgules.
La clause default est facultative. Elle n'est pas obligatoirement placée à la fin du switch.
Le switch évalue expression. Puis il cherche parmi les clauses case celle dont la valeur correspond à la valeur retournée par l'évaluation précédente.
Si aucune clause ne correspond, la clause default est sélectionnée et les expressionsd sont exécutées. Si la clause default n'existe pas, aucune expression n'est exécutée.
Si une clause correspond, alors les expressions attachées à la clause sont exécutées, AINSI QUE TOUTES LES EXPRESSIONS SUIVANTES. Cela est différent de la plupart des autres langages proposant la sélection, qui est une cause fréquente de bugs dans les programmes.
Si on souhaite n'exécuter que les expressions correspondant à la clause sélectionnée, il faut explicitement « sortir du switch » en utilisant l'instruction break.
La fonction f d'affichage des noms de fruits vue dans la section précédente devient en utilisant un switch :
switch (x) {
case pomme : printf ("pomme \n"); break;
case poire : printf ("poire \n"); break;
case fraise: printf ("fraise\n"); break;
case mure : printf ("mure \n"); break;
}
Les breaks sont nécessaires pour ne pas afficher les noms en dessous de celui sélectionné. Sans eux, et si x vaut fraise, on verrait affichés à l'écran, sur deux lignes, fraise et mure.
Les valeurs après les cases doivent être des constantes, comme des nombres, des caractères, des valeurs d'énumération. Elles ne doivent pas nécessiter une évaluation pour obtenir leur valeur.
Structure de données
Les énumérations permettent de restreindre le nombre de valeurs possibles pour une variable.
Le langage C permet aussi de définir des types de données composées, c'est-à-dire contenant des champs, chacun ayant un certain type.
Ce type de données est appelé structure et répond à la syntaxe générale suivante :
struct nom {
type1 nom1;
type2 nom2;
...
typen nomn;
};
Les typei sont soit des types de données standards, soit des types que nous avons définis.
Les nomi sont les noms des champs. Par exemple, on pourrait définir un type de donnée dont le premier champ serait un fruit et le second un entier représentant le mois où le fruit est mûr :
struct fruit_mois {
enum fruit nom;
int mois;
};
Pour déclarer une variable structure, nous procédons comme avec l'énumération :
struct fruit_mois f;
Nous ne pouvons pas accéder globalement à tous les champs de la structure. Aussi, le langage C définit une syntaxe pour accéder individuellement aux champs de la structure :
f.nom = pomme;
f.mois = 9;
Il suffit de séparer la variable structure du nom de champ par un point. Si un champ est aussi une structure, on peut placer plusieurs points, comme dans :
s.champ_1.champ_123.variable = 123;
Création de types de données
Dans ce qui précède, nous avons vu que le langage C permet de construire de nouveaux types de données.
Mais nous ne sommes qu'à moitié satisfaits car il nous faut toujours, au moment de la déclaration d'une variable, indiquer la nature du type, comme avec :
enum fruit variable;
Nous aimerions pouvoir déclarer une variable avec un nom de type sans indiquer la nature du type de données, comme avec :
fruit variable;
Les auteurs du langage C avaient pensé à tout ! Pour cela, ils ont défini le mot clé typedef qui permet de définir de nouveaux types de données qu'on ne peut différencier des types standards.
Si nous écrivons :
enum fruit variable;
nous déclarons une nouvelle variable de type enum fruit. Si nous plaçons devant toute l'expression le mot clef typedef, nous déclarons alors un nouveau type de donnée :
typedef enum fruit type;
Maintenant, enum fruit et type sont équivalents et nous pouvons écrire indifféremment :
enum fruit x;
ou :
type x;
Le langage C nous permet de rassembler les deux déclarations suivantes :
enum fruit {
pomme,
poire,
fraise,
mure
};
typedef enum fruit fruit;
en une seule écriture compacte, que nous utiliserons toujours par la suite :
typedef enum fruit {
pomme,
poire,
fraise,
mure
} fruit;
Pour une structure de donnée, cela donne :
typedef struct nom {
type1 nom1;
type2 nom2;
...
typen nomn;
} nom;
ce qui nous permet de déclarer une variable x de type nom avec :
nom x;
Maintenant, nous allons pouvoir nommer nos nouveaux types de données de manière complètement transparente.
Nombres rationnels : primitives
Forts de nos nouvelles connaissances, nous souhaitons concevoir une bibliothèque pour les nombres rationnels.
Nous allons créer notre bibliothèque comme une véritable librairie C orientée objet : l'objet rationnel aura un constructeur et un destructeur, et chaque champ de l'objet aura un sélecteur pour y accéder.
Structure de donnée
La première chose à faire est de définir le type de donnée :
typedef struct rationnel {
int num;
int den;
} rationnel;
Cette structure contient deux champs de type entier int ; le premier champ est le numérateur et le second champ est le dénominateur.
Constructeur et destructeur
La première fonction que nous allons définir permet la construction d'un nombre rationnel. On passe à cette fonction deux entiers et elle retourne un nombre rationnel :
rationnel r_construit (int num, int den) {
rationnel r;
r.den = den;
r.num = num;
return (r);
}
La seconde fonction à définir est le destructeur. Dans le cas des rationnels, cette fonction ne fait rien. Mais dans d'autres cas, il pourrait y avoir de la mémoire à libérer ou des fichiers à fermer. Pour proposer un canevas général, même si la fonction ne fait rien, il faudra la définir et l'utiliser. La fonction de destruction est :
void r_detruit (rationnel x) {
return;
}
L'instruction return n'est pas utile ici. Mais nous l'écrivons quand même pour bien spécifier notre intention de quitter immédiatement la fonction sans rien faire.
Sélecteurs
Les sélecteurs sont des fonctions qui permettent d'accéder aux champs de la structure. Elles s'écrivent simplement :
int r_den (rationnel x) {
return (x.den);
}
int r_num (rationnel x) {
return (x.num);
}
Nombres rationnels : utilitaires
Maintenant que nous avons les primitives de manipulation des nombres rationnels, nous pouvons créer des fonctions de manipulation.
Addition
La première fonction permet d'additionner deux nombres rationnels et de retourner le résultat sous la forme d'un nombre rationnel :
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);
}
Nous rappelons que a/b + u/v = (av + ub)/(bv).
Affichage
Cette fonction affiche un nombre complexe à l'écran :
void r_affiche (rationnel x) {
printf ("%d/%d", r_num (x), r_den (x));
}
Nombres rationnels : utilisation
Nous avons maintenant de quoi réaliser un petit programme à base de nombres rationnels :
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 programme crée deux nombres rationnels u et v ; il place le résultat de leur addition dans le nombre rationnel w.
Un message est alors affiché, rendant compte de l'opération. Puis tous les nombres rationnels créés sont détruits.
Paquetage
Pour faire fonctionner ce programme, il faut rassembler toutes les fonctions dans le même fichier, placer #include <stdio.h> en tête du fichier (nous utilisons printf), le compiler et exécuter le binaire produit.
Examinons la structure du programme. La première partie est regroupée dans une section appelée primitive. Cette section définit le type de donnée, le constructeur, le destructeur et les sélecteurs.
La seconde section se nomme utilitaires : elle regroupe des fonctions qui n'utilisent que le nom du type de donnée et les fonctions définies dans la section primitive.
Enfin, la troisième partie utilise ce qui est défini dans les deux sections primitive et utilitaire.
Cette structure peut sembler lourde. Elle a l'avantage d'être complètement générique et l'on peut concevoir des programmes de taille très importante en suivant ce modèle.
Il est intéressant de constater que les fonctions des sections utilitaires et utilisation ne font absolument aucune hypothèse sur la nature du type de donnée qu'elles manipulent. Elles manipulent un nom, rationnel, avec des fonctions. Elles ne connaissent rien de rationnel, hormis son nom et les fonctions.
Ceci est la base de la notion de paquetage : un paquetage fournit des fonctionnalités agissant sur des noms de données, sans jamais rien dévoiler de la manière de les réaliser.
Ainsi, la réalisation est devenue complètement indépendante. Nous verrons que notre manière de réaliser les nombres rationnels est très pratique et simple, mais absolument inefficace.
Nous serons amenés à modifier complètement la manière de réaliser les nombres rationnels et nous constaterons que seule la section primitive sera modifiée, les autres sections restant inchangées. Ainsi, sur de gros programmes, si seule une toute petite partie doit être modifiée, cela entraîne automatiquement une réduction importante des coûts de développement.
Dans les articles qui vont suivre, nous allons améliorer notre paquetage en créant une véritable interface « opaque » où les nombres rationnels seront créés de manière dynamique dans la mémoire. Cela nous conduira à créer des programmes sur plusieurs fichiers et donc, à examiner la gestion de projets sous Linux.