Aller au contenu

ELCa11 - BE #3 — Surcharge d’opérateurs et classes génériques

Cours associé : Cours 3 — Surcharge d’opérateurs et classes génériques (voir le polycopié, section Cours 3).


Objectifs

  • Surcharger des opérateurs sur la classe DamierDyn (opérateurs +, +=, <<).
  • Comprendre la sémantique de retour des opérateurs (par valeur vs par référence).
  • Distinguer méthode membre et fonction libre (friend).
  • Découvrir les classes templates pour généraliser le type des éléments.

Prérequis

  • BE #2 : classe DamierDyn avec constructeur, destructeur, constructeur de recopie, operator=.

Principe

Le BE comporte deux parties :

  1. Partie 1 — Surcharge des opérateurs +, +=, << sur DamierDyn.
  2. Partie 2 — Création d’une classe générique DamierDynG<G> qui paramètre le type des cases.

Note : l’opérateur = a déjà été écrit au BE #2 ; on ne le refait pas ici.


Partie 1 — Surcharge d’opérateurs

Notions à maîtriser

  • Opérateur en tant que méthode membre ou fonction libre ;
  • sémantique de retour :
    • opérateur binaire qui produit un nouvel objet (+) → retour par valeur ;
    • opérateur qui modifie l’objet à gauche (+=) → retour par référence ;
  • surcharge multiple d’un même opérateur ;
  • mot-clé friend ;
  • réutilisation : implémenter + à partir de +=.

Contexte

Vous reprenez la classe DamierDyn du BE #2. Le but est de permettre l’écriture suivante :

DamierDyn d1(3, 5, 7), d2(3, 5, 2);
DamierDyn d3 = d1 + d2;        // somme case par case (étape 1)
d1 += d2;                       // ajoute d2 à d1 (étape 2)
d1 += 3;                        // ajoute 3 à toutes les cases (étape 3)
std::cout << d1;                // affiche d1 (étape 4)

Comportement en cas d’incompatibilité de dimensions

Pour + et += entre deux damiers, les dimensions doivent être égales. Si elles ne le sont pas, afficher un message d’erreur sur std::cerr (avec le nom de la fonction concernée) puis appeler std::exit(1) :

#include <cstdlib>     // pour std::exit
// ...
std::cerr << __func__ << " — dimensions incompatibles." << std::endl;
std::exit(1);

Aparté__func__ est une constante prédéfinie du C++ qui contient le nom de la fonction courante. Très pratique pour des messages d’erreur informatifs.

Important : std::exit(1) est une solution brutale — elle termine le programme sans appeler les destructeurs des objets en cours, ce qui peut provoquer des fuites de ressources. On reviendra sur cette gestion d’erreur au BE #4 avec les exceptions, qui résolvent proprement ce problème.


Étape 1 — operator+

Signature attendue (méthode membre, méthode const) :

DamierDyn operator+ (const DamierDyn& D) const;

Pourquoi cette signature ?

  • Retour par valeur (DamierDyn, pas DamierDyn&) : le résultat est un nouvel objet. Le retourner par référence pointerait vers une variable locale détruite à la sortie de la fonction (catastrophe).
  • Paramètre const& : pas de copie de D, lecture seule.
  • const à la fin : la méthode ne modifie pas l’objet à gauche du +.

Implémentation :

  1. vérifier que les dimensions de *this et D sont égales (sinon cerr + exit(1)) ;
  2. créer un nouveau DamierDyn aux mêmes dimensions ;
  3. remplir ses cases par T[i][j] + D.T[i][j] ;
  4. retourner ce nouveau damier.

Vous pouvez utiliser une méthode privée bool sameDimensions(const DamierDyn& D) const; pour factoriser le test.


Étape 2 — operator+= avec un autre damier

Signature attendue :

DamierDyn& operator+= (const DamierDyn& D);

Pourquoi cette signature ?

  • Retour par référence (DamierDyn&) : c’est l’idiome standard, qui permet d’écrire (d1 += d2) += d3; ou d1 = d2 += d3;.
  • Pas de const à la fin : la méthode modifie *this.

Implémentation : vérification des dimensions, ajout case par case, retour de *this.


Étape 3 — operator+= avec un entier (surcharge)

Signature attendue :

DamierDyn& operator+= (int c);

Cette surcharge porte le même nom que la précédente, mais une signature différente. Le compilateur choisira la bonne version selon le type de l’argument :

d1 += d2;      // appelle operator+= (const DamierDyn&)
d1 += 3;       // appelle operator+= (int)

