Aller au contenu

Programmation C-C++/C++ : La couche objet/Surcharge des opérateurs

Un livre de Wikilivres.
Cours de C/C++
^
C++ : La couche objet
Généralités
Extension de la notion de type du C
Déclaration de classes en C++
Encapsulation des données
Héritage
Classes virtuelles
Fonctions et classes amies
Constructeurs et destructeurs
Pointeur this
Données et fonctions membres statiques
Surcharge des opérateurs
Des entrées - sorties simplifiées
Méthodes virtuelles
Dérivation
Méthodes virtuelles pures - Classes abstraites
Pointeurs sur les membres d'une classe

Livre original de C. Casteyde

On a vu précédemment que les opérateurs ne se différencient des fonctions que syntaxiquement, pas logiquement. D'ailleurs, le compilateur traite un appel à un opérateur comme un appel à une fonction. Le C++ permet donc de surcharger les opérateurs pour les classes définies par l'utilisateur, en utilisant une syntaxe particulière calquée sur la syntaxe utilisée pour définir des fonctions membres normales. En fait, il est même possible de surcharger les opérateurs du langage pour les classes de l'utilisateur en dehors de la définition de ces classes. Le C++ dispose donc de deux méthodes différentes pour surcharger les opérateurs.

Les seuls opérateurs qui ne peuvent pas être surchargés sont les suivants :

::
.
.*
?:
sizeof
typeid
static_cast
dynamic_cast
const_cast
reinterpret_cast

Tous les autres opérateurs sont surchargeables. Leur surcharge ne pose généralement pas de problème et peut être réalisée soit dans la classe des objets sur lesquels ils s'appliquent, soit à l'extérieur de cette classe. Cependant, un certain nombre d'entre eux demandent des explications complémentaires, que l'on donnera à la fin de cette section.

 On prendra garde aux problèmes de performances lors de la surcharge des opérateurs. Si la facilité d'écriture des expressions utilisant des classes est grandement simplifiée grâce à la possibilité de surcharger les opérateurs pour ces classes, les performances du programme peuvent en être gravement affectées. En effet, l'utilisation inconsidérée des opérateurs peut conduire à un grand nombre de copies des objets, copies que l'on pourrait éviter en écrivant le programme classiquement. Par exemple, la plupart des opérateurs renvoient un objet du type de la classe sur laquelle ils travaillent. Ces objets sont souvent créés localement dans la fonction de l'opérateur (c'est-à-dire qu'ils sont de portée auto). Par conséquent, ces objets sont temporaires et sont détruits à la sortie de la fonction de l'opérateur. Cela impose donc au compilateur d'en faire une copie dans la valeur de retour de la fonction avant d'en sortir. Cette copie sera elle-même détruite par le compilateur une fois qu'elle aura été utilisée par l'instruction qui a appelé la fonction. Si le résultat doit être affecté à un objet de l'appelant, une deuxième copie inutile est réalisée par rapport au cas où l'opérateur aurait travaillé directement dans la variable résultat. Si les bons compilateurs sont capables d'éviter ces copies, cela reste l'exception et il vaut mieux être averti à l'avance plutôt que de devoir réécrire tout son programme a posteriori pour des problèmes de performances.

Nous allons à présent voir dans les sections suivantes les deux syntaxes permettant de surcharger les opérateurs pour les types de l'utilisateur, ainsi que les règles spécifiques à certains opérateurs particuliers.

Surcharge des opérateurs internes

[modifier | modifier le wikicode]

Une première méthode pour surcharger les opérateurs consiste à les considérer comme des méthodes normales de la classe sur laquelle ils s'appliquent. Le nom de ces méthodes est donné par le mot clé operator, suivi de l'opérateur à surcharger. Le type de la fonction de l'opérateur est le type du résultat donné par l'opération, et les paramètres, donnés entre parenthèses, sont les opérandes. Les opérateurs de ce type sont appelés opérateurs internes, parce qu'ils sont déclarés à l'intérieur de la classe.

Voici la syntaxe :

type operatorOp(paramètres)

l'écriture

A Op B

se traduisant par :

A.operatorOp(B)

