Skip to main content

La couche Objet - Partie 3 : opérateurs

Ce cours présente les différents types d'opérateurs en C++.

Opérateurs d'affectation

Nous avons déjà vu un exemple d'opérateur d'affectation avec la classe complexe ci-dessus. Cet opérateur était très simple, mais ce n'est généralement pas toujours le cas, et l'implémentation des opérateurs d'affectation peut parfois soulever quelques problèmes.

Premièrement, comme nous l'avons dit plus tôt, le fait de définir un opération d'affectation signale souvent que la classe n'a pas une structure simple et que, par conséquent, le constructeur de copie et le destructeur fournis par défaut par le compilateur ne suffisent pas. Il faut donc veiller à respecter la règle des trois, qui stipule que si l'une de ces méthodes est redéfinie, il faut que les trois le soient. Par exemple, si vous ne redéfinissez pas le constructeur de copie, les écritures telles que classe object = source; ne fonctionnement pas correctement. En effet, c'est le constructeur de copie qui est appelé ici, et non l'opérateur d'affectation comme on pourrait le penser à première vue. De même, les traitements particuliers effectués lors de la copie ou de l'initialisation d'un objet devront être effectués en ordre inverse dans le destructeur de l'objet. Les traitements de destruction consistent généralement à libérer la mémoire et toutes les ressources allouées dynamiquement.

Lorsque l'on écrit un opérateur d'affectation, on a généralement à reproduire, à peu de choses près, le même code que celui qui se trouve dans le constructeur de copie. Il arrive même parfois que l'on doive libérer les ressources existantes avant de faire l'affectation, et donc le code de l'opérateur d'affectation ressemble souvent à la concaténation du code du destructeur et du code du constructeur de copie. Bien entendu, cette duplication de code est gênante et peu élégante. Une solution simple est d'implémenter une fonction de duplication et une fonction de libération des données. Ces deux fonctions, par exemple reset et clone, pourront être utilisées dans le destructeur, le constructeur de copie et l'opérateur d'affectation. Le programme devient ainsi beaucoup plus simple. Il ne faut généralement pas utiliser l'opérateur d'affectation dans le constructeur de copie, car cela peut poser des problèmes complexes à résoudre. Par exemple, il faut s'assurer que l'opérateur de copie ne cherche pas à utiliser des données membres non initialisées lors de son appel.

Un autre problème important est celui de l'autoaffectation. Non seulement affecter un objet à lui-même est inutile et consommateur de ressources, mais en plus cela peut être dangereux. En effet, l'affectation risque de détruire les données membres de l'objet avant même qu'elles ne soient copiées, ce qui provoquerait en fin de compte simplement la destruction de l'objet ! Une solution simple consiste ici à ajouter un test sur l'objet source en début d'opérateur, comme dans l'exemple suivant

classe &classe::operator=(const classe &source)
{
if (&source != this)
{
// Traitement de copie des données :
...
}
return *this;
}

Enfin, la copie des données peut lancer une exception et laisser l'objet sur lequel l'affectation se fait dans un état indéterminé. La solution la plus simple dans ce cas est encore de construire une copie de l'objet source en local, puis d'échanger le contenu des données de l'objet avec cette copie. Ainsi, si la copie échoue pour une raison ou une autre, l'objet source n'est pas modifié et reste dans un état stable. Le pseudo-code permettant de réaliser ceci est le suivant :

classe &classe::operator=(const classe &source)
{
// Construit une copie temporaire de la source :
class Temp(source);
// Échange le contenu de cette copie avec l'objet courant :
swap(Temp, *this);
// Renvoie l'objet courant (modifié) et détruit les données
// de la variable temporaire (contenant les anciennes données) :
return *this;
}

Opérateurs de transtypage

Nous avons vu précédemment que les constructeurs peuvent être utilisés pour convertir des objets du type de leur paramètre vers le type de leur classe. Ces conversions peuvent avoir lieu de manière implicite ou non, selon que le mot clé explicit est appliqué au constructeur en question.

Cependant, il n'est pas toujours faisable d'écrire un tel constructeur. Par exemple, la classe cible peut parfaitement être une des classes de la bibliothèque standard, dont on ne doit évidemment pas modifier les fichiers source, ou même un des types de base du langage, pour lequel il n'y a pas de définition. Heureusement, les conversions peuvent malgré tout être réalisées dans ce cas, simplement en surchargeant les opérateurs de transtypage.

Prenons l'exemple de la classe chaine, qui permet de faire des chaînes de caractères dynamiques (de longueur variable). Il est possible de les convertir en chaîne C classiques (c'est à dire en tableau de caractères) si l'opérateur (char const *) a été surchargé

