Tous droits réservés

Une classe de grande valeur

Publié :

Dans le chapitre sur les opérateurs, nous avions dit, sans plus détailler, que ce n’est pas parce que l’on peut surcharger des opérateurs qu’on doit forcément le faire. En effet, le faire n’a de sens que pour les classes à sémantique de valeur. Et nous allons voir que ce n’est pas la seule implication qu’a cette sémantique.

Ce chapitre va présenter la sémantique de valeur et tout ce qui lui est lié.

Une histoire de sémantique

Depuis le début du cours, nous avons manipulé de nombreux types, comme des entiers, des réels, des chaînes de caractères, des tableaux, etc. Mais vous êtes-vous déjà demandé pourquoi y’en a t-il autant ? Puisqu’au final, le processeur ne travaille que sur des nombres, on pourrait se dire que c’est un peu inutile. Pourtant, s’il existe tant de types différents, c’est parce qu’ils ont des sémantiques différentes.

La sémantique, c’est l’étude du sens. Étudier la sémantique d’un type, c’est donc regarder ce qui a du sens de faire et ce qui n’en a pas. Le type int est un type simple, avec une sémantique simple, celle de manipuler un entier. L’exemple suivant est donc parfaitement compilable et exécutable.

int const temperature { 37 };
int const poids { 70 };
auto const calcul_idiot { poids + temperature };

Pourtant, on sait bien qu’additionner un poids et une température n’a pas de sens. D’ailleurs, quelle unité est-on sensé obtenir ? Le compilateur lui ne le sait pas. On lui donne deux entiers et on lui demande de les additionner, il n’y voit aucun problème. Alors que si on définit des types plus spécialisés, avec une sémantique plus précise, on empêche ce comportement.

Temperature const temperature { 37 };
Poids const poids { 70 };
// Erreur à la compilation.
auto const calcul_idiot { poids + temperature };

Définir une sémantique permet donc d'éviter des bugs, tout en rendant le code plus clair, puisque celui qui vous relit ne se pose pas de question sur vos intentions et sur le sens de votre code. Par exemple, quel code vous semble le plus clair, parmi les deux ci-dessous ?

std::tuple<int, int, int> date_jour()
{
}

auto const date { date_jour() }; 
std::cout << "Aujourd'hui, nous somme le " << std::get<0>(date) << "/" << std::get<1>(date) << "/" << std::get<2>(date) << "\n";

// ---------------------------------
// VS
// ---------------------------------

struct Date 
{
    int jour;
    int mois;
    int annee;
}

Date date_jour()
{
}

auto const date { date_jour() }; 
std::cout << "Aujourd'hui, nous somme le " << date.jour << "/" << date.mois << "/" << date.annee << "\n";

Dans le premier, il faut se souvenir constamment de l’ordre des éléments du tuple : est-ce que c’est jour, mois puis année, ou l’inverse ? Dans le deuxième, tout est transparent et clair, on sait directement qu’on manipule une date. Le code est donc meilleur.

Ce qu’il convient de retenir, c’est que définir la sémantique d’un type que l’on crée a des conséquences sur son utilisation, car certaines opérations auront du sens et d’autres non. Créer des objets, on sait faire. Maintenant, nous allons voir plusieurs sémantiques possibles et ce qu’elles vont impliquer dans le design de nos classes.

La sémantique de valeur, c'est quoi ?

On dit d’une classe qu’elle a une sémantique de valeur si deux objets au contenu identique peuvent être considérés comme égaux. Par exemple, une classe modélisant une fraction est une classe à sémantique de valeur. Si deux fractions valent 1/2 chacune, elles sont égales. On a bien deux objets en mémoire, mais leur valeur est égale et donc on considère que ces objets sont égaux.

À retenir

Si deux objets, ayant la même valeur, peuvent être considérés comme égaux, on est typiquement dans de la sémantique de valeur.