Avec cette syntaxe, le premier opérande est toujours l'objet auquel cette fonction s'applique. Cette manière de surcharger les opérateurs est donc particulièrement bien adaptée pour les opérateurs qui modifient l'objet sur lequel ils travaillent, comme par exemple les opérateurs =, +=, ++, etc. Les paramètres de la fonction opérateur sont alors le deuxième opérande et les suivants.

Les opérateurs définis en interne devront souvent renvoyer l'objet sur lequel ils travaillent (ce n'est pas une nécessité cependant). Cela est faisable grâce au pointeur this.

Par exemple, la classe suivante implémente les nombres complexes avec quelques-unes de leurs opérations de base.

Exemple 8-16. Surcharge des opérateurs internes

[modifier | modifier le wikicode]
class complexe
{
    double m_x, m_y;  // Les parties réelles et imaginaires.
public:
    // Constructeurs et opérateur de copie :
    complexe(double x=0, double y=0);
    complexe(const complexe &);
    complexe &operator=(const complexe &);

    // Fonctions permettant de lire les parties réelles
    // et imaginaires :
    double re(void) const;
    double im(void) const;

    // Les opérateurs de base:
    complexe &operator+=(const complexe &);
    complexe &operator-=(const complexe &);
    complexe &operator*=(const complexe &);
    complexe &operator/=(const complexe &);
};

complexe::complexe(double x, double y)
{
    m_x = x;
    m_y = y;
    return ;
}

complexe::complexe(const complexe &source)
{
    m_x = source.m_x;
    m_y = source.m_y;
    return ;
}

complexe &complexe::operator=(const complexe &source)
{
    m_x = source.m_x;
    m_y = source.m_y;
    return *this;
}

double complexe::re() const
{
    return m_x;
}

double complexe::im() const
{
    return m_y;
}

complexe &complexe::operator+=(const complexe &c)
{
    m_x += c.m_x;
    m_y += c.m_y;
    return *this;
}

complexe &complexe::operator-=(const complexe &c)
{
    m_x -= c.m_x;
    m_y -= c.m_y;
    return *this;
}

complexe &complexe::operator*=(const complexe &c)
{
    double temp = m_x*c.m_x -m_y*c.m_y;
    m_y = m_x*c.m_y + m_y*c.m_x;
    m_x = temp;
    return *this;
}

complexe &complexe::operator/=(const complexe &c)
{
    double norm = c.m_x*c.m_x + c.m_y*c.m_y;
    double temp = (m_x*c.m_x + m_y*c.m_y) / norm;
    m_y = (-m_x*c.m_y + m_y*c.m_x) / norm;
    m_x = temp;
    return *this;
}
 La bibliothèque standard C++ fournit une classe traitant les nombres complexes de manière complète, la classe complex. Cette classe n'est donc donnée ici qu'à titre d'exemple et ne devra évidemment pas être utilisée. La définition des nombres complexes et de leur principales propriétés sera donnée dans la Section 14.3.1, où la classe complex sera décrite.

Les opérateurs d'affectation fournissent un exemple d'utilisation du pointeur this. Ces opérateurs renvoient en effet systématiquement l'objet sur lequel ils travaillent, afin de permettre des affectations multiples. Les opérateurs de ce type devront donc tous se terminer par :

return *this;

Surcharge des opérateurs externes

[modifier | modifier le wikicode]

Une deuxième possibilité nous est offerte par le langage pour surcharger les opérateurs. La définition de l'opérateur ne se fait plus dans la classe qui l'utilise, mais en dehors de celle-ci, par surcharge d'un opérateur de l'espace de nommage global. Il s'agit donc d'opérateurs externes cette fois.

