Surcharger fonctions et opérateurs

Modifier une fonction sans remettre en cause l'existant

Il est fréquent, dans un processus de développement, de se trouver confrontés au problème suivant: Lors du codage de la version initiale du programme, nous avons utilisé une fonction f(X), où X est un entier. Or, justement dans la seconde version du programme, nous sommes capables de travailler non plus seulement avec des entiers, mais aussi avec des complexes. Il nous faudra donc une fonction f(X), où X est un nombre complexe. apres Autre situation fréquente: l'algorithme de f s'est un peu compliqué, et maintenant nous avons besoin de passer à f un second paramètre... apres Comment allons-nous faire ? Deux solutions si nous travaillons en C:

Surcharge de fonctions

En C++, il est possible de déclarer et définir plusieurs fonctions ayant même nom, à condition que les listes de leurs arguments diffèrent: cela résout en partie le problème que nous avons évoqué plus haut, comme on le voit ci-dessous:

float fonction (float x) {                   
   float y = 3 * x + 2;
   return y;
}

complexe fonction (const complexe & x) {
   complexe y(0,0);
   y.set_r (3*x.get_r() + 2);
   y.set_i (3*x.get_i());
   return y;
}

ATTENTION Il n'est pas possible de surcharger une fonction par une autre fonction qui aurait même nom et même liste d'arguments mais une valeur de retour différente.

ATTENTION Rien ne garantit que les deux versions de la fonction f ci-dessus font la même chose: c'est au programmeur de s'en assurer, afin que le code reste compréhensible.

ATTENTION Ce mécanisme est extrêmement puissant, en ce sens qu'il va nous permettre de donner un même nom à plusieurs fonctions, travaillant sur des paramètres de types différents. Mais comme souvent, ce qui donne de la simplicité à l'homme est source de complication pour la machine... il n'est pas toujours évident pour le compilateur de décider quelle version de la fonction sera utilisée. Il peut même y avoir parfois ambiguïté. D'où l'existence de règles de surcharge, qui ne seront pas explicitées ici.

Surcharge et constructeurs: le constructeur de copie

Un constructeur avant est une fonction "presque" comme une autre... donc, il n'y a pas de raison pour qu'on ne puisse pas la surcharger. La surcharge du constructeur permet de fournir plusieurs possibilités d'initialisation, à partir de plusieurs types d'objets.

Un de ces constructeurs est particulièrement important: il s'agit du constructeur de copie, qui va nous permettre d'initialiser un objet à partir d'un autre objet de la même classe.

class complexe {
public:
   complexe(float x,float y) : r(x), i(y) {};
   complexe(const complexe& c) : r(c.r),i(c.i) {
       cout << "ici constructeur de copie de complexe" << endl};
private:
   float r;
   float i;
   ...
}

main() {
   const complexe j(0,1);
   complexe A=j;
}

ATTENTION Attention au prototype du constructeur de copie. En particulier, le passage par référence est indispensable: si l'on essaie de passer l'objet par valeur, on demande au compilateur de faire une copie de l'objet afin de la passer au constructeur de copie. Comme en C++ les appels de fonctions sont récursifs, le constructeur de copie va s'appeler lui-même jusqu'à épuisement de la mémoire.

REGLE  D'OR De même que le langage offre un constructeur par défaut, de même il offre un constructeur de copie par défaut. Celui-ci fait tout simplement une copie membre à membre. Lorsque le constructeur par défaut est suffisant, utilisez celui-ci. Mais lorsque le constructeur doit aussi faire autre chose (comme dans l'exemple ci-dessus), vous devez fournir un constructeur de copie.

top


Valeurs par défaut des arguments

Dans une définition de fonction, il est possible de spécifier des valeurs par défaut à chaque argument. Il s'agit là encore d'un moyen très puissant pour modifier une fonction sans tout remettre en cause; Soit par exemple le code suivant:

float mult (float x) {
    return 2 * x;
};

main() {
   ...
   float y = f (4.5);
};

