Site non compatible avec cette vieillerie d'Internet Explorer. Pour une expérience complète, voir la liste des navigateurs supportés.
Thumbnail

 Memo [C++] - La surcharge (fonction et opérateur)

La surcharge (ou overloading en anglais) est monnaie courante en C++. Mais qu'est-ce donc ? Comment cela fonctionne-t-il ? À quoi cela sert-il ? Et bien c'est ce que nous verrons dans cet article.


Post-it | C++

Auteur : Guillaume M.
Créé le : 07 Jan 2020
Dernière mise à jour : 07 Jan 2020

Définition


Avant d'entrer dans le vif du sujet, il est important de savoir ce que représente la signature d'une fonction étant donné que le principe de surcharge est basé sur celle-ci.

Pour cela, regardons d'un peu plus près ce schéma illustrant une déclaration de fonction :

Ici on distingue 3 choses :

  • le type de retour
  • le nom de la fonction
  • le/les paramètre(s)

Cet ensemble forme donc ce qu'on appelle la signature d'une fontion.

À noter que pour une fonction membre, des choses peuvent s'ajouter à la signature d'une fonction, comme par exemple les const-qualifier ou les ref-qualifier.

Une surcharge consiste à définir une nouvelle version d'une fonction en modifiant sa signature.

Pour se faire, il y a 2 règles à respecter :

  • avoir le même nom de fonction
  • avoir un/des paramètres d'entrée différent(s)

 

Surcharge d'une fonction


Pour commencer, définissons d'abord une simple fonction :

void printNumber(int a)
{
    std::cout << "Number to print : " << a << '\n';
}

Ici rien de bien compliqué, on affiche simplement la valeur d'une variable en console.

Maintenant, on aimerait que cette fonction puisse afficher des nombres de type float. Pour se faire, rien de plus simple, surchargeons cette fonction en la "réécrivant" avec un paramètre de type float !

Ce qui nous donne :

void printNumber(float a)
{
    std::cout << "Float to print : " << a << '\n';
}

Voilà ! La fonction printNumber possède maintenant une surcharge permettant de travailler avec un type différent. Le fait d'avoir surcharger la fonction nous a permis entre autre de modifier le comportement de celle-ci selon le type de données qu'elle reçoit. Dans notre cas, la phrase affichée sera différente.

Aller pour le fun, ajoutons une autre surcharge et voyons ce que cela donne dans l'ensemble d'un programme :

#include <iostream>

void printNumber(int a)
{
    std::cout << "Integer to print : " << a << '\n';
}

void printNumber(float a)
{
    std::cout << "Float to print : " << a << '\n';
}

void printNumber(unsigned u, double d)
{
    std::cout << "Unsigned number : " << u << '\n'
              << "Double number : "   << d << '\n';
}

int main()
{
    int      i = 1;
    float    f = 3.14f;
    unsigned u = 3;
    double   d = 7.01;

    printNumber(i);
    printNumber(f);
    printNumber(u, d);

    return 0;
}

 

Surcharge d'opérateur


Commençons par jeter un coup d'oeil aux opérateurs pouvant être surchargés :

https://en.cppreference.com/w/cpp/language/operators

Comme on peut le constater, on y retrouve pas mal d'opérateurs fréquemment utilisés comme le +, le -, les opérateurs de flux << et >>, etc.

Maintenant, pour quelle raison aurait-on besoin de surcharger un opérateur ? Et bien la réponse est simple : pour ajouter un comportement à cet opérateur avec un type de donnée qu'il ne connaît pas.

Note : Concernant la notion "membre" et "non-membre", il faut savoir que toutes les surcharges d'opérateurs peuvent être implémenter en tant que "membre" mais pas toujours en tant que "non-membre". Quand les 2 cas sont possible, ce sera à nous de choisir et la documentation nous sera d'une bonne aide.

 

Exemple 1 : opérateur "non-membre"


Bien, prenons un exemple, imaginons que nous ayons une structure représentant un point et nous voulons afficher les coordonées x et y de ce point :

#include <iostream>

struct Point
{
    Point(int posX = 0, int posY = 0) :
        x{posX}, y{posY}
    {}

    int x{};
    int y{};
};

int main()
{
    Point p{4, 2};
    std::cout << "x = " << p.x << ", y = " << p.y << '\n';

    return 0;
}

Ici, la ligne qui va nous intéresser c'est celle-ci :

std::cout << "x = " << p.x << ", y = " << p.y << '\n';