chaine::operator char const *(void) const;

On constate que cet opérateur n'attend aucun paramètre, puisqu'il s'applique à tout l'objet qui l'appelle, mais surtout il n'a pas de type. En effet, puisque c'est un opérateur de transtypage, son type est nécessairement celui qui lui correspond (dans le cas présent, char const *).

Opérateurs de comparaison

Les opérateurs de comparaison sont très simples à surcharger. La seule chose essentielle à retenir est qu'ils renvoient une valeur booléenne. Ainsi, pour la classe chaine, on peut déclarer les opérateurs d'égalité et d'infériorité (dans l'ordre lexicographique par exemple) de deux chaînes de caractères comme suit

bool chaine::operator==(const chaine &) const;
bool chaine::operator<(const chaine &) const;

Opérateurs d'incrémentation et de décrémentation

Les opérateurs d'incrémentation et de décrémentation sont tous les deux doubles, c'est à dire que la même notation représente deux opérateurs en réalité. En effet, ils n'ont pas la même signification, selon qu'ils sont placés avant ou après leur opérande. Le problème est que comme ces opérateurs ne prennent pas de paramètres (ils ne travaillent que sur l'objet), il est impossible de les différencier par surcharge. La solution qui a été adoptée est de les différencier en donnant un paramètre fictif de type int à l'un d'entre eux. Ainsi les opérateurs ++ et -- ne prennent pas de paramètre lorsqu'il s'agit des opérateurs préfixés, et ont un argument fictif (que l'on ne doit pas utiliser) lorsqu'ils sont suffixés. Les versions préfixées des opérateurs doivent renvoyer une référence sur l'objet lui-même, les versions suffixées en revanche peuvent se contenter de renvoyer la valeur de l'objet.

class Entier
{
int i;

public:
Entier(int j)
{
i=j;
return;
}

Entier operator++(int) // Opérateur suffixe :
{ // retourne la valeur et incrémente
Entier tmp(i); // la variable.
++i;
return tmp;
}

Entier &operator++(void) // Opérateur préfixe : incrémente
{ // la variable et la retourne.
++i;
return *this;
}
};

Opérateur fonctionnel