Supposons qu'on désire modifier la fonction mult afin qu'elle soit capable de multiplier son argument par n'importe quel nombre entier, et pas seulement 2. L'ancienne version correspondrait toujours à une multiplication par deux. Nous donnons donc 2 comme valeur par défaut au second paramètre, ce qui s'écrit: float mult (float x, int m=2);. A partir de là, seront acceptés:

Voilà ce que cela donne dans notre exemple:

float mult (float x, int m=2) {
    return  m * x;
};

main() {
   ...
   float y = f(4.5);         // meme resultat que ci-dessus
   float z = f(4.5,3);       // cela etait impossible avec la version precedente
};

ATTENTION Les arguments ayant des valeurs par défaut se trouvent obligatoirement en fin de liste: sinon, le compilateur n'aurait aucun moyen de savoir de quels arguments vous parlez (il n'y a pas, en C++, de possibilité de fournir des arguments nommés, comme en perl ou en fortran 90).

Valeurs par défaut et constructeurs

Nous avons eu précédemment avant quelques ennuis avec le constructeur de la classe complexe, tel qu'il était défini alors. La solution à nos problèmes est toute simple: il suffit d'utiliser des valeurs par défaut pour les paramètres passés au constructeur. Voici le code:

class complexe {
private:
  ...
public:
  complexe(float x=0, float y=0) {r=x;   i=y; _calc_module();};
  ...
};

main() {
  complexe C;        // sous-entendu initialiser a 0
  complexe C1(2);    // sous-entendu initialiser a (2,0) [reel]
  complexe C2(2,2);
}

Une facilité d'écriture

Le code ci-dessus permet d'écrire:

main() {
   complexe A(5);
   complexe B=5;
   complexe C;
   complexe D = { 1.23, 4.56 };
};

Le mot-clé explicit

La facilité d'un jour devient handicap le lendemain: en effet, revenons sur la classe tableau ( avant); Puisque le constructeur ne comporte qu'un seul paramètre, nous pouvons écrire le code suivant:

main() {
   tableau B = 1024;
}

Cela signifie que le compilateur génèrera automatiquement des conversions d'entier vers tableau partout où ce sera nécessaire: si cette conversion a un sens c'est une très bonne chose, et une grande souplesse. Mais si elle n'a pas de sens, on va se retrouver avec des erreurs lors de l'exécution du code. Le mot-clé explicit permet de s'assurer que cette erreur sera découverte à la compilation et non pas à l'exécution.

class tableau {
  ...
public:
  explicit tableau(int);                                              
};

top


Surcharger les opérateurs

Lorsque nous écrivons le code suivant, en C:

int A=2;
int B=3;
int C;
double A1=2.1;
double B1=3.1,
double C1;
main() {
   C = A + B;
   C1= A1+B1;
}

Nous utilisons la surcharge des opérateurs "sans le savoir", tel M.Jourdain faisant de la prose. En effet, du point-de-vue des instructions en langage machine, l'opérateur + ne produira pas le même code dans la première et dans la seconde ligne. Dans le premier cas, on fait une addition en arithmétique entière, dans le second cas on fait l'addition en arithmétique flottante.
Le C++ permettra de donner une signification à l'opérateur + (ainsi qu'à tous les opérateurs du langage) spécifique pour chaque classe définie.

Opérateurs et fonctions

L'expression: C = A + B peut être vue comme une manière différente d'écrire un appel de fonction. En effet, on pourrait aussi écrire: C = add(A,B) Le résultat serait le même que l'expression ci-dessus, mais le code nettement moins lisible. Le C++ respecte tout simplement la convention suivante: lorsqu'il rencontre une instruction C = A + B, il exécute en réalité l'instruction C = operator+(A,B).

La fonction operator+ doit accepter deux paramètres de type complexe en entrée, et elle doit renvoyer également un complexe, d'où le prototype suivant:

complexe operator+(const complexe&, const complexe&);