Comme on peut le voir, si on souhaite afficher les coordonnées d'un point, il faut fournir des variables ayant un type connu par l'opérateur de flux <<. Donc dans notre cas, x et y qui sont des int.

Lorsqu'il s'agit d'afficher un point ce n'est pas vraiment embêtant mais... avec plusieurs points... l'opération peut vite devenir redondante et à la place on aimerait bien pouvoir passer notre point à l'opérateur de flux puis qu'il se débrouille avec pour afficher ce qu'on souhaite.

De cette manière :

Point p{4, 2};
std::cout << p << '\n';

Et bien... c'est là que la surcharge intervient !

Les opérateurs sont en faites des fonctions avec une "tête" particulière, autrement dit leur syntaxe est légèrement différente des fonctions "normales" :

/////////////////////////
// FONCTION NORMALE

/* type de retour */ /* nom de la fonction */ (/*...paramètre(s)...*/);

/////////////////////////
// FONCTION D'OPÉRATEUR

/* type de retour */ operator/*symbol*/ (/*...paramètre(s)...*/);

La seule différence se situe au niveau du nom, en effet pour un opérator nous ne mettons pas un nom mais le mot operator directement suivi de son symbol.

Bon maintenant que l'on sait à quoi ressemble une fonction d'opérateur, essayons de surcharger l'opérateur de flux de sortie : <<.

Il nous faut donc trouver la fonction libre de l'opérateur de flux de sortie << basé sur l'objet std::cout (cf. std::basic_ostream) car c'est avec lui que l'on va traiter. Pour se faire, jetons un oeil à la documentation :

https://en.cppreference.com/w/cpp/io/basic_ostream/operator_ltlt2

Au premier abord cela peut faire peur mais ce qui nous intéresse ici ce n'est pas de trouver la bonne fonction (ce sont des surcharges du standard C++) mais plutôt de regarder la "tête" de la signature de ces fonctions !

Ici on distingue 3 choses au niveau de la signature :

1. Le type de retour :

basic_ostream<CharT,Traits>&;

Cela correspond à un objet de flux de sortie donc dans notre cas nous utiliserons std::ostream qui représente le type de l'objet std::cout (pour plus d'informations sur le sujet : https://en.cppreference.com/w/cpp/io/basic_ostream).

On notera que le retour est une référence, il faudra donc faire de même.

2. L'opérateur de flux de sortie :

operator<<

3. Deux paramètres en entrée :

(basic_ostream<CharT,Traits>& os, /* une donnée de type char */)

Le premier correspondant à un objet de flux de sortie passé par référence (dans notre cas ce sera std::ostream) et le second à une donnée de type char qui sera remplacé par notre type de donnée perso Point.

Bien ! Maintenant que l'on sait à quoi ressemble la signature de cette fonction d'opérateur, comment peut-on faire pour surcharger celui-ci et lui faire accepter notre nouveau type Point ? Et bien il suffit de créer une nouvelle version cette fonction en y indiquant les objets que l'on souhaite utiliser, à savoir std::ostream et Point, ce qui nous donne :

std::ostream& operator<<(std::ostream& os, const Point& p)
{
    // ...
}

Donc ici, mis à part le type de donnée qui a changé, la structure de la signature reste la même (un retour par référence + 2 paramètres).

Note : Ici l'objet Point est passé par référence constante ce qui évite une copie inutile et indique aussi que p ne sera pas modifié dans le corps de la fonction.

Maintenant que l'on a défini notre propre surcharge de l'opérateur de flux <<, il faut lui indiquer quoi faire avec le nouveau type de donnée Point et pour se faire rien de plus simple, on utilise le premier argument comme si on utilisait std::cout puis on retourne celui-ci :

std::ostream& operator<<(std::ostream& os, const Point& p)
{
    os << "x = " << p.x << ", y = " << p.y;
    return os;
}

Et voilà le tour est joué ! On peut désormais passer une variable de type Point à l'opérateur de flux de sortie et il saura quoi en faire :

Point p{4, 2};
std::cout << p << '\n';

/**************

Output :

x = 4, y = 2

***************/

Encore plus fort, on peut aussi chaîner les points sans problème ! Comme on le ferait avec des types natifs :

Point p1{4, 2};
Point p2{13, 37};

std::cout << p1 << '\n'
          << p2 << '\n';

 

Exemple 2 : opérateur "membre"


Ici nous prendrons le cas d'un opérateur où il est préférable pour la surcharge d'être membre d'une structure ou d'une classe : l'opérateur +=.