La surcharge des opérateurs externes se fait donc exactement comme on surcharge les fonctions normales. Dans ce cas, tous les opérandes de l'opérateur devront être passés en paramètres : il n'y aura pas de paramètre implicite (le pointeur this n'est pas passé en paramètre).

La syntaxe est la suivante :

type operatorOp(opérandes)

où opérandes est la liste complète des opérandes.

L'avantage de cette syntaxe est que l'opérateur est réellement symétrique, contrairement à ce qui se passe pour les opérateurs définis à l'intérieur de la classe. Ainsi, si l'utilisation de cet opérateur nécessite un transtypage sur l'un des opérandes, il n'est pas nécessaire que cet opérande soit obligatoirement le deuxième. Donc si la classe dispose de constructeurs permettant de convertir un type de donnée en son propre type, ce type de donnée peut être utilisé avec tous les opérateurs de la classe.

Par exemple, les opérateurs d'addition, de soustraction, de multiplication et de division de la classe complexe peuvent être implémentés comme dans l'exemple suivant.

Exemple 8-17. Surcharge d'opérateurs externes

[modifier | modifier le wikicode]
class complexe
{
    friend complexe operator+(const complexe &, const complexe &);
    friend complexe operator-(const complexe &, const complexe &);
    friend complexe operator*(const complexe &, const complexe &);
    friend complexe operator/(const complexe &, const complexe &);

    double m_x, m_y;  // Les parties réelles et imaginaires.
public:
    // Constructeurs et opérateur de copie :
    complexe(double x=0, double y=0);
    complexe(const complexe &);
    complexe &operator=(const complexe &);

    // Fonctions permettant de lire les parties réelles
    // et imaginaires :
    double re(void) const;
    double im(void) const;

    // Les opérateurs de base:
    complexe &operator+=(const complexe &);
    complexe &operator-=(const complexe &);
    complexe &operator*=(const complexe &);
    complexe &operator/=(const complexe &);
};

// Les opérateurs de base ont été éludés ici :
...

complexe operator+(const complexe &c1, const complexe &c2)
{
    complexe result = c1;
    return result += c2;
}

complexe operator-(const complexe &c1, const complexe &c2)
{
    complexe result = c1;
    return result -= c2;
}

complexe operator*(const complexe &c1, const complexe &c2)
{
    complexe result = c1;
    return result *= c2;
}

complexe operator/(const complexe &c1, const complexe &c2)
{
    complexe result = c1;
    return result /= c2;
}

Avec ces définitions, il est parfaitement possible d'effectuer la multiplication d'un objet de type complexe avec une valeur de type double. En effet, cette valeur sera automatiquement convertie en complexe grâce au constructeur de la classe complexe, qui sera utilisé ici comme constructeur de transtypage. Une fois cette conversion effectuée, l'opérateur adéquat est appliqué.

On constatera que les opérateurs externes doivent être déclarés comme étant des fonctions amies de la classe sur laquelle ils travaillent, faute de quoi ils ne pourraient pas manipuler les données membres de leurs opérandes.

 Certains compilateurs peuvent supprimer la création des variables temporaires lorsque celles-ci sont utilisées en tant que valeur de retour des fonctions. Cela permet d'améliorer grandement l'efficacité des programmes, en supprimant toutes les copies d'objets inutiles. Cependant ces compilateurs sont relativement rares et peuvent exiger une syntaxe particulière pour effectuer cette optimisation. Généralement, les compilateurs C++ actuels suppriment la création de variable temporaire dans les retours de fonctions si la valeur de retour est construite dans l'instruction return elle-même. Par exemple, l'opérateur d'addition peut être optimisé ainsi :
complexe operator+(const complexe &c1, const complexe &c2)
{
    return complexe(c1.m_x + c2.m_x, c1.m_y + c2.m_y);
}

Cette écriture n'est cependant pas toujours utilisable, et l'optimisation n'est pas garantie.

La syntaxe des opérateurs externes permet également d'implémenter les opérateurs pour lesquels le type de la valeur de retour est celui de l'opérande de gauche et que le type de cet opérande n'est pas une classe définie par l'utilisateur (par exemple si c'est un type prédéfini). En effet, on ne peut pas définir l'opérateur à l'intérieur de la classe du premier opérande dans ce cas, puisque cette classe est déjà définie. De même, cette syntaxe peut être utile dans le cas de l'écriture d'opérateurs optimisés pour certains types de données, pour lesquels les opérations réalisées par l'opérateur sont plus simples que celles qui auraient été effectuées après transtypage.

Par exemple, si l'on veut optimiser la multiplication à gauche par un scalaire pour la classe complexe, on devra procéder comme suit :

complexe operator*(double k, const complexe &c)
{
    complexe result(c.re()*k,c.im()*k);
    return result;
}

ce qui permettra d'écrire des expressions du type :

complexe c1, c2;
double r;
...
c1 = r*c2;

La première syntaxe n'aurait permis d'écrire un tel opérateur que pour la multiplication à droite par un double. En effet, pour écrire un opérateur interne permettant de réaliser cette optimisation, il aurait fallu surcharger l'opérateur de multiplication de la classe double pour lui faire accepter un objet de type complexe en second opérande...

Opérateurs d'affectation

[modifier | modifier le wikicode]

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 dans la Section 8.8.2, le fait de définir un opérateur 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 fonctionneront 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;
}
 Le problème de l'état des objets n'est pas spécifique à l'opérateur d'affectation, mais à toutes les méthodes qui modifient l'objet, donc, en pratique, à toutes les méthodes non const. L'écriture de classes sûres au niveau de la gestion des erreurs est donc relativement difficile.

Vous trouverez de plus amples informations sur le mécanisme des exceptions en C++ dans le Chapitre 9.

À faire...link={{{link}}}

localiser

Opérateurs de transtypage

[modifier | modifier le wikicode]

Nous avons vu dans la Section 8.8.3 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.

À faire...link={{{link}}}

localiser

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 constatera que cet opérateur n'attend aucun paramètre, puisqu'il s'applique à 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 *).

 Si un constructeur de transtypage est également défini dans la classe du type cible de la conversion, il peut exister deux moyens de réaliser le transtypage. Dans ce cas, le compilateur choisira toujours le constructeur de transtypage de la classe cible à la place de l'opérateur de transtypage, sauf s'il est déclaré explicit. Ce mot clé peut donc être utilisé partout où l'on veut éviter que le compilateur n'utilise le constructeur de transtypage. Cependant, cette technique ne fonctionne qu'avec les conversions implicites réalisées par le compilateur. Si l'utilisateur effectue un transtypage explicite, ce sera à nouveau le constructeur qui sera appelé.

De plus, les conversions réalisées par l'intermédiaire d'un constructeur sont souvent plus performantes que celles réalisées par l'intermédiaire d'un opérateur de transtypage, en raison du fait que l'on évite ainsi la copie de la variable temporaire dans le retour de l'opérateur de transtypage. On évitera donc de définir les opérateurs de transtypage autant que faire se peut, et on écrira de préférence des constructeurs dans les classes des types cibles des conversions réalisées.

Opérateurs de comparaison

[modifier | modifier le wikicode]

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

[modifier | modifier le wikicode]

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.

Exemple 8-18. Opérateurs d'incrémentation et de décrémentation

[modifier | modifier le wikicode]
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;
    }
};
 Les opérateurs suffixés créant des objets temporaires, ils peuvent nuire gravement aux performances des programmes qui les utilisent de manière inconsidérée. Par conséquent, on ne les utilisera que lorsque cela est réellement nécessaire. En particulier, on évitera d'utiliser ces opérateurs dans toutes les opérations d'incrémentation des boucles d'itération.