L'addition de trois complexes peut s'écrire D = A + B + C soit (l'opérateur + étant associatif à droite): D = A + (B + C), ou encore D = A + operator+(B,C) soit D = operator+(A,operator+(B,C)) Il va sans dire que la première écriture est bien plus compréhensible que la dernière, cependant il est bon de l'avoir présente à l'esprit, en particulier lorsqu'on définit le prototype de la fonction.

La forme utilisant un appel de fonction et la forme utilisant les opérateurs sont équivalentes. Simplement, la surcharge des opérateurs va permettre à l'utilisateur de nos objets d'écrire un programme plus élégant.

ATTENTION Il ne s'agit pas de créer de nouveaux opérateurs, il s'agit bien de surcharger les opérateurs existants. Ni plus, ni moins. Les règles de priorité et d'associativité définies pour les opérateurs du langage s'appliquent également aux opérateurs surchargés.

Opérateurs: le bestiaire

Les tables ci-dessous indiquent:

Principaux opérateurs du C++
Opérateurs signification Surcharge Intérêt de la surcharge
:: Résolution de portée NON  
. Sélection de membre NON  
+=
-=
*=
/=
%=
Opérateurs unaires arithmétiques. OUI Opérations arithmétiques unaires et performantes
+
-
*
/
%
Opérateurs binaires arithmétiques. OUI Opérations arithmétiques binaires
++
--
Incrémentation, décrémentation OUI Itérateurs
= Opérateur d'égalité. OUI Clônage entre deux objets.
>>
<<
Décalage à gauche ou à droite OUI entrée
sortie.
[] Accès aux membres d'un tableau OUI Indiçage généralisé
() Appel de fonction OUI Objets-fonctions
! Opération logique OUI Permet de comparer un objet à true/false
==
!=
Egalité, non égalité OUI Egalité, non égalité entre deux objets
>
<
>=
<=
Inégalités OUI Inégalités
->
->*
.*
Sélection de membre depuis un ou vers un pointeur. OUI Contrôle de l'accès aux membres
&
*
Pointeur, référence. OUI Objets à comptage de référence, itérateurs
int
long
short
float
double
etc.
Conversion de types OUI Conversion vers un type prédéfini depuis un objet

ATTENTION Vous pouvez mettre n'importe quoi dans le code. Rien (sinon votre bon sens) ne vous empêche de mettre une multiplication dans un opérateur +. Autrement dit, c'est un jeu d'enfant de faire dire à un programme C++: 16 = 4 + 4... Mais bien sûr ce n'est pas fait pour cela ! Au contraire, le seul intérêt de la surcharge des opérateurs est que les utilisateurs de vos objets pourront écrire des programmes plus clairs. Utilisez la dernière colonne du tableau ci-dessus afin de surcharger vos opérateurs à bon essient.

Priorité et associativité des opérateurs
Opérateurs (par priorité descendante) Associativité
() [] -> .   -->
! ~ ++ -- + - * & (int) sizeof   <--
* / %   -->
+ -   -->
<< >>   -->
< <= > >=   -->
== !=   -->
&   -->
^   -->
|   -->
&&   -->
||   -->
?:   <--
= += -= *= /= %= &= ^= |= <<= >>= <--
,   -->

Fonction membre ou fonction ordinaire ?

REGLE  D'OR Faut-il spécifier un opérateur comme une fonction-membre ou comme une fonction ordinaire (éventuellement amie) ? La règle générale est la suivante:

En effet, un opérateur unaire modifie par nature l'objet sur lequel il opère (a +=3 modifie a). Il est donc cohérent d'en faire une fonction-membre. Un opérateur binaire, par contre, opère sur deux objets. En faire une fonction-membre revient à "privilégier" de manière arbitraire l'un des deux objets. Au mieux c'est incohérent, au pire cela ne fonctionnera pas.

Les quatre opérations

Le code suivant montre une implémentation de l'opérateur += sur la classe complexe. += est implémenté en tant que fonction membre:

class complexe {
private:
  ...
public:
  ...
  complexe& operator+= (const complexe&);
};

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

Le code suivant montre l'implémentation de l'opérateur +, qui est simplement une fonction ordinaire, prenant deux complexes comme paramètres, et renvoyant un autre complexe:

complexe operator+(const complexe& a, const complexe& b) {
  complexe r=a;
  r += b;
  return r;
};

Nous avons défini deux opérateurs (+ et +=), mais seul l'un d'entre eux (+=) accède aux données privées. Cela signifie que si nous modifions l'implémentation de complexe (hypothèse réaliste, nous avons déjà vu trois implémentations différentes) nous n'aurons qu'un seul opérateur à modifier: moins de travail, surtout moins de risque d'erreur.

ATTENTION Attention aux types de retour des opérateurs: en effet, operator+= renvoie un complexe&, tandis que operator+ renvoie simplement un complexe. Pourquoi ? Il est toujours préférable de renvoyer une référence plutôt qu'un objet, pour des questions de performances: en effet, renvoyer un objet signifie effectuer une copie, opération éventuellement longue pour des objets volumineux, alors que renvoyer une référence signifie renvoyer simplement... une adresse. Opération très rapide, et indépendante de la taille de l'objet. C'est ainsi que operator+= renvoie une référence. Par contre, operator+ renvoie un complexe. Ce serait en effet une erreur dans ce cas de renvoyer une référence, car celle-ci pointerait sur une variable locale avant. Cela a d'ailleurs une conséquence dans le code que nous écrirons lors de l'utilisation de ces opérateurs: ainsi il sera plus performant d'écrire a += b que d'écrire a = a + b , bien que les deux écritures soient autorisées et signifient la même chose. C'est vrai dès que a et b sont des objets.

Les opérateurs d'incrémentation ou décrémentation

Les opérateurs ++ et -- peuvent bien sûr être surchargés, cependant un problème se pose: en C comme en C++, les versions prédéfinies de ces opérateurs peuvent être:

L'opération est la même, simplement la valeur de retour sera différente:

Il est possible (et même recommandé) d'utiliser la même distinction avec des opérateurs surchargés. La convention adoptée par le langage est d'effectuer deux déclarations de fonctions différentes:

REGLE  D'OR Dans le cas de l'opérateur postfixé, on doit:

d'où surcoût (qui peut ne pas être négligeable, suivant la taille de l'objet). Moralité: utilisez toujours la version préfixée, sauf nécessité absolue.