Conseil de factorisation : une fois operator+= écrit, vous pouvez réécrire operator+ en termes de += :

DamierDyn DamierDyn::operator+ (const DamierDyn& D) const {
    DamierDyn res(*this);   // copie via le constructeur de recopie (BE #2)
    res += D;               // utilise operator+=
    return res;
}

Étape 4 — operator<< (sortie standard)

Signature attendue (fonction libre, pas méthode membre) :

friend std::ostream& operator<< (std::ostream& os, const DamierDyn& D);

Pourquoi pas une méthode membre ? Une méthode membre est implicitement de la forme objet.methode(args). Si operator<< était membre, on devrait écrire d << std::cout, ce qui n’est pas l’usage attendu (std::cout << d). Pour que std::cout se trouve à gauche de l’opérateur, operator<< doit être une fonction libre dont le premier paramètre est le flux.

Pourquoi friend ? Une fonction libre n’a pas accès aux membres private de la classe. Le mot-clé friend (déclaré dans la classe) lui accorde cet accès.

Implémentation : itérer sur les cases et écrire chacune dans le flux (avec un format aligné via <iomanip>).

std::ostream& operator<< (std::ostream& os, const DamierDyn& D) {
    for (int i = 0; i < D.L; ++i) {
        for (int j = 0; j < D.C; ++j) {
            os << std::setw(4) << D.T[i][j];
        }
        os << std::endl;
    }
    return os;
}

Le retour std::ostream& permet l’enchaînement : std::cout << d1 << d2;.


Programme de test (Partie 1)

Dans main(), vérifier les comportements suivants :

  1. créer deux damiers D1 et D2 aux mêmes dimensions, avec des valeurs initiales différentes ;
  2. tester D3 = D1 + D2; et afficher D3 ;
  3. tester D1 += D2; et afficher D1 ;
  4. tester D1 += 5; et afficher D1 ;
  5. tester std::cout << D1 << D2; (enchaînement) ;
  6. tester D4 = D1 + D2 + D3; (enchaînement de +) ;
  7. tester avec deux damiers de dimensions différentes : vérifier que le programme s’arrête avec un message sur cerr.

Points à noter

  • Le retour par valeur de operator+ peut sembler coûteux (copie d’un objet potentiellement gros). Le compilateur applique automatiquement une optimisation appelée Return Value Optimization (RVO) qui élimine cette copie dans la plupart des cas.
  • L’idiome standard est : opérateurs modificateurs (+=, -=, *=) en méthodes membres retournant *this& ; opérateurs producteurs (+, -) souvent écrits en termes des modificateurs.
  • operator<< ne peut pas être déclaré const à la fin (c’est une fonction libre, pas une méthode).

Questions à se poser

  • Pourquoi operator+ retourne-t-il par valeur, alors que operator+= retourne par référence ?
  • Que se passerait-il si on déclarait operator+ sans const à la fin ?
  • Pourquoi operator<< doit-il être une fonction libre friend plutôt qu’une méthode membre ?
  • Si vous écrivez d3 = d1 + d2 + d3;, combien de fois le constructeur de recopie est-il appelé (sans optimisation du compilateur) ?

Partie 2 — Classes génériques (templates)

Notions à maîtriser

  • Définition d’un template de classe : template <class G> class Foo
  • Type paramétré G utilisé comme un type concret dans la classe ;
  • particularité technique : code complet dans le .h ;
  • valeur par défaut générique avec G().

Contexte

La classe DamierDyn ne manipule que des entiers. Pour manipuler un damier de float, de double ou d’un autre type, on devrait dupliquer la classe avec son code — peu pratique et source de bugs.

Solution : un template de classe, qui paramètre le type des cases. La même classe pourra alors générer des versions spécialisées pour int, float, double, etc.

Travail à réaliser

Créez une nouvelle classe DamierDynG<G>, version paramétrée de DamierDyn. La signature attendue :

template <class G>
class DamierDynG {
public:
    DamierDynG(int l, int c, G val = G());
    DamierDynG(const DamierDynG<G>& D);
    ~DamierDynG();

    DamierDynG<G>& operator=  (const DamierDynG<G>& D);
    DamierDynG<G>& operator+= (const DamierDynG<G>& D);
    DamierDynG<G>& operator+= (G c);
    DamierDynG<G>  operator+  (const DamierDynG<G>& D) const;

    void Init(G val = G());
    void Set(int x, int y, G val);
    G    Get(int x, int y) const;
    void Print() const;