Opérateur fonctionnel

[modifier | modifier le wikicode]

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 des 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 utile en raison de son n-arité (*, /, etc. sont des opérateurs binaires car ils ont deux opérandes, ?: est un opérateur ternaire car il a trois opérandes, () est n-aire car il peut avoir n opérandes). Il est donc utilisé couramment pour les classes de gestion de matrices de nombres, afin d'autoriser l'écriture « matrice(i,j,k) ».

Exemple 8-19. Implémentation d'une classe matrice

[modifier | modifier le wikicode]
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.

 Les opérations de base sur les matrices (addition, soustraction, inversion, transposition, etc.) n'ont pas été reportées ici par souci de clarté. La manière de définir ces opérateurs a été présentée dans les sections précédentes.

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

[modifier | modifier le wikicode]

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.

Exemple 8-20. Opérateur de déréférencement et d'indirection

[modifier | modifier le wikicode]
// 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

[modifier | modifier le wikicode]

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.

Exemple 8-21. Détermination de la taille de l'en-tête des tableaux

[modifier | modifier le wikicode]
#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 suivante :

new(paramètres) classe;

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.

Exemple 8-22. Opérateurs new avec placement

[modifier | modifier le wikicode]
#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.

 Les opérateurs new et delete avec placement prédéfinis par la bibliothèque standard C++ effectuent exactement ce que les opérateurs de cet exemple font. Il n'est donc pas nécessaire de les définir, si on ne fait aucun autre traitement que de réutiliser le bloc mémoire que l'opérateur new reçoit en paramètre.

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;
}
 Il est possible d'utiliser le placement avec les opérateurs new[] et delete[] exactement de la même manière qu'avec les opérateurs new et delete.