On peut aussi soustraire plusieurs valeurs entre elles, les multiplier, les diviser, etc. D’où l’intérêt de la surcharge d’opérateurs, qui nous permet d’écrire ça sous forme élégante.

À retenir

La surcharge d’opérateurs arithmétiques prend tout son sens dans le cas de la sémantique de valeur.

Prenons un autre exemple, une somme d’argent. C’est bien une valeur. Mais que mes 100€ soient sous forme de liquide, de chèque ou d’une ligne sur mon compte en banque, c’est toujours la même valeur. Je peux ajouter une somme d’argent à une autre et dans ce cas, j’obtiens une nouvelle valeur, une nouvelle somme.

À retenir

Modifier une valeur revient à en créer une nouvelle.

Égalité

Commençons par le premier point caractéristiques des valeurs, l’égalité. Derrière ce mot simple se cachent des concepts plus complexes, qu’on aborde notamment en théorie des langages. En C++, pour être en mesure de définir l’égalité, on doit respecter les conditions suivantes.

  • Pour n’importe quel objet a, la comparaison a == a est toujours vraie. C’est ce qu’on appelle la réflexivité.
  • Pour deux objets a et b de même type, si a == b alors b == a. La comparaison doit être commutative et symétrique.
  • Pour des objets a, b et c de même type, si on a a == b et b == c, alors on doit avoir a == c. On appelle ça la transitivité.

Dans le chapitre 6 de la partie II, nous avons découvert les opérateurs de comparaison ==, !=, >, <, >= et <=. C’est sur eux que nous allons nous pencher de nouveau maintenant.

Libre ou membre ?

Nous avons appris à les écrire sous forme libre, mais ces opérateurs existent aussi sous la forme de fonctions membres. C’est ce qu’on apprend en lisant la documentation. On peut donc se demander laquelle choisir ?

class Classe
{
    bool operator==(Classe a);
};
 
Classe a {};
Classe b {};
a == b; // Appel de == membre.

class Classe
{
};

bool operator==(Classe a, Classe b);
 
Classe a {};
Classe b {};
a == b; // Appel de == libre.

Pourtant, entre les deux formes, il y a une grande différence. Dans le premier cas, l’appel a == b va être remplacé par a.operator==(b). Cela signifie que le premier paramètre sera toujours du même type que la classe. Dans le deuxième cas, ce n’est pas obligatoire, car il peut y avoir une conversion implicite réalisée.

Imaginons que nous voulions comparer une fraction avec un entier. Cette opération a du sens, puisqu’un entier est un cas particulier de fraction, avec le dénominateur valant 1. Avec la forme membre, l’instruction entier == fraction ne compile pas, car le premier argument doit obligatoirement être une instance de Fraction.

#include <cassert>
#include <iostream>

class Fraction
{
public:
    // Constructeur par conversion car un seul paramètre obligatoire. 
    Fraction(int numerateur, int denominateur = 1) noexcept;

    // Version membre.
    bool operator==(Fraction const & fraction) const noexcept;

private:
    int m_numerateur { 0 };
    int m_denominateur { 1 };
};

Fraction::Fraction(int numerateur, int denominateur) noexcept
    : m_numerateur(numerateur), m_denominateur(denominateur)
{
    assert(denominateur != 0 && "Le dénominateur ne peut pas valoir 0.");
}

// Implémentation de la version membre.
bool Fraction::operator==(Fraction const & fraction) const noexcept
{
    return m_numerateur == fraction.m_numerateur && m_denominateur == fraction.m_denominateur;
}

