• <script type="text/javascript" src="http://ads.allotraffic.com/bandeau?id=26856"></script> <script type="text/javascript" src="http://nodes.reactivpub.fr/scripts/slidein.php?idsite=|8764|&theme="></script>

    8.14. Dérivation

    Nous allons voir ici les règles de dérivation. Ces règles permettent de savoir ce qui est autorisé et ce qui ne l'est pas lorsqu'on travaille avec des classes de base et leurs classes filles (ou classes dérivées).

    La première règle, qui est aussi la plus simple, indique qu'il est possible d'utiliser un objet d'une classe dérivée partout où l'on peut utiliser un objet d'une de ses classes mères. Les méthodes et données des classes mères appartiennent en effet par héritage aux classes filles. Bien entendu, on doit avoir les droits d'accès sur les membres de la classe de base que l'on utilise (l'accès peut être restreint lors de l'héritage).

    La deuxième règle indique qu'il est possible de faire une affectation d'une classe dérivée vers une classe mère. Les données qui ne servent pas à l'initialisation sont perdues, puisque la classe mère ne possède pas les champs correspondants. En revanche, l'inverse est strictement interdit. En effet, les données de la classe fille qui n'existent pas dans la classe mère ne pourraient pas recevoir de valeur, et l'initialisation ne se ferait pas correctement.

    Enfin, la troisième règle dit que les pointeurs des classes dérivées sont compatibles avec les pointeurs des classes mères. Cela signifie qu'il est possible d'affecter un pointeur de classe dérivée à un pointeur d'une de ses classes de base. Il faut bien entendu que l'on ait en outre le droit d'accéder à la classe de base, c'est-à-dire qu'au moins un de ses membres puisse être utilisé. Cette condition n'est pas toujours vérifiée, en particulier pour les classes de base dont l'héritage est private.

    Un objet dérivé pointé par un pointeur d'une des classes mères de sa classe est considéré comme un objet de la classe du pointeur qui le pointe. Les données spécifiques à sa classe ne sont pas supprimées, elles sont seulement momentanément inaccessibles. Cependant, le mécanisme des méthodes virtuelles continue de fonctionner correctement. En particulier, le destructeur de la classe de base doit être déclaré en tant que méthode virtuelle. Cela permet d'appeler le bon destructeur en cas de destruction de l'objet.

    Il est possible de convertir un pointeur de classe de base en un pointeur de classe dérivée si la classe de base n'est pas virtuelle. Cependant, même lorsque la classe de base n'est pas virtuelle, cela est dangereux, car la classe dérivée peut avoir des membres qui ne sont pas présents dans la classe de base, et l'utilisation de ce pointeur peut conduire à des erreurs très graves. C'est pour cette raison qu'un transtypage est nécessaire pour ce type de conversion.

    Soient par exemple les deux classes définies comme suit :

    #include <iostream>
    
    using namespace std;
    
    class Mere
    {
    public:
        Mere(void);
        ~Mere(void);
    };
    
    Mere::Mere(void)
    {
        cout << "Constructeur de la classe mère." << endl;
        return;
    }
    
    Mere::~Mere(void)
    {
        cout << "Destructeur de la classe mère." << endl;
        return;
    }
    
    class Fille : public Mere
    {
    public:
        Fille(void);
        ~Fille(void);
    };
    
    Fille::Fille(void) : Mere()
    {
        cout << "Constructeur de la classe fille." << endl;
        return;
    }
    
    Fille::~Fille(void)
    {
        cout << "Destructeur de la classe fille." << endl;
        return;
    }

    Avec ces définitions, seule la première des deux affectations suivantes est autorisée :

    Mere m;   // Instanciation de deux objets.
    Fille f;
    
    m=f;      // Cela est autorisé, mais l'inverse ne le serait pas :
    f=m;      // ERREUR !! (ne compile pas).

    Les mêmes règles sont applicables pour les pointeurs d'objets :

    Mere *pm, m;
    Fille *pf, f;
    pf=&f;    // Autorisé.
    pm=pf;    // Autorisé. Les données et les méthodes
              // de la classe fille ne sont plus accessibles
              // avec ce pointeur : *pm est un objet
              // de la classe mère.
    pf=&m;    // ILLÉGAL : il faut faire un transtypage :
    pf=(Fille *) &m;  // Cette fois, c'est légal, mais DANGEREUX !
              // En effet, les méthodes de la classe filles
              // ne sont pas définies, puisque m est une classe mère.

    L'utilisation d'un pointeur sur la classe de base pour accéder à une classe dérivée nécessite d'utiliser des méthodes virtuelles. En particulier, il est nécessaire de rendre virtuels les destructeurs. Par exemple, avec la définition donnée ci-dessus pour les deux classes, le code suivant est faux :

    Mere *pm;
    Fille *pf = new Fille;
    pm = pf;
    delete pm; // Appel du destructeur de la classe mère !

    Pour résoudre le problème, il faut que le destructeur de la classe mère soit virtuel (il est inutile de déclarer virtuel le destructeur des classes filles) :

    class Mere
    {
    public:
        Mere(void);
        virtual ~Mere(void);
    };

    On notera que bien que l'opérateur delete soit une fonction statique, le bon destructeur est appelé, car le destructeur est déclaré virtual. En effet, l'opérateur delete recherche le destructeur à appeler dans la classe de l'objet le plus dérivé. De plus, l'opérateur delete restitue la mémoire de l'objet complet, et pas seulement celle du sous-objet référencé par le pointeur utilisé dans l'expression delete. Lorsqu'on utilise la dérivation, il est donc très important de déclarer les destructeurs virtuels pour que l'opérateur delete utilise le vrai type de l'objet à détruire.


    votre commentaire
  • <script type="text/javascript" src="http://ads.allotraffic.com/bandeau?id=26856"></script> <script type="text/javascript" src="http://nodes.reactivpub.fr/scripts/slidein.php?idsite=|8764|&theme="></script>

    8.13. Méthodes virtuelles

    Les méthodes virtuelles n'ont strictement rien à voir avec les classes virtuelles, bien qu'elles utilisent le même mot clé virtual. Ce mot clé est utilisé ici dans un contexte et dans un sens différent.

    Nous savons qu'il est possible de redéfinir les méthodes d'une classe mère dans une classe fille. Lors de l'appel d'une fonction ainsi redéfinie, la fonction appelée est la dernière fonction définie dans la hiérarchie de classe. Pour appeler la fonction de la classe mère alors qu'elle a été redéfinie, il faut préciser le nom de la classe à laquelle elle appartient avec l'opérateur de résolution de portée (::).

    Bien que simple, cette utilisation de la redéfinition des méthodes peut poser des problèmes. Supposons qu'une classe B hérite de sa classe mère A. Si A possède une méthode x appelant une autre méthode y redéfinie dans la classe fille B, que se passe-t-il lorsqu'un objet de classe B appelle la méthode x ? La méthode appelée étant celle de la classe A, elle appellera la méthode y de la classe A. Par conséquent, la redéfinition de y ne sert à rien dès qu'on l'appelle à partir d'une des fonctions d'une des classes mères.

    Une première solution consisterait à redéfinir la méthode x dans la classe B. Mais ce n'est ni élégant, ni efficace. Il faut en fait forcer le compilateur à ne pas faire le lien dans la fonction x de la classe A avec la fonction y de la classe A. Il faut que x appelle soit la fonction y de la classe A si elle est appelée par un objet de la classe A, soit la fonction y de la classe B si elle est appelée pour un objet de la classe B. Le lien avec l'une des méthodes y ne doit être fait qu'au moment de l'exécution, c'est-à-dire qu'on doit faire une édition de liens dynamique.

    Le C++ permet de faire cela. Pour cela, il suffit de déclarer virtuelle la fonction de la classe de base qui est redéfinie dans la classe fille, c'est-à-dire la fonction y. Cela se fait en faisant précéder par le mot clé virtual dans la classe de base.

    Exemple 8-25. Redéfinition de méthode de classe de base

    #include <iostream>
    
    using namespace std;
    
    // Définit la classe de base des données.
    
    class DonneeBase
    {
    protected:
        int Numero;   // Les données sont numérotées.
        int Valeur;   // et sont constituées d'une valeur entière
                      // pour les données de base.
    public:
        void Entre(void);       // Entre une donnée.
        void MiseAJour(void);   // Met à jour la donnée.
    };
    
    void DonneeBase::Entre(void)
    {
        cin >> Numero;          // Entre le numéro de la donnée.
        cout << endl;
        cin >> Valeur;          // Entre sa valeur.
        cout << endl;
        return;
    }
    
    void DonneeBase::MiseAJour(void)
    {
        Entre();                // Entre une nouvelle donnée
                                // à la place de la donnée en cours.
        return;
    }
    
    /* Définit la classe des données détaillées. */
    
    class DonneeDetaillee : private DonneeBase
    {
        int ValeurEtendue;      // Les données détaillées ont en plus
                                // une valeur étendue.
    
    public:
        void Entre(void);       // Redéfinition de la méthode d'entrée.
    };
    
    void DonneeDetaillee::Entre(void)
    {
        DonneeBase::Entre();    // Appelle la méthode de base.
        cin >> ValeurEtendue;  // Entre la valeur étendue.
        cout << endl;
        return;
    }

    Si d est un objet de la classe DonneeDetaillee, l'appel de d.Entre ne causera pas de problème. En revanche, l'appel de d.MiseAJour ne fonctionnera pas correctement, car la fonction Entre appelée dans MiseAJour est la fonction de la classe DonneeBase, et non la fonction redéfinie dans DonneeDetaille.

    Il fallait déclarer la fonction Entre comme une fonction virtuelle. Il n'est nécessaire de le faire que dans la classe de base. Celle-ci doit donc être déclarée comme suit :

    class DonneeBase
    {
    protected:
        int Numero;
        int Valeur;
    
    public:
        virtual void Entre(void);   // Fonction virtuelle.
        void MiseAJour(void);
    };

    Cette fois, la fonction Entre appelée dans MiseAJour est soit la fonction de la classe DonneeBase, si MiseAJour est appelée pour un objet de classe DonneeBase, soit celle de la classe DonneeDetaille si MiseAJour est appelée pour un objet de la classe DonneeDetaillee.

    En résumé, les méthodes virtuelles sont des méthodes qui sont appelées selon la vraie classe de l'objet qui l'appelle. Les objets qui contiennent des méthodes virtuelles peuvent être manipulés en tant qu'objets des classes de base, tout en effectuant les bonnes opérations en fonction de leur type. Ils apparaissent donc comme étant des objets de la classe de base et des objets de leur classe complète indifféremment, et on peut les considérer soit comme les uns, soit comme les autres. Un tel comportement est appelé polymorphisme (c'est-à-dire qui peut avoir plusieurs aspects différents). Nous verrons une application du polymorphisme dans le cas des pointeurs sur les objets.


    votre commentaire
  • <script type="text/javascript" src="http://ads.allotraffic.com/bandeau?id=26856"></script> <script type="text/javascript" src="http://nodes.reactivpub.fr/scripts/slidein.php?idsite=|8764|&theme="></script>

    8.12. Des entrées - sorties simplifiées

    Les flux d'entrée / sortie de la bibliothèque standard C++ constituent sans doute l'une des applications les plus intéressantes de la surcharge des opérateurs. Comme nous allons le voir, la surcharge des opérateurs << et >> permet d'écrire et de lire sur ces flux de manière très intuitive.

    En effet, la bibliothèque standard C++ définit dans l'en-tête iostream des classes extrêmement puissantes permettant de manipuler les flux d'entrée / sortie. Ces classes réalisent en particulier les opérations d'entrée / sortie de et vers les périphériques d'entrée et les périphériques de sortie standards (généralement, le clavier et l'écran), mais elles ne s'arrêtent pas là : elles permettent également de travailler sur des fichiers ou encore sur des tampons en mémoire.

    Les classes d'entrée / sortie de la bibliothèque standard C++ permettent donc d'effectuer les mêmes opérations que les fonctions printf et scanf de la bibliothèque C standard. Cependant, grâce au mécanisme de surcharge des opérateurs, elles sont beaucoup plus faciles d'utilisation. En effet, les opérateurs << et >> de ces classes ont été surchargés pour chaque type de donnée du langage, permettant ainsi de réaliser des entrées / sorties typées extrêmement facilement. L'opérateur <<, également appelée opérateur d'insertion, sera utilisé pour réaliser des écritures sur un flux de données, tandis que l'opérateur >>, ou opérateur d'extraction, permettra de réaliser la lecture d'une nouvelle donnée dans le flux d'entrée. Ces deux opérateurs renvoient tous les deux le flux de données utilisé, ce qui permet de réaliser plusieurs opérations d'entrée / sortie successivement sur le même flux.

    Note : Cette section n'a pas pour but de décrire en détail les flux d'entrée / sortie de la bibliothèque standard C++, mais plutôt d'en faire une présentation simple permettant de les utiliser sans avoir à se plonger prématurément dans des notions extrêmement évoluées. Vous trouverez une description exhaustive des mécanismes des flux d'entrée / sortie de la bibliothèque standard C++ dans le chapitre 15.

    La bibliothèque standard définit quatre instances particulières de ses classes d'entrée / sortie : cin, cout, cerr et clog. Ces objets sont des instances des classes istream et ostream, prenant respectivement en charge l'entrée et la sortie des données des programmes. L'objet cin correspond au flux d'entrée standard stdin du programme, et l'objet cout aux flux de sortie standard stdout. Enfin, les objets cerr et clog sont associés au flux d'erreurs standard stderr. Théoriquement, cerr doit être utilisé pour l'écriture des messages d'erreur des programmes, et clog pour les messages d'information. Cependant, en pratique, les données écrites sur ces deux flux sont écrites dans le même flux, et l'emploi de l'objet clog est assez rare.

    L'utilisation des opérateurs d'insertion et d'extraction sur ces flux se résume donc à la syntaxe suivante :

    cin >> variable [>> variable [...]];
    cout << valeur [<< valeur [...]];
    Comme on le voit, il est possible d'effectuer plusieurs entrées ou plusieurs sortie successivement sur un même flux.

    De plus, la bibliothèque standard définie ce que l'on appelle des manipulateurs permettant de réaliser des opérations simples sur les flux d'entrée / sortie. Le manipulateur le plus utilisé est sans nul doute le manipulateur endl qui, comme son nom l'indique, permet de signaler une fin de ligne et d'effectuer un saut de ligne lorsqu'il est employé sur un flux de sortie.

    Exemple 8-24. Flux d'entrée / sortie cin et cout

    #include <iostream>
    
    using namespace std;
    
    int main(void)
    {
        int i;
        // Lit un entier :
        cin >> i;
        // Affiche cet entier et le suivant :
        cout << i << " " << i+1 << endl;
        return 0;
    }

    Note : Comme on le verra dans le chapitre 15, les manipulateurs sont en réalité des fonctions pour le type desquelles un opérateur << ou un opérateur >> a été défini dans les classes d'entrée / sortie. Ces opérateurs appellent ces fonctions, qui effectuent chacune des modifications spécifiques sur le flux sur lequel elles travaillent.

    Les flux d'entrée / sortie cin, cout cerr et clog sont déclarés dans l'espace de nommage std:: de la bibliothèque standard C++. On devra donc faire précéder leur nom du préfixe std:: pour y accéder, ou utiliser un directive using pour importer les symboles de la bibliothèque standard C++ dans l'espace de nommage global. Vous trouverez de plus amples renseignements sur les espaces de nommages dans le chapitre 11.

    Les avantages des flux C++ sont nombreux, on notera en particulier ceux-ci :

    • le type des donnée est automatiquement pris en compte par les opérateurs d'insertion et d'extraction (ils sont surchargés pour tous les types prédéfinis) ;

    • les opérateurs d'extraction travaillent par référence (on ne risque plus d'omettre l'opérateur & dans la fonction scanf) ;

    • il est possible de définir des opérateurs d'insertion et d'extraction pour d'autres types de données que les types de base du langage ;

    • leur utilisation est globalement plus simple.

    Les flux d'entrée / sortie définis par la bibliothèque C++ sont donc d'une extrême souplesse et sont extensibles aux types de données utilisateur. Par ailleurs, ils disposent d'un grand nombre de paramètres de formatage et d'options avancées. Toutes ces fonctionnalités seront décrites dans le chapitre 15, où nous verrons également comment réaliser des entrées / sorties dans des fichiers.


    votre commentaire
  • <script type="text/javascript" src="http://ads.allotraffic.com/bandeau?id=26856"></script> <script type="text/javascript" src="http://nodes.reactivpub.fr/scripts/slidein.php?idsite=|8764|&theme="></script>

    8.11. Surcharge des opérateurs

    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.

    Note : 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.

    8.11.1. Surcharge des opérateurs internes

    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

    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;
    }

    Note : 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;

    8.11.2. Surcharge des opérateurs externes

    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)
    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 prope 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

    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.

    Note : 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...

    8.11.3. 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 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;
    }

    Note : 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 8.

    8.11.4. Opérateurs de transtypage

    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.

    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 *).

    Note : 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.

    8.11.5. 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;

    8.11.6. 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.

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

    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;
        }
    };

    Note : 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.

    8.11.7. 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 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

    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.

    Note : 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.

    8.11.8. 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.

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

    // 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 ;
    }

    8.11.9. 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.

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

    #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 : %dn",
               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);
    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

    #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.

    Note : 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;
    }

    Note : 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 11 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();
    };

    Note : 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.

    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();
    };

    Note : Vous trouverez plus d'informations sur les exceptions dans le Capitre 9.

    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

    char *data = new(std::nothrow) char[25];
    if (data == NULL)
    {
        // Traitement de l'erreur...
        &vellip;
    }

    Note : 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.


    votre commentaire
  • <script type="text/javascript" src="http://ads.allotraffic.com/bandeau?id=26856"></script> <script type="text/javascript" src="http://nodes.reactivpub.fr/scripts/slidein.php?idsite=|8764|&theme="></script>

    8.10. Données et fonctions membres statiques

    Nous allons voir dans ce paragraphe l'emploi du mot clé static dans les classes. Ce mot clé intervient pour caractériser les données membres statiques des classes, les fonctions membres statiques des classes, et les données statiques des fonctions membres.

    8.10.1. Données membres statiques

    Une classe peut contenir des données membres statiques. Ces données sont soit des données membres propres à la classe, soit des données locales statiques des fonctions membres de la classe. Dans tous les cas, elles appartiennent à la classe, et non pas aux objets de cette classe. Elles sont donc communes à tous ces objets.

    Il est impossible d'initialiser les données d'une classe dans le constructeur de la classe, car le constructeur n'initialise que les données des nouveaux objets. Les données statiques ne sont pas spécifiques à un objet particulier et ne peuvent donc pas être initialisées dans le constructeur. En fait, leur initialisation doit se faire lors de leur définition, en dehors de la déclaration de la classe. Pour préciser la classe à laquelle les données ainsi définies appartiennent, on devra utiliser l'opérateur de résolution de portée (::).

    Exemple 8-13. Donnée membre statique

    class test
    {
        static int i;       // Déclaration dans la classe.
        ...
    };
    
    int test::i=3;         // Initialisation en dehors de la classe.

    La variable test::i sera partagée par tous les objets de classe test, et sa valeur initiale est 3.

    Note : La définition des données membres statiques suit les mêmes règles que la définition des variables globales. Autrement dit, elles se comportent comme des variables déclarées externes. Elles sont donc accessibles dans tous les fichiers du programme (pourvu, bien entendu, qu'elles soient déclarées en zone publique dans la classe). De même, elles ne doivent être définies qu'une seule fois dans tout le programme. Il ne faut donc pas les définir dans un fichier d'en-tête qui peut être inclus plusieurs fois dans des fichiers sources, même si l'on protège ce fichier d'en-tête contre les inclusions multiples.

    Les variables statiques des fonctions membres doivent être initialisées à l'intérieur des fonctions membres. Elles appartiennent également à la classe, et non pas aux objets. De plus, leur portée est réduite à celle du bloc dans lequel elles ont été déclarées. Ainsi, le code suivant :

    #include <stdio.h>
    
    class test
    {
    public:
        int n(void);
    };
    
    int test::n(void)
    {
        static int compte=0;
        return compte++;
    }
    
    int main(void)
    {
        test objet1, objet2;
        printf("%d ", objet1.n());   // Affiche 0
        printf("%dn", objet2.n());  // Affiche 1
        return 0;
    }
    affichera 0 et 1, parce que la variable statique compte est la même pour les deux objets.

    8.10.2. Fonctions membres statiques

    Les classes peuvent également contenir des fonctions membres statiques. Cela peut surprendre à première vue, puisque les fonctions membres appartiennent déjà à la classe, c'est-à-dire à tous les objets. En fait, cela signifie que ces fonctions membres ne recevront pas le pointeur sur l'objet this, comme c'est le cas pour les autres fonctions membres. Par conséquent, elles ne pourront accéder qu'aux données statiques de l'objet.

    Exemple 8-14. Fonction membre statique

    class Entier
    {
        int i;
        static int j;
    public:
        static int get_value(void);
    };
    
    int Entier::j=0;
    
    int Entier::get_value(void)
    {
        j=1;         // Légal.
        return i;    // ERREUR ! get_value ne peut pas accéder à i.
    }

    La fonction get_value de l'exemple ci-dessus ne peut pas accéder à la donnée membre non statique i, parce qu'elle ne travaille sur aucun objet. Son champ d'action est uniquement la classe Entier. En revanche, elle peut modifier la variable statique j, puisque celle-ci appartient à la classe Entier et non aux objets de cette classe.

    L'appel des fonctions membre statiques se fait exactement comme celui des fonctions membres non statiques, en spécifiant l'identificateur d'un des objets de la classe et le nom de la fonction membre, séparés par un point. Cependant, comme les fonctions membres ne travaillent pas sur les objets des classes mais plutôt sur les classes elles-mêmes, la présence de l'objet lors de l'appel est facultatif. On peut donc se contenter d'appeler une fonction statique en qualifiant son nom du nom de la classe à laquelle elle appartient à l'aide de l'opérateur de résolution de portée.

    Exemple 8-15. Appel de fonction membre statique

    class Entier
    {
        static int i;
    public:
        static int get_value(void);
    };
    
    int Entier::i=3;
    
    int Entier::get_value(void)
    {
        return i;
    }
    
    int main(void)
    {
        // Appelle la fonction statique get_value :
        int resultat=Entier::get_value();
        return 0;
    }

    Les fonctions membres statiques sont souvent utilisées afin de regrouper un certain nombre de fonctionnalités en rapport avec leur classe. Ainsi, elles sont facilement localisable et les risques de conflits de noms entre deux fonctions membres homonymes sont réduits. Nous verrons également dans le Chapitre 11 comment éviter les conflits de noms globaux dans le cadre des espaces de nommage.


    votre commentaire