Les opérateurs ++ et -- servent à définir des itérateurs après.

L'opérateur d'affectation

Affectation n'est pas initialisation

Dans ce paragraphe on utilisera la fonction suivante:

tableau renvoie_tableau() {
    tableau t = { 1,2,3 };
    return t;
}

NOOONEn dépit des apparences, les quatre lignes de code marqués 1 à 4 ne sont pas équivalentes.

tableau t1 = {4,5,6};
tableau t2 = {7,8};

1- tableau t3 = t1;
2- t3 = t2;

3- tableau t4 = renvoie_tableau();
4- t3 = renvoie_tableau();

En effet on a écrit ici:

  1. Une déclaration de variable avec initialisation
  2. Une affectation (la variable t3 existait déjà)
  3. Une déclaration de variable avec initialisation à partir d'une rvalue
  4. Une affectation à parti d'une rvalue

A ces quatre opérations peuvent correspondre quatre fonctions-membres différentes.

class tableau {
public:
  explicit tableau(int);
  tableau(const tableau&);
  tableau& operator=(const tableau&);
  ~tableau() {free buffer;};
  
private:
  int taille;
  char* buffer;
  void copie(const tableau&);

};

void tableau::copie(const tableau& b) {
  ... copier le buffer de b ...
};

tableau::tableau(int t) {
  buffer = malloc(t * sizeof(char));
};

// Pour la ligne 1-
tableau::tableau(const tableau& b) {
  taille = b.taille; 
  buffer = malloc(taille * sizeof(char)); 
  copie(b);
};

// Pour la ligne 2-  
tableau& tableau::operator=(const tableau& b) {
  if (this !=&b)
  {
      free(buffer);
      buffer = malloc(taille * sizeof(char)); 
      copie(b);
  }
  return *this;
};