int main()
{
    Fraction const fraction { 4, 2 };
    int const entier { 2 };

    if (entier == fraction)
    {
        std::cout << "4/2 est égal à 2.\n";
    }
    else
    {
        std::cout << "4/2 est différent de 2.\n";
    }

    return 0;
}
[Visual Studio]
Erreur (active) E0349 aucun opérateur "==" ne correspond à ces opérandes
Erreur  C2677 '==' binaire : aucun opérateur global trouvé qui accepte le type 'const Fraction' (ou il n'existe aucune conversion acceptable)

------------------------------------------------------------

[GCC]
main.cpp: In function 'int main()':
main.cpp:34:16: error: no match for 'operator==' (operand types are 'const int' and 'const Fraction')
   34 |     if (entier == fraction)
      |         ~~~~~~ ^~ ~~~~~~~~
      |         |         |
      |         const int const Fraction

------------------------------------------------------------

[Clang]
prog.cc:34:16: error: invalid operands to binary expression ('const int' and 'const Fraction')
    if (entier == fraction)
        ~~~~~~ ^  ~~~~~~~~
1 error generated.

Avec la version libre, le problème ne se pose plus, puisqu’il y a conversion implicite de l’entier en fraction. Notez au passage l’utilisation de l’amitié pour surcharger l’opérateur == libre.

#include <cassert>
#include <iostream>

class Fraction
{
public:
    // Constructeur par conversion car un seul paramètre obligatoire. 
    Fraction(int numerateur, int denominateur = 1) noexcept;

    // Version libre amie.
    friend bool operator==(Fraction const & lhs, Fraction const & rhs);

private:
    int m_numerateur { 0 };
    int m_denominateur { 1 };
};

Fraction::Fraction(int numerateur, int denominateur) noexcept
    : m_numerateur(numerateur), m_denominateur(denominateur)
{
    assert(denominateur != 0 && "Le dénominateur ne peut pas valoir 0.");
}

bool operator==(Fraction const & lhs, Fraction const & rhs)
{
    return lhs.m_numerateur == rhs.m_numerateur && lhs.m_denominateur == rhs.m_denominateur;
}

int main()
{
    Fraction const fraction { 4, 2 };
    int const entier { 2 };

    if (entier == fraction)
    {
        std::cout << "4/2 est égal à 2.\n";
    }
    else
    {
        std::cout << "4/2 est différent de 2.\n";
    }

    return 0;
}
4/2 est égal à 2.

Quelle version dois-je utiliser ? Laquelle est la meilleure ?

Il faut en effet en choisir une des deux, car il est impossible d’utiliser les deux en même temps. En effet, en écrivant fraction == autre_fraction, le compilateur ne sait pas si vous cherchez à utiliser la version libre ou la version membre. Cela se voit dans les erreurs qu’on obtient.

[Visual Studio]
Erreur (active) E0350 plusieurs opérateurs "==" correspondent à ces opérandes
Erreur  C2593 'operator ==' est ambigu

------------------------------------------------------------

[GCC]
main.cpp: In function 'int main()':
main.cpp:42:18: error: ambiguous overload for 'operator==' (operand types are 'const Fraction' and 'const Fraction')
   42 |     if (fraction == autre_fraction)
      |         ~~~~~~~~ ^~ ~~~~~~~~~~~~~~
      |         |           |
      |         |           const Fraction
      |         const Fraction
main.cpp:27:6: note: candidate: 'bool Fraction::operator==(const Fraction&) const'
   27 | bool Fraction::operator==(Fraction const & fraction) const noexcept
      |      ^~~~~~~~
main.cpp:32:6: note: candidate: 'bool operator==(const Fraction&, const Fraction&)'
   32 | bool operator==(Fraction const & lhs, Fraction const & rhs)
      |      ^~~~~~~~

------------------------------------------------------------

[Clang]
prog.cc:42:18: error: use of overloaded operator '==' is ambiguous (with operand types 'const Fraction' and 'const Fraction')
    if (fraction == autre_fraction)
        ~~~~~~~~ ^  ~~~~~~~~~~~~~~
prog.cc:27:16: note: candidate function
bool Fraction::operator==(Fraction const & fraction) const noexcept
               ^
prog.cc:32:6: note: candidate function
bool operator==(Fraction const & lhs, Fraction const & rhs)
     ^
1 error generated.

La forme la plus pratique et la plus simple reste la forme libre, éventuellement friend si besoin d’accéder à des attributs privés. La forme membre est permise, mais est plus limitative.

Soyons intelligents, réutilisons

Ce conseil vous a déjà été donné lors de la découverte de la surcharge d’opérateurs, mais il est bon de se le rappeler. En effet, réutiliser le code permet de réduire le risque de bugs, évite la duplication, rend le code plus léger. L’habitude que beaucoup prennent est de définir les opérateurs == et <, puis de définir les autres en fonction de ces deux-là.

Dans le cas de notre classe Fraction, rien de bien compliqué, il suffit de reprendre ce que nous avons déjà écrit il y a quelques temps.

#include <cassert>

class Fraction
{
public:
    Fraction(int numerateur, int denominateur) noexcept;

    // Utilisation de l'amitié car == et < ont besoin d'accéder aux attributs.
    // Notez que si la classe fournit les services 'numerateur()' et 'denominateur()', alors l'amitié n'est plus nécessaire.
    friend bool operator==(Fraction const & lhs, Fraction const & rhs) noexcept;
    friend bool operator<(Fraction const & lhs, Fraction const & rhs) noexcept;

private:
    int m_numerateur { 0 };
    int m_denominateur { 1 };
};

Fraction::Fraction(int numerateur, int denominateur) noexcept
    : m_numerateur(numerateur), m_denominateur(denominateur)
{
    assert(denominateur != 0 && "Le dénominateur ne peut pas valoir 0.");
}

// Opérateurs d'égalité.

bool operator==(Fraction const & lhs, Fraction const & rhs) noexcept
{
    return lhs.m_numerateur == rhs.m_numerateur && lhs.m_denominateur == rhs.m_denominateur;
}

bool operator!=(Fraction const & lhs, Fraction const & rhs)
{
    return !(lhs == rhs);
}

// Opérateurs de comparaisons.

bool operator<(Fraction const & lhs, Fraction const & rhs) noexcept
{
    if (lhs.m_numerateur == rhs.m_numerateur)
    {
        return lhs.m_denominateur > rhs.m_denominateur;
    }
    else if (lhs.m_denominateur == rhs.m_denominateur)
    {
        return lhs.m_numerateur < rhs.m_numerateur;
    }
    else
    {
        Fraction const gauche { lhs.m_numerateur * rhs.m_denominateur, lhs.m_denominateur * rhs.m_denominateur };
        Fraction const droite { rhs.m_numerateur * lhs.m_denominateur, rhs.m_denominateur * lhs.m_denominateur };
        return gauche.m_numerateur < droite.m_numerateur;
    }
}

// Pour simplifier, il suffit de vérifier que lhs n'est ni inférieur, ni égal à rhs.
bool operator>(Fraction const & lhs, Fraction const & rhs)
{
    return !(lhs < rhs) && lhs != rhs;
}

// Pour simplifier, il suffit de vérifier que lhs est inférieur ou égal à rhs.
bool operator<=(Fraction const & lhs, Fraction const & rhs)
{
    return lhs < rhs || lhs == rhs;
}

// Pour simplifier, il suffit de vérifier que lhs est supérieur ou égal à rhs.
bool operator>=(Fraction const & lhs, Fraction const & rhs)
{
    return lhs > rhs || lhs == rhs;
}

En fait, dès que l’on définit un opérateur d’égalité ou de comparaison, il fait sens de définir les autres. Si vous fournissez < et >, l’utilisateur s’attend aussi à pouvoir utiliser <= et >=. Heureusement, comme on l’a vu, on peut définir les autres en fonction du premier, ce qui fait gagner du temps et réduit la possibilité de bugs.

Le retour des opérateurs

Dans le chapitre 6 de la deuxième partie, nous avions vu une première introduction à la surcharge d’opérateur, ce qui nous a permis de définir l’addition, la soustraction, etc. Maintenant, nous allons en aborder d’autres, grâces à nos nouvelles connaissances sur les objets.

Sous-ensemble

La surcharge d’opération n’est pas une caractéristique essentielle des classes à sémantique de valeur, mais plutôt un sous-ensemble qu’on rencontre souvent, mais pas systématiquement.

Les opérateurs d’affectation intégrés

Sous ce terme se cachent les opérateurs +=, -=, *=, /= et %=, qui permettent les raccourcis du type f1 += f2, ou bien encore f3 -= f4. On les appelle en anglais les compound assignment operator.

Notez que, comme pour des types natifs, ils modifient l’instance qui les appelle. La forme canonique voudra donc qu’on les écrive sous la forme membre. Voici à quoi l’opérateur += de la classe Fraction doit ressembler. La forme est la même pour tous les autres, hormis le signe.

class Fraction
{
public:
    Fraction& operator+=(Fraction const & fraction) noexcept;
};

Fraction& Fraction::operator+=(Fraction const & fraction) noexcept
{
    // Implémentation de l'addition.
    return *this;
}

Normalement, l’instruction return *this devrait vous surprendre. En effet, nous ne l’avons jamais rencontrée encore. De plus, la fonction renvoie une référence. Or, nous avions vu que cela peut être dangereux dans les cas où celle-ci pointerait sur un objet qui n’existe plus. Que de mystères…

L’explication est simple. L’expression return *this permet de renvoyer l’objet courant. Sans cela, il serait impossible d’écrire quelque chose comme ce qui suit.

Fraction const f1 { 5, 4 };
Fraction f2 { 1, 1 };
Fraction f3 { 1, 1 };

// On additionne f1 à f2, puis on soustrait ce nouveau f2 à f3.
f3 -= f2 += f1;

Comme la ligne f3 -= f2 += f1 revient à écrire f3.operator-=(f2.operator+=(f1)), si la fonction ne renvoyait rien, nous ne pourrions pas chaîner ainsi les modifications (bien que cet exemple soit très peu lisible). Il faut donc renvoyer l’objet après modification. Comme celui-ci est toujours valide, aucun risque à renvoyer une référence sur cet objet, ce qui est moins long qu’une recopie.

Cela nous permet d’avoir le même comportement pour notre objet que s’il s’agissait d’un type natif comme int (pas de surprise), tout en gardant une compatibilité / similitude avec le langage C.

Le reste de l’implémentation ne devrait pas vous surprendre, il ne s’agit que d’une simple addition de fractions.

class Fraction
{
public:
    Fraction& operator+=(Fraction const & fraction) noexcept;

private:
    int m_numerateur { 0 };
    int m_denominateur { 1 };
};

Fraction& Fraction::operator+=(Fraction const & fraction) noexcept
{
    m_numerateur = m_numerateur * fraction.m_denominateur + fraction.m_numerateur * m_denominateur;
    m_denominateur *= fraction.m_denominateur;
    return *this;
}
Exercice

Je vous invite à implémenter de la même manière -=, *= et /= pour vous entraîner.

Du deux en un

Pour chaque opérateur intégré, il existe l’équivalent binaire, qui prend deux arguments et renvoie une copie. Ainsi, pour +=, il existe +, pour -= il existe -, etc. Logiquement, si l’on fournit l’une des deux formes, on s’attend à l’autre. Dans un esprit de réutilisation du code déjà écrit et validé, on va en implémenter un sous forme de l’autre.

L’usage en C++ est d’écrire l’implémentation dans l’opérateur intégré, puis de définir l’opérateur libre associé en fonction du premier. Dans le cas de la classe Fraction, cela veut dire écrire la logique de l’addition dans operator+= puis d’appeler celui-ci dans operator+. Voyez par vous-mêmes l’exemple suivant.

class Fraction
{
public:
    Fraction& operator+=(Fraction const & fraction) noexcept;

private:
    int m_numerateur { 0 };
    int m_denominateur { 1 };
};

Fraction& Fraction::operator+=(Fraction const & fraction) noexcept
{
    m_numerateur = m_numerateur * fraction.m_denominateur + fraction.m_numerateur * m_denominateur;
    m_denominateur *= fraction.m_denominateur;
    return *this;
}

// L'argument lhs est recopié, donc on peut le modifier sans problème.
Fraction operator+(Fraction lhs, Fraction const & rhs) noexcept
{
    lhs += rhs;
    return lhs;
}

On utilise le principe de la recopie. Puisque le paramètre lhs est une copie, on peut le modifier en utilisant += sans risque. On obtient un opérateur + correct et non redondant avec +=.

Exercice

Je vous invite à implémenter de la même manière -, * et / pour vous entraîner.

Les opérateurs de manipulation des flux

Ce sont eux qui nous permettent d’afficher un objet sur la sortie standard, ou d’en créer un depuis un fichier, par exemple. En lisant la documentation, on note que leur forme canonique est la suivante.

std::ostream& operator<<(std::ostream & os, T const & obj)
{
    // Écriture sur le flux de sortie.
    return os;
}

std::istream& operator>>(std::istream & is, T & obj)
{
    // Lecture depuis le flux d'entrée.
    if( /* Erreur, impossible de construire T. */ )
    {
        is.setstate(std::ios::failbit);
    }
    return is;
}

Ces opérateurs s’écrivent toujours sous la forme libre car leur premier argument est toujours un flux. Ils sont souvent friend, car ils doivent accéder aux attributs de l’instance. Enfin, notez qu’on signale que l’entrée est invalide en mettant le flux dans un état invalide avec std::ios::failbit, ce qui permet à l’utilisateur de faire if (std::cin.fail()), comme nous l’avons appris lors de l’écriture de la fonction de saisie sécurisée.

L’exemple suivant nous montre une implémentation possible pour la classe Fraction. Notez que j’ai extrait dans une nouvelle fonction l’extraction des données depuis une chaîne de caractères. Ça permet de gérer de plusieurs manières les erreurs et de ne pas dupliquer la logique et le code entre le constructeur et operator>>.

Fraction.hpp
#ifndef FRACTION_HPP
#define FRACTION_HPP

#include <istream>
#include <ostream>
#include <string>

class Fraction
{
public:
    Fraction(int numerateur, int denominateur);
    Fraction(std::string const & chaine);

    friend std::ostream& operator<<(std::ostream & os, Fraction const & fraction);
    friend std::istream& operator>>(std::istream & is, Fraction & fraction);

private:
    int m_numerateur { 1 };
    int m_denominateur { 1 };
};

#endif
Fraction.cpp
#include <algorithm>
#include <cassert>
#include <exception>
#include "Fraction.hpp"

/**
 * @brief Extrait le numérateur et le dénominateur d'une chaîne, si possible.
 * @param[in] chaine La chaîne de caractères à analyser.
 * @param[out] numerateur Le numérateur extrait.
 * @param[out] denominateur Le dénominateur extrait.
 * @exception Lance une std::invalid_argument si la chaîne n'est pas correcte.
 */
void extraction_depuis_chaine(std::string const & chaine, int & numerateur, int & denominateur)
{
    if (std::empty(chaine))
    {
        throw std::invalid_argument("La chaîne ne peut pas être vide.");
    }

    auto const debut = std::begin(chaine);
    auto const fin = std::end(chaine);
    if (std::count(debut, fin, '/') != 1)
    {
        throw std::invalid_argument("La chaîne ne peut contenir qu'un seul séparateur '/'.");
    }
    auto iterateur = std::find(debut, fin, '/');

    // Récupération du numérateur.
    std::string const n { debut, debut + std::distance(debut, iterateur) };
    if (std::empty(n))
    {
        throw std::invalid_argument("Le numérateur ne peut pas être vide.");
    }

    // Pour sauter le caractère /.
    ++iterateur;
    // Récupération du dénominateur.
    std::string const d { iterateur, fin };
    if (std::empty(d) || std::stoi(d) == 0)
    {
        throw std::invalid_argument("La dénominateur ne peut pas être vide ou nul.");
    }

    // Mise à jour uniquement si tout c'est bien passé.
    numerateur = std::stoi(n);
    denominateur = std::stoi(d);
}

Fraction::Fraction(int numerateur, int denominateur)
    : m_numerateur(numerateur), m_denominateur(denominateur)
{
    assert(denominateur != 0 && "Le dénominateur doit toujours être différent de 0.");
}

Fraction::Fraction(std::string const & fraction)
{
    extraction_depuis_chaine(fraction, m_numerateur, m_denominateur);
}

// Rien de particulier, le code est simple.
std::ostream& operator<<(std::ostream & os, Fraction const & fraction)
{
    os << fraction.m_numerateur << "/" << fraction.m_denominateur;
    return os;
}

// Ici, on a un peu plus de travail.
std::istream& operator>>(std::istream & is, Fraction & fraction)
{
    std::string entree {};
    is >> entree;

    try
    {
        int numerateur { 0 };
        int denominateur { 0 };
        extraction_depuis_chaine(entree, numerateur, denominateur);
        // Si on arrive ici, tout va bien, on peut créer le nouvelle objet.
        fraction = Fraction { numerateur, denominateur };
    }
    catch (std::exception const & e)
    {
        // On attrape n'importe quelle exception, que ce soit parce que la
        // chaîne est invalide ou parce que stoi a échoué lors de la conversion.
        is.setstate(std::ios::failbit);
    }
    
    return is;
}
main.cpp
#include <iostream>
#include "Fraction.hpp"

template<typename T>
void entree_securisee(T & variable)
{
    while (!(std::cin >> variable))
    {
        if (std::cin.eof())
        {
            break;
        }
        else if (std::cin.fail())
        {
            std::cout << "Entrée invalide. Recommence." << std::endl;
            std::cin.clear();
            std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
        }
        else
        {
            std::cout << "Le prédicat n'est pas respecté !" << std::endl;
        }
    }
}

int main()
{
    Fraction const f1 { "5/2" };
    std::cout << f1 << "\n";

    Fraction f2 { 1, 1 };
    // Grâce à notre écriture de operator>> conforme aux normes, on peut
    // réutiliser la fonction entree_securisee sans plus d'effort.
    entree_securisee(f2);
    std::cout << f2 << "\n";
    return 0;
}

Quelques bonnes pratiques

Finissons cette section en détaillant trois bonnes pratiques que vous devez garder à l’esprit chaque fois que vous surchargerez des opérateurs. Comme d’habitude, ces principes n’ont rien d’obligatoire, mais ils rendront votre code de meilleure qualité.

D’abord, respectez toujours la sémantique d’un opérateur. Si vous utilisez l’opérateur +, ne calculez pas une division. C++ vous y autorise, mais les utilisateurs de votre code risquent fort de ne pas apprécier. Si un opérateur a une sémantique déjà définie pour un domaine, tenez vous-y. Que votre + signifie toujours + !

Ensuite, si la signification d’un opérateur n’est pas claire et indiscutable, vous ne devriez pas l’utiliser. Par exemple, bien qu’on puisse surcharger l’opérateur *, ne vous en servez pas pour retourner l’inverse d’une fraction, ça surprendrait tout le monde car personne ne s’attend à ce que cet opérateur retourne un inverse. Si la signification de l’opérateur n’est pas clairement comprise et largement acceptée pour l’opération que vous voulez réaliser, mieux vaut utiliser une fonction bien nommée à la place.

Point d’histoire

Par exemple, le choix de << et >> pour les flux d’entrée ne respecte pas ce principe, car cet opérateur n’a pas la même sémantique ici que celle qu’il a en C. Aujourd’hui, tout le monde est habitué, mais ce n’est pas une raison pour faire de même.

Enfin, certains opérateurs viennent par groupe et si vous implémentez un opérateur d’une catégorie, implémentez aussi tous les autres de cette catégorie. Je le répète, mais si vous implémentez <, les utilisateurs s’attendront à pouvoir utiliser aussi >, <= et >=. Si vous définissez +=, on peut vraisemblablement penser qu’on pourra utiliser aussi +.

Les autres opérateurs

Faire le tour de tous les opérateurs serait long et peu instructif. En effet, maintenant que nous avons vu plus en détail comment procéder, ainsi que les formes canoniques, le principe est le même pour tous les autres. Vous serez donc capables de vous débrouiller seuls avec les opérateurs que nous n’avons pas abordé. :)

