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 le C++ est un langage récursif, 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;
};

Les deux premières lignes ont exactement la même signification, simplement C=5 est plus parlant.Tout le monde comprend que l'initialisation d'un complexe par un réel donne un complexe avec une partie imaginaire nulle. D'autre part, la troisième ligne conduit à l'initialisation à 0 d'un nombre complexe.

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

Là, il n'est pas du tout évident, lorsqu'on lit le code ci-dessus, que cela signifie "allouer un buffer de taille 1024 octets"... Le concepteur de tableau devrait donc inhiber cette écriture, qui se révèle inadéquate. D'autant plus que cette écriture correspond en fait à une conversion (depuis le type int vers le type tableau), qui en l'occurrence n'est pas souhaitable, et peut provoquer des soucis soit à la compilation, soit à l'exécution. On peut donc inhiber cette conversion implicite en utilisant le mot-clé explicit devant la définition du constructeur:

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

NOOONEn dépit des apparences, les deux lignes de code ci-dessous ne sont pas équivalentes.

complexe A=4;
A=5;

En effet, la première ligne correspond à une déclaration de variable avec initialisation, alors que la seconde ligne correspond à une affectation. L'initialisation est une affectation précédée d'une allocation de mémoire. Dans le cas de l'initialisation, la fonction appelée est le constructeur de copie, dans le cas de l'affectation il s'agit de l'operator=. Afin d'éviter de réécrire du code, la manière habituelle de procéder est de définir une fonction privée de copie, fonction qui sera appelée par le constructeur et par l'opérateur d'affectation. Cela pourrait donner par exemple, pour notre objet tableau:

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

tableau::tableau(const tableau& b) {
  taille = b.taille; 
  buffer = malloc(taille * sizeof(char)); 
  copie(b);
};
  
tableau& tableau::operator=(const tableau& b) {
  if (this !=&b)
  {
    if (taille != b.taille)
      buffer = realloc(buffer,b.taille * sizeof(char));
    copie(b);
  }
  return *this;
};

void main() {
  tableau B(1000);
  tableau A(1000);
  A=B;
};
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).

Le trio 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. Sinon, gros risques de plantages, le compilateur se chargeant de fournir ses versions "à lui" de la ou des fonctions manquantes...

La mise en cage du trio infernal

Lorsque l'on écrit un objet, on peut parfaitement empêcher les utilisateurs de l'objet en question d'utiliser copie et constructeur de copie: pour cela, il suffit de les définir dans la section private. Ainsi, seul l'objet lui-même (c'est-à-dire vous, le concepteur) sera capable de les utiliser. Vous interdisez aux utilisateurs de l'objet toute copie de celui-ci. Dans ce cas, constructeur de copie et opérateur d'affectation peuvent d'ailleurs être des fonctions vides...

class unique {
private:
   unique(const unique&) {};
   unique& operator=(const unique&) {};
   ...

Nous verrons lors du chapitre sur l'héritage une mise en cage un peu moins brutale de l'opérateur = après.

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 = (float)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:

top


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