// Pour la ligne 3- (en C++11 !!!)
tableau::tableau(tableau&& b) noexcept {
  taille = b.taille; 
  buffer = b.buffer;
  b.taille = 0;
  b.buffer = nullptr;
};

// Pour la ligne 4- (en C++11 !!!)
tableau& tableau::operator=(tableau&& b) noexcept {
  if (this !=&b)
  {
    free(b.buffer);
    taille = b.taille; 
    buffer = b.buffer;
    b.taille = 0;
    b.buffer = nullptr;
  }
  return *this;
};
Auto références

Il est important de prévoir, dans les opérateurs d'affectation, le cas a priori stupide où une variable est affectée à elle-même: cela est un cas de figure tout-à-fait possible, par le jeu des pointeurs et des références. Or, operator= risque alors de provoquer un plantage (on croit traviller sur l'objet destination, alors qu'on travaille aussi sur l'objet source: D'où dans le code ci-dessous le if (this != &b).

noexcept

Le mot noexcept situé à la fin de la déclaration est important, il sera expliqué au chapitre sur les exceptions après

Le trio infernal et le quintette infernal

Le trio infernal est constitué par les trois fonctions suivantes:

Si l'une de ces trois fonctions est inexistante, le compilateur en produira une version par défaut. Dans le cas du constructeur de copie ou de l'affectation, la version par défaut consiste en une simple copie membre à membre. De sorte que de nombreuses classes se contentent de la version fournie par défaut.

REGLE  D'OR Si vous fournissez l'une de ces trois fonctions, fournissez les trois. En effet, cela signifie que votre objet gère des ressources (mémoire, connexions réseau, etc.). Les versions proposées par le compilateur ne s'occuperont pas de la gestion des ressources, d'où probablement de gros soucis lors de l'exécution du code.

Pour le quintette infernal, ajoutez les deux fonctions suivantes:

Si vous ne fournissez pas ces deux fonctions le programme fonctionnera tout de même car on peut toujours remplacer un déplacement par une copie. Simplement, étant moins optimisé, il risque d'avoir des performances inférieures.

Supprimer une fonction-membre du quintette (C++11)

Lorsque l'on écrit un objet, on peut empêcher les utilisateurs de l'objet en question d'utiliser copie et constructeur de copie: pour cela, il suffit de les définir comme supprimés en utilisant le mot-clé delete