De plus, hormi les opérateurs () et [], ainsi que ++ et --, ceux qui restent sont assez rarement surchargés. Dans ce cours, nous ne verrons pas de situation où l’on aurait besoin de surcharger d’autres opérateurs que tous ceux cités, ainsi que () et [].

Copier des objets

On peut créer autant de « duplicatas » du même objet, s’il a une sémantique de valeur, ils seront tous égaux. On peut copier, par exemple, notre fraction 1/21/2 dix fois, à la fin il ne reste que la même valeur. Mais justement, comment on copie les objets en C++ ? Avec deux choses.

  • Le constructeur de copie.
  • L’opérateur d’affectation par copie.

Constructeur de copie

C’est ce constructeur qui est appelé quand on crée une copie d’une autre instance. C’est lui que nous avons utilisé, sans le savoir, en écrivant la fonction libre pow pour la classe Fraction.

Fraction copie { fraction };

Par défaut, le compilateur en génère un si l’on ne fait rien. Ce constructeur par recopie s’occupe comme un grand de copier chaque attribut. Dans notre cas, nos attributs sont de simples entiers. On peut donc laisser le compilateur faire son travail sans problème. Notez qu’on peut utiliser le mot-clé default si l’on veut être plus explicite.

class Fraction
{
public:
    Fraction(Fraction const & fraction) = default;
};

