Partager l'article ! Tout sur le langage C++ (episode 8): Champs de bits : L’unité usuelle de compte en informatique est l’octet, car les ordinateurs m ...
L’unité usuelle de compte en informatique est l’octet, car les ordinateurs manipulent les données par paquets de huit bits en général, et souvent de seize ou plus. Cependant, dans certains cas, on doit accéder à certains bits individuellement dans une donnée.
Pour cela, on dispose d’opérateurs sur les types entiers, comme le décalage à gauche ou à droite (<< ,
>>), le « et » , le « ou » et le « ou exclusif » logiques (&, |, ^).
Cependant, lorsqu’on doit faire de nombreux accès aux bits séparés d’une donnée, cela devient trop long, et désagréable, de spécifier une opération logique chaque fois. Les structures à champs de bits permettent de résoudre ce problème.
Une telle structure est semblable à toute autre, mais derrière le nom des champs du type int ou unsigned, on précise un nombre de 1 à 16 indiquant la taille en
bits du champ. Voici un exemple simple :
struct champbits { unsigned basbas : 4; unsigned bashaut : 4; unsigned hautbas : 4; int hauthaut : 4; };
Cette structure occupe seize bits (4 fois 4) en mémoire, soit la taille d’un entier usuel. Notons qu’on aurait pu la déclarer plus brièvement ainsi :
struct champbits { unsigned basbas : 4, bashaut : 4, hautbas : 4; int hauthaut : 4; };
Lorsqu’un champ de bits est unsigned, sa valeur varie de 0 à 2b -1, où b est le nombre de bits. Par exemple, le champ basbas a
une valeur de 0 à 15. Lorsque le champ est signed, sa valeur varie de -2b-1à 2b-1-1, le bit de poids fort servant de
bit de signe ; ainsi le champ hauthaut varie de -8 à 7.
Les champs de bits sont utilisés comme des entiers ordinaires ; lors d’une affectation, les bits excédentaires sont supprimés, les bits manquants sont nuls. Par exemple, si l’on écrit :
champbits cb; int i = 30 // 30 == 0x1E cb.bashaut = i; // met 0xE == 14 dans cb.bashaut i = cb.bashaut; // maintenant i == 14
le résultat est de tronquer i en ne conservant que ses quatre bits de poids faibles.
Si vous écrivez :
int i = -7890; champbits cb; cb = *(champbits*)&i; // recopieidanscb
vous obtiendrez dans cb la décomposition de -7890 en quatre parties, soit { 14, 2, 1, -2 }, indiquant que ce nombre vaut 0xE12E en mémoire (dans sa forme sans signe).
Voici un autre exemple, un peu plus intéressant à notre avis. La fonction suivante calcule la valeur de la mantisse, de l’exposant et du signe d’un nombre à virgule flottante float.
Ces quantités sont réparties ainsi dans les quatre octets occupés par un float (des bits de poids faibles aux forts) : 23 bits de mantisse, 8 bits d’exposant (biaisé par 127),
et un bit de signe :
void disseque(float f, int& signe, int& exposant long& mantisse) { struct dissq { unsigned mantisse1 : 16; unsigned mantisse2 : 7; unsigned exposant : 8; int signe : 1; } fb; fb = *(dissq*)&f; // recopie f dans fb exposant = fb.exposant -127; signe = fb.signe; mantisse = 0x800000 | fb.mantisse1 | (long(fb.mantisse2) << 16); }
L’exposant est biaisé, ce qui explique qu’il faille retirer 127. Quant à la mantisse, elle est ici en deux parties car on ne peut avoir de champs de bits de plus de seize bits. En outre, le bit
le plus élevé (le vingt-quatrième), qui est toujours à 1, n’est pas stocké dans le nombre, il faut le rajouter explicitement (d’où le 0x800000). La valeur du nombre est alors :
Nous avons vu précédemment que tous les bits des champs de bits étaient placés les uns derrière les autres, du moins significatif (déclaré en premier) au plus significatif. Il arrive que l’on ne souhaite pas utiliser certains bits. Dans ce cas, il suffit de ne pas nommer le champ correspondant. Par exemple, les microprocesseurs de la famille 8086 ont un mot d’état de seize bits, dont seuls quelques-uns ont un sens. Ainsi, le bit ZF est à 1 si la dernière opération a produit un résultat nul, à 0 sinon. Voici une structure reproduisant cette configuration :
struct flags { unsigned CF : 1; // retenue unsigned : 1; unsigned PF : 1; // parité unsigned : 1; unsigned AF : 1; // retenue auxiliaire unsigned : 1; unsigned ZF : 1; // zéro unsigned SF : 1; // signe unsigned TF : 1; // trap unsigned IF : 1; // autorisation d’interruption unsigned DF : 1; // direction unsigned OF : 1; // débordement unsigned : 4; }
Les bits non utilisés, au nombre de sept, figurent sans nom dans cette structure.
Signalons aussi que, lorsque l’on demande au compilateur Turbo C++ d’aligner les données sur les mots de mémoire, il ne doit pas y avoir de champ de bits chevauchant une limite de mot, sinon il est décalé sur le mot suivant.
On notera que les champs de bits n’ont pas d’adresse mémoire (il est illégal d’utiliser l’opérateur d’adressage & avec eux), puisqu’ils ne se trouvent pas nécessairement sur une
limite d’octet. En outre le langage ne permet pas de les organiser en tableaux.
Les champs de bits peuvent procurer des facilités dans certains cas ; ils sont surtout utiles dans des applications très techniques faisant intervenir le matériel ou les périphériques.
Une structure peut avoir à la fois des champs de bits et des champs normaux, ainsi que des méthodes. Une classe et une union (ci-après) peuvent aussi en avoir.
Les structures ont pour taille approximativement la somme des tailles de leurs composants. Leur taille peut donc devenir très grande. Or il arrive que certains champs ne soient pas utilisés lorsque d’autres le sont, parce qu’ils sont mutuellement incompatibles.
Pour résoudre partiellement ce problème, le langage fournit les unions. Il s’agit de groupes de données, comme les structures, mais au lieu de se trouver placées les unes derrière les autres en mémoire, elles se trouvent toutes à la même adresse. Par exemple, l’union suivante :
union longgroupe { long l; unsigned mots[2]; unsigned char octets[4]; }
n’occupe que quatre octets en mémoire (on suppose ici que c’est la taille des entiers long, mais cela peut être différent sur votre ordinateur). De la sorte, si l’on écrit :
longgroupe lg; lg.l = 100000;
comme 100000 = 0x186A0, on trouvera dans lg.mots les valeurs { 0x86A0, 0x1 } (soit 34464 et 1) (sur PC, le mots de poids faibles sont
placés en premier) et dans lg.octets { 0xA0, 0x86, 0x1, 0x0 } (soit 160, 134, 1 et 0). On a ainsi un moyen simple de
décomposer un entier long, ou n’importe quoi d’autre, en octets.
On peut initialiser une union en donnant entre accolades la valeur de son premier champ, comme ceci :
longgroupe lg = { 100000 };
D’une façon générale, une union peut contenir autant de champs de n’importe quel genre que souhaité, mais ils se trouvent tous à la même adresse, de sorte que la taille de la structure est celle du plus long de ces champs. En outre, l’union ne sait pas quel champ « est le bon » , en ce sens que n’importe lequel peut être modifié à tout moment, avec des répercussions sur tous les autres. C’est pourquoi il est peu recommandé de placer des informations différentes dans une union simplement pour récupérer de la place, si l’on ne dispose pas d’un moyen simple pour savoir quelle est le champ qui correspond à une information valable.
Un tel moyen consiste à utiliser les unions comme champs de structures ou mieux encore de classes. Prenons un exemple (un peu bête car il est difficile de ne pas en prendre un artificiel, vu qu’il existe souvent de meilleurs systèmes) : nous disposons d’un fichier avec les noms complets de personnes. En Occident, le nom complet est généralement formé de deux mots, ici arbitrairement limités à 15 caractères. Dans certains pays, en Orient notamment, il est composé de trois mots plus petits (10 caractères), dont seul le premier représente le nom de famille, les deux autres formant le prénom. Voici un exemple de classe qui peut indifféremment stocker un nom oriental ou occidental. Un champ spécial indique si l’on est en présence de l’une ou l’autre alternative :
class noms { char oriental; // 1 = oriental, 0 = occidental union { char nomorient[3][10]; char nomoccident[2][15]; }; public : noms(char *nom, char *prenom1, char *prenom2 = 0) { oriental = (prenom2 != 0); if (oriental) { strncpy(nomorient[0], nom, 10); strncpy(nomorient[1], prenom1, 10); strncpy(nomorient[2], prenom2, 10); } else { strncpy(nomoccident[0], nom, 15); strncpy(nomoccident[1], prenom1, 15); } } char *nom(void) { if (oriental) return nomorient[0]; else return nomoccident[0]; } char *prenom1(void) { if (oriental) return nomorient[1]; else return nomoccident[1]; } char *prenom2(void) { if (oriental) return nomorient[2]; else return ""; } char *prenomcomplet(void) { static char tampon[22]; if (oriental) { strcpy(tampon, nomorient[1]); strcat(tampon, "-"); strcat(tampon, nomorient[2]); return tampon; } else return nomoccident[1]; } };
Noter qu’il n’est pas nécessaire de préciser un nom de champ pour l’union, les noms des champs internes suffisent. Par contre, quand une union contient un champ de type structure, classe ou union, il faut lui donner un nom.
Le constructeur suppose que lorsque aucun second prénom n’est précisé, il s’agit d’un nom occidental. On peut donc écrire :
noms occ("Dupont", "Jean"); noms ori("Fang", "Li", "Zi");
Nous laissons au lecteur le soin d’écrire une fonction renvoyant le nom complet, avec la convention que le prénom vient en premier en occident, tandis qu’il vient en dernier en orient.
Exercice 6.8 :
Trouvez un moyen plus simple d’implanter une telle classe, sans utiliser d’union.
Il suffit d’utiliser une chaîne de caractères normale, plus un pointeur indiquant l’emplacement du début du prénom ; selon les besoins, on placera un zéro ou un blanc entre les deux. De même, un autre pointeur indiquera la séparation entre les deux prénoms orientaux. Ce second séparateur est à zéro pour un occidental :
class noms { char chaine[32]; char *separateur; // adresse du zéro ou blanc char *separateur2; // adresse du zéro ou tiret public : noms(char *nom, char *prenom1, char *prenom2 = 0) { int i = (prenom2 ? 10 : 15); strncpy(chaine, nom, i); separateur = strchr(chaine, 0); separateur2 = separateur+1; strncpy(separateur2, prenom1, i); if (prenom2) { separateur2 = strchr(separateur2, 0); strncpy(separateur2+1, prenom2, i); } else separateur2 = 0; } char *nom(void) { *separateur = 0; return chaine; } char *prenom1(void) { if (separateur2) *separateur2 = 0; return separateur+1; } char *prenom2(void) { if (separateur2) return separateur2+1; else return ""; } char *prenomcomplet(void) { if (separateur2) *separateur2 = '-'; return 1+separateur; } char *nomcomplet(void) { *separateur = ' '; prenomcomplet(); return chaine; } };
Cette classe n’occupe que cinq octets de plus que l’autre, et la facilité des opérations est un gain de temps important. On notera d’ailleurs que l’on n’est plus obligé de tronquer les chaînes : si un nom fait 20 caractères et le prénom 8, on peut les placer ensemble ; une meilleure implantation du constructeur est donc possible (nouvel exercice...).
Cet exemple illustre le fait que les unions sont nettement moins dangereuses lorsqu’elles sont membres de classes contenant un indicateur qui les contrôle.
Les unions peuvent avoir des méthodes et des constructeurs, mais tous leurs membres sont obligatoirement publics ; en outre, Turbo C++ n’accepte pas que les unions anonymes aient des méthodes.
Exercice 6.9 :
Sans écrire les méthodes, donner une implantation d’une classe pouvant contenir soit un nom et un prénom de 15 caractères chacun, soit un nom de 15 caractères, un prénom de 14 et une initiale intermédiaire de 1 (nom américain), soit un nom en trois parties à l’orientale.
Il faut recourir à une structure dans l’union :
class noms { char type; // 0 = occ, 1 = orient, 2 = américain. union { char nomoccident[2][15]; char nomorient[3][10]; struct { char nom[15]; char prenom[14]; char initiale; } nomamericain; }; public : // ... };
Observer que l’étiquette nomamericain est ici obligatoire, comme on l’a dit dans le texte. Il va sans dire que la gestion d’un tel ensemble nécessite quelques acrobaties étonnantes.
Donnons quand même un exemple de méthode, une qui écrit le nom complet à l’écran :
void noms::ecrire(void) { switch (type) { case 0: cout << nomoccident[1] << ' ' << nomoccident[0]; break; case 1: cout << nomorient[0] << ' ' << nomorient[1] << '-' << nomorient[2]; break; case 2: cout << nomamericain.prenom << ' ' << nomamericain.initiale << ". " << nomamericain.nom; } }
Sauf pour des décompositions comme longgroupe l’intérêt des unions est assez faible en C++, d’autant que, contrairement à ce qui se passe par exemple en Pascal, les parties qui se recouvrent dans une union sont nécessairement réduites à
un seul élément, et que l’union elle-même est tout entière en mode de recouvrement, ce qui oblige à utiliser des structures imbriquées compliquées pour faire recouvrir des données
différentes.
Nous avons vu dans le chapitre précédent ce qu’était une classe et les bases de sa manipulation. Un des principaux atouts du langage C++ résulte de son grand nombre d’opérateurs, mais aussi de sa capacité à redéfinir ces opérateurs, ce qui permet des écritures particulièrement simples et agréables. Pour cela, le mécanisme de fonctions et classes « amies » est pratiquement indispensable.
Nous avons vu qu’une classe avait généralement des membres privés, et que ceux-ci n’étaient pas accessibles par des fonctions non membres. Cette restriction peut sembler lourde, mais elle est à la base même de la protection des données qui fait une grande partie de la puissance de la programmation par objets en général, et de C++ en particulier.
Dans certains cas, cependant, on souhaite pouvoir utiliser une fonction qui puisse accéder aux membres d’une classe, sans toutefois nécessairement disposer d’une instance de cette classe par laquelle l’appeler.
Une première possibilité consiste à utiliser un membre statique. Si l’on écrit par exemple :
class exemple { // parties privées... public : static exemple* f(void); // ... };
il est possible d’appeler la fonction f sans passer par un membre, comme ceci :
exemple *p = exemple::f();
Cependant cette notation, si elle a l’avantage de la clarté, est assez lourde. C’est pourquoi le langage fournit les fonctions amies pour résoudre ce problème.
Une fonction est l’amie (friend) d’une classe lorsqu’elle est autorisée à adresser directement les membres privés de cette classe. Pour la déclarer ainsi, il faut donner, à
l’intérieur de la classe, la déclaration complète de la fonction précédée du mot clé friend. Voici un exemple simple :
class exemple { int i, j; public: exemple() { i = 0; j = 0; } friend exemple inverse(exemple); }; exemple inverse(exemple ex) // renvoie ex avec tous les bits inversés { exemple ex2 = ex; ex2.i = ~ex2.i; // accès aux champs ex2.j = ~ex2.j; return ex; }
À part le fait qu’elle est amie de la classe exemple, la fonction est parfaitement ordinaire, et peut être déclarée et définie de la même façon que toute autre.
Le terme même d’amie indique clairement que la fonction doit avoir un comportement décent : il faut veiller à ce qu’elle ne modifie pas incorrectement les membres de la classe.
Une fonction peut être amie d’autant de classes que nécessaire, mais évidemment cela n’est utile que lorsque la fonction utilise une instance de la classe, et plus précisément modifie un membre privé de la classe (car en général il existe des fonctions membres en ligne permettant de lire ces membres privés ou une interprétation de ceux-ci).
Notons que la « déclaration d’amitié » doit se faire à l’intérieur de la classe. De ce fait, si l’on dispose d’une classe mais sans avoir la possibilité de la modifier (par exemple, dans un fichier en-tête on peut ne trouver que la déclaration d’une classe sans sa définition), il n’est pas possible de lui ajouter des fonctions amies. Cela n’est pas une restriction du langage, mais au contraire un moyen sûr et efficace de protéger des données. De ce fait, avant de « verrouiller » une classe, on prendra soin de fournir tous les moyens d’accès raisonnables (en lecture notamment) aux champs utiles, afin de permettre la création de fonctions non amies utilisant cette classe.
Lorsque cette précaution a été prise, il n’est plus besoin d’une fonction amie, en dépit des apparences. Par exemple, la librairie <complex.h> fournit une classe
complex (qui est fondamentalement formée de deux nombres à virgule flottante nommés partie réelle et partie imaginaire) et un ensemble de fonctions la
manipulant ; cependant, les concepteurs de la librairie n’ont pas implanté une opération importante sur les nombres complexes, nommée conjugaison, qui consiste simplement à changer
le signe de la partie imaginaire. Est-ce à dire qu’il faut modifier <complex.h> pour déclarer « amie » la fonction ayant cet effet ? Nullement, car on dispose de
deux fonctions amies real et imag donnant les parties réelle et imaginaire d’un complexe, ainsi que du constructeur complex(double, double) qui crée un
complexe à partir de ses deux parties. De ce fait, il suffit d’écrire une fonction normale :
inline complexe conjug(complexe c) { return complexe(real(c), -imag(c)); }
Cette fonction n’est pas amie de la classe complex, mais elle n’accède qu’à des parties publiques de celle-ci (le constructeur et les deux fonctions amies real et
imag), il n’y a donc pas de problème. On pourrait bien sûr s’inquiéter : les trois appels de fonction (real, imag et complex) ne vont-ils
pas grever le temps d’exécution de cette opération pourtant élémentaire ? Nullement, car ces trois fonctions très simples aussi sont écrites en ligne. De ce fait, l’écriture c1 =
conjug(c2) ; ne provoquera aucun appel de fonction, puisque conjug est aussi en ligne.
On souhaite parfois qu’une méthode d’une classe puisse accéder aux parties privées d’une autre classe. Pour cela, il suffit de déclarer la méthode friend également, en utilisant son
nom complet (nom de classe suivi de :: et du nom de la méthode). Par exemple :
class autre { // ... void combine(exemple); }; class exemple { // ...parties privées public : friend void autre::combine(exemple); }; void autre::combine(exemple ex) { // utilise les membres privés de ex }
La fonction combine, qui fait une modification quelconque de l’instance de autre qui l’appelle, à l’aide des données contenues dans une instance de exemple,
a libre accès aux parties privées des deux classes.
On aurait pu aussi écrire une fonction amie des deux classes :
class exemple { // ...parties privées public : friend void combine(autre&, exemple); }; class autre { // ... friend void combine(autre&, exemple); }; void combine(autre& au, exemple ex) { // accède aux membres des deux arguments }
mais la syntaxe d’appel est alors différente : combine(au,ex) contre au.combine(ex).
Lorsqu’on souhaite que tous les membres d’une classe puissent accéder aux parties privées d’une autre classe, on peut déclarer « amie » une classe entière :
class autre; // déclaration class exemple { // parties privées... public : friend autre; // ... }; class autre { // ... };
Les membres de la classe autre peuvent tous modifier les parties privées des instances de exemple. Noter la déclaration de autre avant celle de
exemple, obligatoire (sinon on obtient Error : Undefined symbol 'autre', symbole 'autre' non défini). Pour l’éviter, on peut éventuellement changer l’ordre
de définition, mais il suffit en fait de préciser le sélecteur class derrière friend :
class exemple { // parties privées... public : friend class autre; // ... }; class autre { // ... };
Cette écriture, comme la précédente avec une déclaration, est inutilisable pour des méthodes isolées. De ce fait, si l’on souhaite qu’une méthode de autre soit amie de
exemple et une de exemple amie de autre, il faut déclarer les deux classes entièrement amies l’une de l’autre.
Lorsqu’on crée une nouvelle classe, il se peut que certaines actions correspondent intuitivement à un concept d’opération.
Imaginons par exemple une classe fraction qui gère des nombres fractionnaires non sous leur forme à virgule flottante, mais sous leur forme plus mathématique de quotient de deux
nombres entiers :
class fraction { long num, den; // numérateur, dénominateur public : fraction(long numer, long denom = 1) { num = numer; den = denom; } };
Un élément de la classe « représente » donc la valeur
, mathématiquement parlant. De ce fait, il est naturel de définir par exemple une addition sur de tels nombres :
inline fraction somme(fraction f1, fraction f2) { return fraction(f1.num*f2.den + f1.den*f2.num, f2.den*f1.den); }
On a bien sûr utilisé la formule :
De plus, la fonction somme est supposée avoir été déclarée amie de la classe, puisqu’elle en utilise les membres.
Lorsqu’on utilise ces fractions, il faut alors écrire :
fraction f1(2, 5), f2 = 4; // f1 = 2/5, f2 = 4/1 f3 = somme(f1, f2); // f3 = 4 +2/5 = 22/5 f3 = somme(f3, -6); // f3 = 22/5 -6 = -8/5
ce qui n’est pas extrêmement pratique. Il paraît relativement naturel d’écrire plutôt :
fraction f1(2, 5), f2 = 4; // f1 = 2/5, f2 = 4/1 f3 = f1 + f2 - 6; // f3 = 4 +2/5 -6 = -8/5
C’est ce que permet la redéfinition d’opérateurs.
Nous allons définir les quatre opérations de base pour la classe fraction. Pour cela, il suffit de nommer operator+, operator-, etc., les fonctions
opératoires :
class fraction { long num, den; // numérateur, dénominateur public : fraction(long numer, long denom = 1) { num = numer; den = denom; } friend fraction operator+(fraction, fraction); friend fraction operator-(fraction, fraction); friend fraction operator*(fraction, fraction); friend fraction operator/(fraction, fraction); }; inline fraction operator+(fraction f1, fraction f2) { return fraction(f1.num*f2.den + f1.den*f2.num, f2.den*f1.den); } inline fraction operator-(fraction f1, fraction f2) { return fraction(f1.num*f2.den - f1.den*f2.num, f2.den*f1.den); } inline fraction operator*(fraction f1, fraction f2) { return fraction(f1.num*f2.num, f2.den*f1.den); } inline fraction operator/(fraction f1, fraction f2) { return fraction(f1.num*f2.den, f2.num*f1.den); }
On peut alors écrire :
fraction f = 1 + 2/fraction(5) - fraction(1,3)*8;
La précédence des opérateurs reste la même (voir tableau en annexe), si bien que f vaut 1 +2/5
-((1/3)*8), soit -4/15. Noter qu’on peut écrire 2/fraction(5), ou fraction(2)/5, ou fraction(2)/fraction(5), ou encore fraction(2,
5) (qui cependant a un sens différent car il n’y a pas d’opération exécutée dans ce cas), mais il ne faut pas écrire 2/5 qui donnerait une division entière normale (soit
0 ici).
Il reste possible d’employer le nom complet des opérateurs, comme ceci :
fraction f = operator-(operator+(1, operator/(2, fraction(5)), operator*(fraction(1,3), 8));
ce qui donne le même résultat mais est évidemment peu rentable. Cela indique toutefois clairement dans quel ordre les opérations sont exécutées.
Exercice 7.1 :
Combien de fonctions sont-elles appelées dans l’expression précédente ? Et quelle est la place mémoire occupée au total ?
Aucune fonction n’est appelée, puisque les opérateurs et le constructeur sont écrits en ligne. Le compilateur développe donc l’expression sous la forme :
fraction f; f.num = (1*(1*5)+(2*1)*1)*(3*1)-(1*8)*((1*5)*1); f.den = (3*1)*((1*5)*1);
à vous de le vérifier... Quant à la place mémoire occupée, c’est celle de f, soit huit octets. Si les fonctions n’étaient pas écrites en ligne, il y aurait treize appels de
fonctions, dont neuf appels du constructeur, et la place mémoire occupée serait (transitoirement) égale à 8*9 octets, sans compter ceux occupés par f ; cependant cette place
serait restituée à la fin du calcul par neuf appels du destructeur standard, correspondant aux neuf appels automatiques du constructeur.
Les opérateurs redéfinis peuvent aussi être écrits comme des fonctions membres :
class fraction { // ... fraction operator+(fraction f) { return fraction(num*f.den + den*f.num, den*f.den); }
Nous verrons en fin de chapitre comment choisir l’une ou l’autre déclaration.
Exercice 7.2 :
Que se passe-t-il si l’on additionne 1/4 à lui-même ? Obtient-on le même résultat qu’en multipliant par 2 ? Comment régler le problème ?
On obtient 8/16 et ajoutant 1/4 à lui-même, contre 2/4 en multipliant par 2. Dans les deux cas, le résultat est égal à 1/2, mais cela peut poser des problèmes par la suite, car les nombres ont des numérateurs et dénominateurs qui augmentent très vite.
Pour régler le problème, il faut simplifier les fractions. Il faut pour cela écrire une fonction calculant le PGCD (Plus Grand Commun Diviseur) de deux nombres, en utilisant l’algorithme d’Euclide. Voici une solution :
int pgcd(int a, int b) { if ( (a == 0) || (b == 0) ) return a+b; int c; do { c = a%b; a = b; b = c; } while (c); return a; } class fraction { int num; int den; fraction& reduire() { int d = pgcd(num, den); num /= d; den/= d; return *this; } public : // .... }
Il suffit alors d’appeler la fonction membre privée reduire à la fin de chaque opération pour simplifier les fractions. Noter que lorsque l’un des arguments de la fonction
pgcd est nul, la fonction renvoie l’autre, de telle sorte que la fraction sera réduite en 0/1 (soit 0) ou 1/0 (infini, qui est en fait une valeur erronée).
Les opérateurs unaires peuvent également être redéfinis. On peut par exemple légitimement redéfinir le moins unaire :
inline fraction operator-(fraction f) { return fraction( -f.num, f.den); }
On note que, malgré l’identité des noms, le compilateur accepte cette fonction en même temps que le moins binaire : c’est un autre exemple de recouvrement de fonction.
Tous les opérateurs sont redéfinissables, sauf ?: (qui est le seul opérateur ternaire de C++), sizeof, et ceux directement liés aux classes, à savoir le point
(.), ainsi que les pointeurs sur membres (.*) et les opérateurs de résolution de portée (:: et ::*).
On ne peut pas créer de nouveaux opérateurs ayant un nom ne figurant pas dans la liste donnée en annexe, comme par exemple
** ou :=. De plus, il n’est pas possible de changer l’ « arité » d’un opérateur, c’est-à-dire son caractère binaire ou unaire. Enfin on ne peut pas modifier
leur précédence, qui reste toujours celle indiquée dans le tableau en annexe.
Il en résulte que, lorsque le nom d’un opérateur que l’on souhaite définir n’est pas clairement imposé par le contexte, il convient de réfléchir soigneusement à celui que l’on choisira, notamment
en fonction de la précédence souhaitée. Ainsi, on pourrait imaginer, sur une classe numérique comme fraction, d’utiliser l’opérateur ^ pour symboliser l’exponentiation (
« x à la puissance y » ), comme c’est le cas dans certains langages de programmation. Ce choix est bien entendu possible, mais pas très heureux, car la
précédence de cet opérateur est assez faible. De ce fait, une expression comme a + b^c sera interprétée comme (a+b)^c, ce qui n’est pas très naturel. On préférera dans
ce cas définir une méthode, nommée par exemple pow (abréviation de l’anglais power, puissance), et écrire a + b.pow(c) qui ne prête pas à erreur.
En dehors des deux règles énoncées ci-dessus, il n’y a aucune restriction pratique sur la redéfinition d’opérateurs. En particulier, le compilateur ne fait aucune hypothèse fonctionnelle à leur
sujet ; il ne suppose jamais qu’ils sont symétriques par exemple. Si dans un contexte naturel, a + b est égal à b + a, il n’en est pas forcément ainsi pour un
opérateur redéfini, et le compilateur ne le supposera donc pas : la première expression correspond à operator+(a, b), la seconde à operator+(b, a). Cela peut
sembler anecdotique, mais est très important en pratique, pour des objets comme les matrices, dont la multiplication n’est pas commutative.
Rappelons qu’en vertu des règles de recouvrement de fonctions, il peut exister plusieurs versions différentes d’un même opérateur si elles s’appliquent à des opérandes différents. Par exemple, on
pourrait définir un opérateur operator+(fraction, long) si l’on connaissait un moyen nettement plus rapide d’additionner une fraction et un entier que deux fractions (ce qui n’est
guère le cas). Dans ce cas, il faudrait aussi définir operator+(long, fraction) afin que le gain soit obtenu quel que soit l’ordre d’écriture des termes.
On ne peut redéfinir les opérateurs que pour les types structures, classes ou unions. Les autres en effet sont prédéclarés, avec des opérateurs fixés une fois pour toutes.
En particulier, on ne peut pas redéfinir les opérateurs pour les pointeurs ou les tableaux. Si l’on souhaite un type fonctionnellement équivalent en redéfinissant certains opérateurs, il faut créer une classe. Voici un exemple imaginable :
class intptr { int *p; public : .... friend int operator*(exempleptr); } int operator*(exempleptr ep) // déréférencement spécial { // fait quelque chose de particulier ici }
Toutefois ce genre d’écriture est difficile et risqué, notamment à cause des problèmes de post- et pré-incrémentation (voir plus loin). Il est beaucoup plus fréquent de créer des classes
équivalant à des tableaux (voir paragraphe sur l’opérateur []).
Tous les opérateurs sur les pointeurs sont redéfinissables (pour des classes), même -> et ->*. Notons toutefois que -> doit
obligatoirement renvoyer un pointeur ou une classe.
Le changement de type est un opérateur (en fait une noria d’opérateurs, puisqu’il y en a autant que le nombre de types). Pour un type donné, il peut s’écrire de deux façons différentes lorsqu’on
l’utilise soit sous la forme opératoire (type) x, soit sous la forme fonctionnelle type(x). Dans tous les cas, c’est un opérateur unaire, de nom operator
type() (mais pas operator(type)() qui provoquerait une erreur). La syntaxe est un peu spéciale, en ce sens qu’aucun type résultat n’est à déclarer (c’est en fait
type), c’est-à-dire qu’on n’écrit pas type operator type() mais directement operator type() dans la classe (ce doit être une méthode
obligatoirement).
Voici par exemple une définition de changement de type de fraction vers double tout à fait naturelle :
class fraction { // ... comme ci-avant operator double() { return num/ double(den); } };
On peut alors écrire :
fraction f(3,17); // donne 3/17 double d = double(f); // ou encore d = (double)f;
Notons que la définition d’un opérateur inverse, de fraction vers double, est plus problématique, car les fractions ne sont pas généralement représentables exactement
dans un nombre à virgule flottante. La conversion inverse exige donc une définition d’une notion de précision.
Les opérateurs de conversion ne peuvent avoir pour arguments que des classes nouvellement définies, comme on l’a dit au paragraphe précédent. En conséquence, on ne peut pas créer un opérateur
operator fraction(long) par exemple.
Nous connaissons cependant déjà la solution à ce problème : il suffit d’écrire un constructeur fraction::fraction(long) dont l’effet sera strictement identique. C’est d’ailleurs
ce que nous avons fait précédemment.
Les opérateurs d’incrémentation ++ et de décrémentation -- peuvent être redéfinis comme les autres. Ils posent toutefois un problème particulier car on ne peut pas
distinguer leur application en préfixe et en suffixe. Par exemple, si l’on a écrit :
fraction operator++(fraction& f) { f.num += f.den; return f; } // ...... fraction f = 5, g = f++/7;
la valeur de g sera 6/7 et non 5/7 comme attendu. En effet, la façon dont on a écrit l’opérateur, dont l’argument est d’abord augmenté puis retourné, signifie qu’il agit comme un
pré-incrément. Le langage permet son utilisation sous les deux formes ++f ou f++, mais pas la définition de deux opérateurs d’incrémentation, un de pré-incrément,
l’autre de post-incrément.
Exercice 7.3 :
Comment écrire operator++(fraction) si l’on souhaite obtenir l’effet d’un post-incrément ?
Il faut utiliser un objet intermédiaire :
fraction operator++(fraction& f) { fraction g = f; f.num += f.den; return g; }
Pour cette raison, il est préférable de ne pas redéfinir ces opérateurs, sauf en leur donnant un sens tout à fait différent de l’incrémentation, afin d’éviter toute erreur.
[] et ()
:
Les crochets sont un opérateur binaire : l’un des arguments est la variable qui précède les crochets, l’autre celle qui se trouve entre eux. Cet opérateur est redéfinissable, ce qui permet
des écritures « d’imitation de tableaux » . Par exemple, avec le type liste vu au chapitre
précédent, on peut écrire :
class liste { noeud* courant; int nombre; public : // ... autres méthodes ... friend element operator[](liste& l, int i); }; element operator[](liste& l, int i = 0); // donne le i-ième élément de la liste après courant { if (!courant) return 0; noeud *anccourant = l.courant; l.avance(i); element e = courant->contenu; courant = anccourant; return e; }
Noter la valeur par défaut pour le second argument. On peut donc écrire :
liste l; // ..... element e1 = l[5], e2 = l[];
et e2 est alors la valeur courante dans la liste. Cette notation plus agréable et naturelle permet de se débarrasser de la fonction membre valeur.
Exercice 7.4 :
Écrire le même opérateur avec l’autre genre de liste (celle qui est en fait un tableau). Aurait-on pu écrire cet opérateur si l’on ne pouvait pas modifier la définition de la classe
liste pour y insérer la déclaration friend ?
Il suffit d’écrire :
class liste { element *tab, *courant; int nombre; public : // ... autres méthodes ... friend element operator [](liste& l, int i); }; element operator[](liste& l, int i = 0); // donne le i-ième élément de la liste après courant { if (!courant) return 0; element *anccourant = l.courant; l.avance(i); element e = courant->contenu; courant = anccourant; return e; }
Si l’on n’avait pas pu accéder à la déclaration des classes, il aurait fallu utiliser la méthode valeur devenue indispensable pour accéder au contenu de la liste :
element operator[](liste& l, int i = 0); // donne le i-ième élément de la liste après courant { l.avance(i); element e = l.valeur(); l.recule(i); return e; }
Cet opérateur est correct avec les deux versions de la liste, puisque celles-ci ont le même comportement extérieur, comme on l’a indiqué au chapitre 6.
L’appel de fonction, qui comme on le sait se note par des parenthèses (), est un opérateur assez semblable à [], qui peut être redéfini (pas pour les fonctions, mais pour les classes). Il a un avantage déterminant sur les autres, et notamment
sur [], c’est qu’on peut placer un nombre quelconque d’arguments entre
les parenthèses : il s’agit donc en fait d’un opérateur « N-aire » pour toute valeur de N. Cela permet de l’utiliser pour des tableaux multidimensionnels par
exemple.
Ainsi, si l’on définit une classe matrice, on peut écrire ceci :
class matrice { double *tab; // liste des éléments à la suite unsigned lgn, col; // nb de lignes et colonnes publi c: // ... double operator()(int i, int j) { if ( (i > lgn) || (i < 1) || (j > col) || (j < 1) ) return 0; else return *(tab + (--i)*col + --j); } };
Nous avons ici écrit l’opérateur comme un membre, mais on aurait pu écrire une fonction amie.
Cet opérateur étant défini, il suffira donc d’écrire :
matrice M; // ... double d = M(1,5);
pour avoir le cinquième élément de la première ligne. Cette notation est plus agréable que M[1][5] qui de plus aurait nécessité une double redéfinition d’opérateur.
Noter que les deux opérateurs que nous avons définis dans ce paragraphe, et qui agissent sur des classes ayant une allure de tableau, donnent à celles-ci une grande qualité que les tableaux usuels n’ont pas : ils vérifient leurs arguments afin d’éviter des débordements des limites des tableaux. Dans nos exemples, les fonctions renvoient zéro lorsque les bornes sont dépassées, mais on pourrait aussi afficher un message d’erreur, lancer une exception, etc.
L’affectation =, et ses dérivées +=, *=, etc., sont des opérateurs binaires. L’affectation est prédéfinie pour toutes les classes, et représente alors une
copie terme à terme des membres de la classe.
Il est parfaitement possible de la redéfinir. C’est spécialement utile pour les classes utilisant des membres pointeurs. Par exemple, pour la classe matrice :
class matrice { double *tab; // liste des éléments à la suite unsigned lgn, col; // nb de lignes et colonnes public: // ... matrice& operator=(matrice& m) { lgn = m.lgn; col = m.col; tab = new double[lgn*col]; return *this; } };
En général, on retourne type& comme résultat, afin de permettre des écritures du type :
matrice m1, m2; m1 = m2 = m1 + m2;
Toutefois, on peut aussi déclarer un résultat void si l’on souhaite interdire de telles écritures.
Exercice 7.5 :
Dans les anciennes versions de C++, l’affectation prédéfinie était une copie en bloc d’un objet dans l’autre. Pouvez-vous donner un exemple où le comportement de la nouvelle version diffère de l’ancienne ? Quel comportement est préférable selon vous ?
Il suffit d’envisager une première classe exemple avec un opérateur d’affectation redéfini, puis une seconde classe autre, contenant un membre exemple ex.
Dans ce cas, dans l’ancienne version, une affectation au1 = au2 recopiait en bloc les deux objets, donc recopiait en bloc les membres ex. Dans la nouvelle version, cette
affectation provoquera, entre autres, un appel de exemple::operator=(au1.ex, au2.ex), ce qui provoquera une copie correcte des membres ex. Ce comportement est préférable
car, dans l’ancienne version, il fallait penser à redéfinir l’opérateur d’affectation pour toutes les classes contenant des membres pour lesquels cet opérateur avait été redéfini. Ce n’est plus
nécessaire désormais.
L’ancien comportement était plus rapide, mais source d’erreurs ; il posait aussi des problèmes avec l’héritage (voir chapitre 8).
l est très important de différencier les deux écritures suivantes :
matrice m1 = m2; m3 = m2;
Pour m3, le compilateur appelle l’opérateur d’affectation, mais pour m1, c’est le constructeur de copie (qui existe toujours, voir chapitre 6) qui est appelé. Cette séquence résulte donc (nonobstant le fait qu’on ne peut appeler explicitement un
constructeur) en :
matrice m1.matrice::matrice(m2); // constructeur m3.operator=(m2); // affectation
De ce fait, lorsqu’un opérateur d’affectation est défini, on écrira généralement le constructeur de copie ainsi :
inline matrice::matrice(matrice& m) // constructeur de copie { *this = m; }
sauf si l’on souhaite des effets particuliers et différents (à vos risques et périls).
Dans certains cas, on souhaite rendre impossible une affectation dans une classe, ou encore une copie par construction. Cependant il n’existe aucun moyen direct de « dédéfinir » l’affectation ou la construction par copie, qui sont toujours fournies par défaut lorsqu’on ne les redéfinit pas. Un moyen indirect consiste à déclarer les méthodes correspondantes, mais sans en donner de définition (aucune implantation). Dans ce cas, tout appel de l’une ou l’autre provoquera une erreur d’édition de liens ; c’est la méthode utilisée pour certaines classes de flots (voir chapitre 9). Une autre possiblité est de déclarer ces méthodes dans la partie privée d’une classe ; dans ce cas, le compilateur refusera les appels de copie, mais pas dans les fonctions amies ni les méthodes.
Les affectations-opérations comme += ne sont jamais prédéfinies pour une classe, et jamais interprétées comme des raccourcis d’écriture pour x = x + y.
En conséquence, on définira de même :
matrice& operator+=(matrice& m) { return *this = *this + m; }
Dans certains cas, il sera peut-être rentable de faire le contraire :
matrice& operator +=(matrice& m) { // ... faire une addition sur place } matrice& operator+(matrice m1, matrice& m2) { return m1 += m2; }
Noter que, comme dans ce dernier cas m1 a été passé par valeur et non par référence comme m2, un constructeur de copie est appelé au moment de l’addition, et c’est sur
cette copie que l’on ajoute le deuxième argument (voir la fin du chapitre).
| Mai 2012 | ||||||||||
| L | M | M | J | V | S | D | ||||
| 1 | 2 | 3 | 4 | 5 | 6 | |||||
| 7 | 8 | 9 | 10 | 11 | 12 | 13 | ||||
| 14 | 15 | 16 | 17 | 18 | 19 | 20 | ||||
| 21 | 22 | 23 | 24 | 25 | 26 | 27 | ||||
| 28 | 29 | 30 | 31 | |||||||
|
||||||||||