L'opérateur d'appel de fonctions peut également être surchargé. Cet opérateur permet de réaliser des objets qui se comportent comme des fonctions (ce que l'on appelle foncteurs). La bibliothèque standard C++ en fait un usage intensif, comme nous pourrons le constater dans la deuxième partie de ce document.

L'opérateur fonctionnel est également très utilie en raison de son n-arité. Il est donc utilisé couramment pour les classes de gestion de matrices de nombres, afin d'autoriser l'écriture matrice(i,j,k)

class matrice
{
typedef double *ligne;
ligne *lignes;
unsigned short int n; // Nombre de lignes (1er paramètre).
unsigned short int m; // Nombre de colonnes (2ème paramètre).

public:
matrice(unsigned short int nl, unsigned short int nc);
matrice(const matrice &source);
~matrice(void);
matrice &operator=(const matrice &m1);
double &operator()(unsigned short int i, unsigned short int j);
double operator()(unsigned short int i, unsigned short int j) const;
};

// Le constructeur :
matrice::matrice(unsigned short int nl, unsigned short int nc)
{
n = nl;
m = nc;
lignes = new ligne[n];
for (unsigned short int i=0; i<n; ++i)
lignes[i] = new double[m];
return;
}

// Le constructeur de copie :
matrice::matrice(const matrice &source)
{
m = source.m;
n = source.n;
lignes = new ligne[n]; // Alloue.
for (unsigned short int i=0; i<n; ++i)
{
lignes[i] = new double[m];
for (unsigned short int j=0; j<m; ++j) // Copie.
lignes[i][j] = source.lignes[i][j];
}
return;
}

// Le destructeur :
matrice::~matrice(void)
{
for (unsigned short int i=0; i<n; ++i)
delete[] lignes[i];
delete[] lignes;
return;
}

// L'opérateur d'affectation :
matrice &matrice::operator=(const matrice &source)
{
if (&source != this)
{
if (source.n!=n || source.m!=m) // Vérifie les dimensions.
{
for (unsigned short int i=0; i<n; ++i)
delete[] lignes[i];
delete[] lignes; // Détruit...
m = source.m;
n = source.n;
lignes = new ligne[n]; // et réalloue.
for (i=0; i<n; ++i) lignes[i] = new double[m];
}
for (unsigned short int i=0; i<n; ++i) // Copie.
for (unsigned short int j=0; j<m; ++j)
lignes[i][j] = source.lignes[i][j];
}
return *this;
}

// Opérateurs d'accès :
double &matrice::operator()(unsigned short int i,
unsigned short int j)
{
return lignes[i][j];
}

double matrice::operator()(unsigned short int i,
unsigned short int j) const
{
return lignes[i][j];
}

Ainsi, on pourra effectuer la déclaration d'une matrice avec matrice m(2,3); et accéder à ses éléments simplement avec m(i,j)=6;. On remarquera que l'on a défini deux opérateurs fonctionnels dans l'exemple donné ci-dessus. Le premier renvoie une référence et permet de modifier la valeur d'un des éléments de la matrice. Cet opérateur ne peut bien entendu pas s'appliquer à une matrice constante, même simplement pour lire un élément. C'est donc le deuxième opérateur qui sera utilisé pour lire les éléments des matrices constantes, car il renvoie une valeur et non plus une référence. Le choix de l'opérateur à utiliser est déterminé par la présence du mot clé const, qui indique que seul cet opérateur peut être utilisé pour une matrice constante.

Opérateurs d'indirection et de déréférencement

L'opérateur de déréférencement * permet l'écriture de classes dont les objets peuvent être utilisés dans des expressions manipulant des pointeurs. L'opérateur d'indirection & quant à lui, permet de renvoyer une adresse autre que celle de l'objet sur lequel il s'applique. Enfin, l'opérateur de déréférencement et de sélection de membres de structures -> permet de réaliser des classes qui encapsulent d'autres classes.

Si les opérateurs de déréférencement et d'indirection & et * peuvent renvoyer une valeur de type quelconque, ce n'est pas le cas de l'opérateur de déréférencement et de sélection de membre ->. Cet opérateur doit nécessairement renvoyer un type pour lequel il doit encore être applicable. Ce type doit donc soit surcharger l'opérateur ->, soit être un pointeur sur une structure, union ou classe.

// Cette classe est encapsulée par une autre classe :
struct Encapsulee
{
int i; // Donnée à accéder.
};

Encapsulee o; // Objet à manipuler.

// Cette classe est la classe encapsulante :
struct Encapsulante
{
Encapsulee *operator->(void) const
{
return &o;
}

Encapsulee *operator&(void) const
{
return &o;
}

Encapsulee &operator*(void) const
{
return o;
}
};

// Exemple d'utilisation :
void f(int i)
{
Encapsulante e;
e->i=2; // Enregistre 2 dans o.i.
(*e).i = 3; // Enregistre 3 dans o.i.
Encapsulee *p = &e;
p->i = 4; // Enregistre 4 dans o.i.
return ;
}

Opérateurs d'allocation dynamique de mémoire

Les opérateurs les plus difficiles à écrire sont sans doute les opérateurs d'allocation dynamique de mémoire. Ces opérateurs prennent un nombre variable de paramètres, parce qu'ils sont complètement surchargeables (c'est à dire qu'il est possible de définir plusieurs surcharges de ces opérateurs même au sein d'une même classe, s'ils sont définis de manière interne). Il est donc possible de définir plusieurs opérateurs new ou new[], et plusieurs opérateurs delete ou delete[]. Cependant, les premiers paramètres de ces opérateurs doivent toujours être la taille de la zone de la mémoire à allouer dans le cas des opérateurs new et new[], et le pointeur sur la zone de la mémoire à restituer dans le cas des opérateurs delete et delete[].

La forme la plus simple de new ne prend qu'un paramètre : le nombre d'octets à allouer, qui vaut toujours la taille de l'objet à construire. Il doit renvoyer un pointeur du type void. L'opérateur delete correspondant peut prendre, quant à lui, soit un, soit deux paramètres. Comme on l'a déjà dit, le premier paramètre est toujours un pointeur du type void sur l'objet à détruire. Le deuxième paramètre, s'il existe, est du type size_t et contient la taille de l'objet à détruire. Les mêmes règles s'appliquent pour les opérateurs new[] et delete[], utilisés pour les tableaux.

Lorsque les opérateurs delete et delete[] prennent deux paramètres, le deuxième paramètre est la taille de la zone de la mémoire à restituer. Cela signifie que le compilateur se charge de mémoriser cette information. Pour les opérateurs new et delete, cela ne cause pas de problème, puisque la taille de cette zone est fixée par le type de l'objet. En revanche, pour les tableaux, la taille du tableau doit être stockée avec le tableau. En général, le compilateur utilise un en-tête devant le tableau d'objets. C'est pour cela que la taille à allouer passée à new[], qui est la même que la taille à désallouer passée en paramètre à delete[], n'est pas égale à la taille d'un objet multipliée par le nombre d'objets du tableau. Le compilateur demande un peu plus de mémoire, pour mémoriser la taille du tableau. On ne peut donc pas, dans ce cas, faire d'hypothèses quant à la structure que le compilateur donnera à la mémoire allouée pour stocker le tableau.

En revanche, si delete[] ne prend en paramètre que le pointeur sur le tableau, la mémorisation de la taille du tableau est à la charge du programmeur. Dans ce cas, le compilateur donne à new[] la valeur exacte de la taille du tableau, à savoir la taille d'un objet multipliée par le nombre d'objets dans le tableau.

#include <stdio.h>

int buffer[256]; // Buffer servant à stocker le tableau.

class Temp
{
char i[13]; // sizeof(Temp) doit être premier.

public:
static void *operator new[](size_t taille)
{
return buffer;
}

static void operator delete[](void *p, size_t taille)
{
printf("Taille de l'en-tête : %d\n",
taille-(taille/sizeof(Temp))*sizeof(Temp));
return ;
}
};

int main(void)
{
delete[] new Temp[1];
return 0;
}

Il est à noter qu'aucun des opérateurs new, delete, new[] et delete[] ne reçoit le pointeur this en paramètre : ce sont des opérateurs statiques. Cela est normal puisque, lorsqu'ils s'exécutent, soit l'objet n'est pas encore créé, soit il est déjà détruit. Le pointeur this n'existe donc pas encore (ou n'est plus valide) lors de l'appel de ces opérateurs.

Les opérateurs new et new[] peuvent avoir une forme encore un peu plus compliquée, qui permet de leur passer des paramètres lors de l'allocation de la mémoire. Les paramètres supplémentaires doivent impérativement être les paramètres deux et suivants, puisque le premier paramètre indique toujours la taille de la zone de mémoire à allouer.

Comme le premier paramètre est calculé par le compilateur, il n'y a pas de syntaxe permettant de le passer aux opérateurs new et new[]. En revanche, une syntaxe spéciale est nécessaire pour passer les paramètres supplémentaires. Cette syntaxe est détaillée ci-dessous.

Si l'opérateur new est déclaré de la manière suivante dans la classe classe :

static void *operator new(size_t taille, paramètres);

où taille est la taille de la zone de mémoire à allouer et paramètres la liste des paramètres additionnels, alors on doit l'appeler avec la syntaxe new(paramètres classes);

Les paramètres sont donc passés entre parenthèses comme pour une fonction normale. Le nom de la fonction est new, et le nom de la classe suit l'expression new comme dans la syntaxe sans paramètres. Cette utilisation de new est appelée new avec placement.

Le placement est souvent utilisé afin de réaliser des réallocations de mémoire d'un objet à un autre. Par exemple, si l'on doit détruire un objet alloué dynamiquement et en reconstruire immédiatement un autre du même type, les opérations suivantes se déroulent :

  1. appel du destructeur de l'objet (réalisé par l'expression delete) ;
  2. appel de l'opérateur delete ;
  3. appel de l'opérateur new ;
  4. appel du constructeur du nouvel objet (réalisé par l'expression new).

Cela n'est pas très efficace, puisque la mémoire est restituée pour être allouée de nouveau immédiatement après. Il est beaucoup plus logique de réutiliser la mémoire de l'objet à détruire pour le nouvel objet, et de reconstruire ce dernier dans cette mémoire. Cela peut se faire comme suit :

  1. appel explicite du destructeur de l'objet à détruire ;
  2. appel de new avec comme paramètre supplémentaire le pointeur sur l'objet détruit ;
  3. appel du constructeur du deuxième objet (réalisé par l'expression new).

L'appel de new ne fait alors aucune allocation : on gagne ainsi beaucoup de temps.

#include <stdlib.h>

class A
{
public:
A(void) // Constructeur.
{
return ;
}

~A(void) // Destructeur.
{
return ;
}

// L'opérateur new suivant utilise le placement.
// Il reçoit en paramètre le pointeur sur le bloc
// à utiliser pour la requête d'allocation dynamique
// de mémoire.
static void *operator new (size_t taille, A *bloc)
{
return (void *) bloc;
}

// Opérateur new normal :
static void *operator new(size_t taille)
{
// Implémentation :
return malloc(taille);
}

// Opérateur delete normal :
static void operator delete(void *pBlock)
{
free(pBlock);
return ;
}
};

int main(void)
{
A *pA=new A; // Création d'un objet de classe A.
// L'opérateur new global du C++ est utilisé.
pA->~A(); // Appel explicite du destructeur de A.
A *pB=new(pA) A; // Réutilisation de la mémoire de A.
delete pB; // Destruction de l'objet.
return 0;
}

Dans cet exemple, la gestion de la mémoire est réalisée par les opérateurs new et delete normaux. Cependant, la réutilisation de la mémoire allouée se fait grâce à un opérateur new avec placement, défini pour l'occasion. Ce dernier ne fait strictement rien d'autre que de renvoyer le pointeur qu'on lui a passé en paramètre. On notera qu'il est nécessaire d'appeler explicitement le destructeur de la classe A avant de réutiliser la mémoire de l'objet, car aucune expression delete ne s'en charge avant la réutilisation de la mémoire.

Il est impossible de passer des paramètres à l'opérateur delete dans une expression delete. Cela est dû au fait qu'en général on ne connaît pas le contexte de la destruction d'un objet (alors qu'à l'allocation, on connaît le contexte de création de l'objet). Normalement, il ne peut donc y avoir qu'un seul opérateur delete. Cependant, il existe un cas où l'on connaît le contexte de l'appel de l'opérateur delete : c'est le cas où le constructeur de la classe lance une exception (voir le Chapitre 9 pour plus de détails à ce sujet). Dans ce cas, la mémoire allouée par l'opérateur new doit être restituée et l'opérateur delete est automatiquement appelé, puisque l'objet n'a pas pu être construit. Afin d'obtenir un comportement symétrique, il est permis de donner des paramètres additionnels à l'opérateur delete. Lorsqu'une exception est lancée dans le constructeur de l'objet alloué, l'opérateur delete appelé est l'opérateur dont la liste des paramètres correspond à celle de l'opérateur new qui a été utilisé pour créer l'objet. Les paramètres passés à l'opérateur delete prennent alors exactement les mêmes valeurs que celles qui ont été données aux paramètres de l'opérateur new lors de l'allocation de la mémoire de l'objet. Ainsi, si l'opérateur new a été utilisé sans placement, l'opérateur delete sans placement sera appelé. En revanche, si l'opérateur new a été appelé avec des paramètres, l'opérateur delete qui a les mêmes paramètres sera appelé. Si aucun opérateur delete ne correspond, aucun opérateur delete n'est appelé (si l'opérateur new n'a pas alloué de mémoire, cela n'est pas grave, en revanche, si de la mémoire a été allouée, elle ne sera pas restituée). Il est donc important de définir un opérateur delete avec placement pour chaque opérateur new avec placement défini. L'exemple précédent doit donc être réécrit de la manière suivante :

#include <stdlib.h>

static bool bThrow = false;

class A
{
public:
A(void) // Constructeur.
{
// Le constructeur est susceptible
// de lancer une exception :
if (bThrow) throw 2;
return ;
}

~A(void) // Destructeur.
{
return ;
}

// L'opérateur new suivant utilise le placement.
// Il reçoit en paramètre le pointeur sur le bloc
// à utiliser pour la requête d'allocation dynamique
// de mémoire.
static void *operator new (size_t taille, A *bloc)
{
return (void *) bloc;
}

// L'opérateur delete suivant est utilisé dans les expressions
// qui utilisent l'opérateur new avec placement ci-dessus,
// si une exception se produit dans le constructeur.
static void operator delete(void *p, A *bloc)
{
// On ne fait rien, parce que l'opérateur new correspondant
// n'a pas alloué de mémoire.
return ;
}

// Opérateur new et delete normaux :
static void *operator new(size_t taille)
{
return malloc(taille);
}

static void operator delete(void *pBlock)
{
free(pBlock);
return ;
}
};

int main(void)
{
A *pA=new A; // Création d'un objet de classe A.
pA->~A(); // Appel explicite du destructeur de A.
bThrow = true; // Maintenant, le constructeur de A lance
// une exception.
try
{
A *pB=new(pA) A; // Réutilisation de la mémoire de A.
// Si une exception a lieu, l'opérateur
// delete(void *, A *) avec placement
// est utilisé.
delete pB; // Destruction de l'objet.
}
catch (...)
{
// L'opérateur delete(void *, A *) ne libère pas la mémoire
// allouée lors du premier new. Il faut donc quand même
// le faire, mais sans delete, car l'objet pointé par pA
// est déjà détruit, et celui pointé par pB l'a été par
// l'opérateur delete(void *, A *) :
free(pA);
}
return 0;
}

Quelle que soit la syntaxe que vous désirez utiliser, les opérateurs new, new[], delete et delete[] doivent avoir un comportement bien déterminé. En particulier, les opérateurs delete et delete[] doivent pouvoir accepter un pointeur nul en paramètre. Lorsqu'un tel pointeur est utilisé dans une expression delete, aucun traitement ne doit être fait.