Maintenant regardons un peu le tableau des opérateurs, il nous indique 3 choses :

Le type d'opérateur qui nous intéresse dans notre cas :

a@b

En tant que fonction membre :

(a).operator@(b)

En tant que fonction non-membre :

operator@(a, b)

Donc ici ce qui nous intéresse c'est la version en tant que membre, mais pourquoi ne pas utiliser la version non-membre ? Et bien c'est simplement une question de bonne pratique ! Comment peut-on le savoir ? Et bien en lisant la documentation, encore et toujours :).

Et donc cette documentation nous dit que pour les opérateurs arithmétiques binaires (+, -, *, /, etc.), il est de bonne augure de les implémenter en tant que "non-membre" pour conserver une certaine symétrie tandis que pour les opérateurs arithmétiques d'affectation composée (+=, -=, *=, /=, etc.) il est préférable de les implémenter en tant que fonction membre, ce qui permet la modification des données privées de la structure ou de la classe.

Passons maintenant à l'implémentation de la surcharge de l'opérateur += et pour ne pas s'embêter nous allons reprendre notre structure Point.

Pour se faire regardons d'abord la syntaxe de base, on aimerait pouvoir faire ça :

p1 += p2

Ce qui pourrait se traduire, si on en suit la syntaxe en tant qu'opérateur membre, par ceci :

p1.operator+=(p2);

Et bien... allons-y ! Définissons cette surcharge dans notre structure :

struct Point
{
    Point(int posX = 0, int posY = 0) :
        x{posX}, y{posY}
    {}

    int x{};
    int y{};

    Point& operator+=(const Point& rhs)
    {
        x += rhs.x;
        y += rhs.y;
        return *this;
    }
};

Dans un premier temps, l'argument rhs (Right Hand Side en anglais) correspond à la variable se situant à droite de l'opérateur, rien de bien compliqué ici.

En revanche, on peut se demander pourquoi renvoyer une référence vers l'objet lui-même ? Et bien le fait de renvoyer quelque chose permet de "chaîner" les opérateurs et dans notre cas, étant donné que l'opérateur modifie l'état interne de l'objet se situant à sa gauche, c'est donc une référence que nous devons renvoyer (nous verrons dans la partie Bonus que ce n'est pas toujours une référence qui doit être renvoyée).

Et voilà ! On peut maintenant utiliser l'opérateur += avec notre type Point :

p1 += p2;
p1 += p2 += p3 += p4;

 

Bonus : Surcharge de l'opérateur arithmétique "+"


Si j'ai choisi celui-ci ce n'est pas un hasard, nous verrons pourquoi ci-après :).

D'abord première question que l'on se pose : "membre" ou "non-membre "? Et bien... comme d'habitude : la documentation.

Donc ce sera "non-membre" pour l'opérateur + !

On rappel la syntaxe pour une fonction d'opérateur "non-membre" :

operator@(a, b)

Ce qui nous donne :

Point operator+(Point lhs, const Point& rhs)
{
    lhs += rhs;
    return lhs;
}

Ici rien de bien compliqué, on renvoi le résultat de l'addition de la variable de gauche avec celle de droite et petit plus, on a réutilisé l'opérateur += que l'on a surchargé précédemment ! C'est la raison pour laquelle j'ai choisi de surcharger l'opérateur + :p.

Regardons maintenant ce que tout cela donne dans un code complet :

#include <iostream>

struct Point
{
    Point(int posX = 0, int posY = 0) :
        x{posX}, y{posY}
    {}

    int x{};
    int y{};

    Point& operator+=(const Point& rhs)
    {
        x += rhs.x;
        y += rhs.y;
        return *this;
    }
};

Point operator+(Point lhs, const Point& rhs)
{
    lhs += rhs;
    return lhs;
}

std::ostream& operator<<(std::ostream& os, const Point& p)
{
    os << "x = " << p.x << ", y = " << p.y;
    return os;
}

int main()
{
    Point p1{1, 1};
    Point p2{1, 1};
    Point p3{1, 1};

    p1 += p2;
    p2 = p1 + p2 + p3;

    std::cout << p1 << '\n'
              << p2 << '\n'
              << p3 << '\n';

    return 0;
}

Et voilà ! Pour conclure, je dirai qu'au premier abord la surcharge d'opérateur peut semblait difficile mais avec le temps et la pratique, on ne peut qu'apprécier l'utilité de cette technique indispensable :).