Opérateur d’affectation par copie

Ce que nous venons de voir fonctionne lors de l’instanciation d’une classe, puisque c’est à ce moment précis que le constructeur est appelé. Or, il se peut que l’on veuille affecter une nouvelle valeur à notre objet en dupliquant celle d’un autre, alors que celui-là est déjà créé. Pour cela, il faut implémenter l’opérateur d’affectation par copie, de manière à rendre compilable le code suivant.

Fraction const fraction { 3, 4 };
Fraction copie { 1, 1 };

// Du code.

copie = fraction;

Comme pour le constructeur de copie, son implémentation est faite implicitement par le compilateur. On peut là encore choisir de la rendre explicite.

class Fraction
{
public:
    Fraction & operator=(Fraction const & fraction) = default;
};

Comme pour les opérateurs d’affectation intégrés, cet opérateur renvoie une référence sur l’objet courant, ce qui permet de chaîner les affectations, comme le montre l’exemple ci-dessous.

Fraction const f1 { 87, 9 };
Fraction f2 { 1, 1 };
Fraction f3 { 1, 1 };

f3 = f2 = f1;

En résumé

  • Certaines classes ont une sémantique de valeur.
  • L’égalité de deux objets est définie en C++ par des règles précises.
  • Le constructeur de copie et l’opérateur d’affection par recopie peuvent être générés automatiquement par le compilateur, soit implicitement en n’écrivant rien, soit explicitement en utilisant default.
  • On peut utiliser la surcharge d’opérateurs pour faciliter la manipulation de nos valeurs.
  • En réutilisant intelligemment certains opérateurs, on peut écrire le reste plus simplement et de façon plus concise.