On notera que lorsque l'opérateur new est utilisé avec placement, si le deuxième argument est de type size_t, l'opérateur delete à deux arguments peut être interprété soit comme un opérateur delete classique sans placement mais avec deux paramètres, soit comme l'opérateur delete avec placement correspondant à l'opérateur new avec placement. Afin de résoudre cette ambiguïté, le compilateur interprète systématiquement l'opérateur delete avec un deuxième paramètre de type size_t comme étant l'opérateur à deux paramètres sans placement. Il est donc impossible de définir un opérateur delete avec placement s'il a deux paramètres, le deuxième étant de type size_t. Il en est de même avec les opérateurs new[] et delete[].

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.

Enfin, vos opérateurs new et new[] doivent, en cas de manque de mémoire, appeler un gestionnaire d'erreur. Le gestionnaire d'erreur fourni par défaut lance une exception de classe std::bad_alloc (voir le Chapitre 9 pour plus de détails sur les exceptions). Cette classe est définie comme suit dans le fichier d'en-tête new :

class bad_alloc : public exception
{
public:
    bad_alloc(void) throw();
    bad_alloc(const bad_alloc &) throw();
    bad_alloc &operator=(const bad_alloc &) throw();
    virtual ~bad_alloc(void) throw();
    virtual const char *what(void) const throw();
};
 Comme son nom l'indique, cette classe est définie dans l'espace de nommage std::. Si vous ne voulez pas utiliser les notions des espaces de nommage, vous devrez inclure le fichier d'en-tête new.h au lieu de new. Vous obtiendrez de plus amples renseignements sur les espaces de nommage dans le Chapitre 11.
À faire...link={{{link}}}

localiser

La classe exception dont bad_alloc hérite est déclarée comme suit dans le fichier d'en-tête exception :

class exception
{
public:
    exception (void) throw();
    exception(const exception &) throw();
    exception &operator=(const exception &) throw();
    virtual ~exception(void) throw();
    virtual const char *what(void) const throw();
};
 Vous trouverez plus d'informations sur les exceptions dans le Chapitre 9.
À faire...link={{{link}}}

localiser

Si vous désirez remplacer le gestionnaire par défaut, vous pouvez utiliser la fonction std::set_new_handler. Cette fonction attend en paramètre le pointeur sur le gestionnaire d'erreur à installer et renvoie le pointeur sur le gestionnaire d'erreur précédemment installé. Les gestionnaires d'erreurs ne prennent aucun paramètre et ne renvoient aucune valeur. Leur comportement doit être le suivant :

  • soit ils prennent les mesures nécessaires pour permettre l'allocation du bloc de mémoire demandé et rendent la main à l'opérateur new. Ce dernier refait alors une tentative pour allouer le bloc de mémoire. Si cette tentative échoue à nouveau, le gestionnaire d'erreur est rappelé. Cette boucle se poursuit jusqu'à ce que l'opération se déroule correctement ou qu'une exception std::bad_alloc soit lancée ;
  • soit ils lancent une exception de classe std::bad_alloc ;
  • soit ils terminent l'exécution du programme en cours.

La bibliothèque standard définit une version avec placement des opérateurs new et new[], qui renvoient le pointeur nul au lieu de lancer une exception en cas de manque de mémoire. Ces opérateurs prennent un deuxième paramètre, de type std::nothrow_t, qui doit être spécifié lors de l'appel. La bibliothèque standard définit un objet constant de ce type afin que les programmes puissent l'utiliser sans avoir à le définir eux-même. Cet objet se nomme std::nothrow

Exemple 8-23. Utilisation de new sans exception

[modifier | modifier le wikicode]
char *data = new(std::nothrow) char[25];
if (data == NULL)
{
    // Traitement de l'erreur...
    &vellip;
}
 La plupart des compilateurs ne respectent pas les règles dictées par la norme C++. En effet, ils préfèrent retourner la valeur nulle en cas de manque de mémoire au lieu de lancer une exception. On peut rendre ces implémentations compatibles avec la norme en installant un gestionnaire d'erreur qui lance lui-même l'exception std::bad_alloc.