    template <class G2>
    friend std::ostream& operator<< (std::ostream& os, const DamierDynG<G2>& D);

private:
    int  L;
    int  C;
    G**  T;

    void Alloc(int l, int c);
    void Free();
    bool sameDimensions(const DamierDynG<G>& D) const;
};

Quelques explications sur la syntaxe :

  • template <class G> introduit le paramètre de type. class G et typename G sont synonymes ; les deux écritures sont valides.
  • À l’intérieur de la classe, G se manipule comme un type concret (G val, G** T, etc.).
  • G() désigne la valeur par défaut du type G. Pour int, c’est 0. Pour float, c’est 0.0f. Pour bool, c’est false. Pour un objet utilisateur, c’est l’objet construit avec son constructeur sans argument.
  • L’écriture du friend operator<< est plus complexe : il faut redéclarer un nouveau paramètre template (G2) pour la fonction libre. C’est un détail technique qu’on accepte sans s’attarder.

Particularité technique : code dans le .h

Le compilateur doit avoir accès au code complet d’un template au moment où il l’instancie (DamierDynG<int>, DamierDynG<float>, etc.). On ne peut donc pas séparer la déclaration et l’implémentation comme avec DamierDyn au BE #2.

Tout le code de DamierDynG doit être placé dans le fichier .h : déclaration de la classe et définition des méthodes.

L’implémentation des méthodes hors de la classe utilise la syntaxe suivante :

template <class G>
DamierDynG<G>::DamierDynG(int l, int c, G val) {
    Alloc(l, c);
    Init(val);
}

template <class G>
void DamierDynG<G>::Init(G val) {
    for (int i = 0; i < L; ++i)
        for (int j = 0; j < C; ++j)
            T[i][j] = val;
}

Notez qu’il faut :

  • répéter template <class G> avant chaque définition ;
  • préfixer le nom de la classe par DamierDynG<G>:: (pas seulement DamierDynG::).

Erreur typique : si vous séparez quand même la déclaration (.h) et l’implémentation (.cpp), le code compile mais l’éditeur de liens (linker) se plaint d’une undefined reference dès que vous instanciez le template. Solution : tout mettre dans le .h.

Programme de test (Partie 2)

Voici un test à faire fonctionner :

#include "damierdyng.h"

int main() {
    DamierDynG<float> Df(2, 2);
    Df.Init(-1.4f);
    Df += 4.5f;
    Df.Set(1, 0, 13.4f);
    std::cout << Df;

    DamierDynG<int> Di(3, 3, 7);
    Di += 2;
    std::cout << Di;

    DamierDynG<double> Dd(2, 3, 1.5);
    DamierDynG<double> Dd2(Dd);          // ctor de recopie
    Dd2 += Dd;
    std::cout << Dd2;

    return 0;
}

Points à noter

  • template <class G> et template <typename G> sont équivalents. Choisir un style et s’y tenir.
  • Un template ne génère du code machine que lorsqu’il est instancié. DamierDynG<int> et DamierDynG<float> produisent deux classes distinctes en mémoire.
  • Si G est un type qui ne supporte pas l’opérateur utilisé (par exemple, G = std::string ne supporte pas T[i][j] += c), la compilation échouera au moment de l’instanciation. Le message d’erreur peut être déroutant.

Questions à se poser

  • Pourquoi le code complet d’un template doit-il être placé dans le .h et non dans un .cpp ?
  • Que se passe-t-il si G ne dispose pas d’un constructeur par défaut (cas où G() n’est pas valide) ?
  • Combien de versions de DamierDynG se retrouvent dans l’exécutable si on utilise DamierDynG<int>, DamierDynG<float> et DamierDynG<double> dans le même programme ?

Aller plus loin — vers les exceptions (BE #4)

Vous avez constaté que la gestion d’erreur sur dimensions incompatibles repose sur std::cerr + std::exit(1). Cette solution est brutale : std::exit(1) termine le programme sans appeler les destructeurs des objets en cours, ce qui provoque potentiellement des fuites de ressources (mémoire, fichiers ouverts, connexions…).

Au BE #4, vous remplacerez ces exit(1) par des exceptions, mécanisme propre du C++ qui :

  • signale une erreur en remontant la pile d’appels jusqu’à un gestionnaire ;
  • garantit que tous les destructeurs des objets locaux sont bien appelés en chemin (stack unwinding) — donc pas de fuite ;
  • permet à l’appelant de réagir (réessayer, afficher, abandonner…) plutôt que d’imposer la fin du programme.