class non_copiable {
private:
   non_copiable(const non_copiable&)=delete;
   non_copiable& operator=(const non_copiable&)=delete;
   ...

D'où une nouvelle définition du trio ou quitette infernal: Si on doit définir ou supprimer une fonction du quintette, on doit les définir ou supprimer toutes.

Nous en reparlerons dans le chapitre sur l'héritage.après.

Le principe de responsabilité unique et la règle du zéro

Bonne pratique: Les classes définies dans vos programmes (C++ ou pas) doivent obéir au principe de "responsabilité unique": une classe doit être responsable d'une seule chose. Or, les seules classes pour lesquelles nous devons fournir un constructeur et un destructeur qui ne soit pas le défaut sont les classes responsables d'une ressource.

Sauf que ces classes sont déjà écrites ! (elles sont disponibles dans la stl)

D'où la règle du zéro:

Sur ces questions liées à la conception du code, voir ici: https://fr.wikipedia.org/wiki/Principe_de_responsabilit%C3%A9_unique

top


Conversions et opérateurs

Conversions vers une classe

Nous avons défini un opérateur +, mais celui-ci ne nous permet que d'ajouter deux complexes entre eux. Et pourtant, le code suivant est valide:

complexe A(1,1);
float B=1;
complexe C;
C = A + B;
C = B + A;

En fait, dans un cas comme celui-ci, le compilateur cherche à effectuer des conversions de types. Puisque nous avons défini des valeurs par défaut pour les paramètres du constructeur avant, le compilateur sait générer un complexe à partir d'un flottant. Il sait donc faire une conversion de types flottant vers complexe. Toutes les conversions de types seront donc traitées à l'aide de constructeurs surchargés.

Conversions depuis une classe

Cependant, comment allons-nous effectuer une conversion de type depuis la classe complexe vers un type de base du langage ? La technique ci-dessus ne le permet pas, car le compilateur ne peut deviner ce que signifierait une telle conversion. Nous allons alors définir, puis utiliser, un opérateur de conversion. Dans le cas des nombres complexes, par exemple, nous pourrions considérer qu'une conversion d'un complexe vers un flottant consiste à prendre la partie réelle du complexe. D'où la définition suivante:

class complexe {
     public:
       ...
       operator float() {return r;};
     private:
       ...
     };

A partir de maintenant, on peut faire une conversion de type comme on a l'habitude en C++:

...

complexe J(1,0);
float I = static_cast<int>(J);

ATTENTION Lorsqu'on définit un operator type(), il ne faut pas spécifier de type de retour. C'est un peu bizarre, mais assez logique, compte-tenu du fait que le type est déjà spécifié dans le nom de l'opérateur lui-même.

Autres opérateurs

Certains opérateurs seront évoqués un peu plus loin:

Trois nuances d'objets-fonctions

Ce paragraphe présente brièvement trois manières de déclarer des fonctions (ou des choses qui y ressemblent) et de passer ces fonctions en paramètres à d'autres fonctions. Cela est très utilisé par les objets de la Stl, par exemple (fonctions de tris, de filtres, etc.)

Pour illustrer ces trois nuances, nous utiliserons un petit programme qui fait appel à l'algorithme all_of de la stl: il s'agit d'un algorithme générique, qui travaille sur un ensemble d'objets. Pour chaque objet, un "prédicat unaire" est appelé. Un prédicat unaire est une fonction à un seul paramètre (unaire), qui renvoie un booléen (prédicat).

all_of renverra true si tous les appels ont renvoyé true.

Dans notre exemple, le prédicat consiste à déterminer si l'entier passé en paramètre est supérieur à une certaine valeur, appelée seuil. Donc on cherche à vérifier si, dans une collection d'entiers, il en existe au moins un qui dépasse le seuil.

Pointeurs de fonctions C

A éviter !

En C, on peut définir des pointeurs sur des fonctions, en passant le pointeur à une fonction en tant que paramètre d'entrée, on passe la fonction

Voir ici la version pointeur de fonctions de notre programme, télécharger ici

Cette version présente plusieurs inconvénients: la variable seuil est obligatoirement déclarée en tant que variable globale. La fonction a elle aussi une portée globale.

Fonctions objets

C++ moderne

En C++, il est préférable de définir un objet-fonction, c'est-à-dire un objet doté d'une fonction appelée operator().

Voir ici la version objet fonctions de notre programme, télécharger ici

Cette version a un avantage majeur sur le pointeur de fonction: on peut tout mettre dans la portée locale, aussi bien la déclaration de classe que la variable seuil. Elle permet de passer le seuil par l'intermédiaire du constructeur de l'objet, ce qui est nettement plus "propre" qu'utiliser une portée de variable globale. Néanmoins, la syntaxe reste assez lourde, clairement trop dans notre cas particulier, qui nécessite une déclaration de fonction triviale.

Fonctions lambdas (depuis C++11)

C++ moderneC++ moderne

En C++, depuis la norme 2011, on peut déclarer des fonctions "anonymes", appelées fonctions lamdas.

Voir ici la version lambda de notre programme, télécharger ici

Cette version est moins lourde que la précédente, on voit que la définition de la fonction lamba se fait "en ligne" dans le passage de paraèmtres, seulement lorsqu'on en a besoin. Très pratique pour de petites fonctions triviales à utiliser avec les algorithmes de la stl.

Une fonction lambda n'a pas de nom. On ne spécifie pas le type de retour, le compilateur va le deviner grâce à l'instruction return. Et elle a une spécification supplémentaire: [ ], qui permet de spécifier explicitement ce qu'on fait des variables disponibles lors de l'appel:

Possibilités de panachage, voir ici

top


xhtml Licence Creative Commons Emmanuel Courcelle <emmanuel.courcelle@inp-toulouse.fr>