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. 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...
Comment allons-nous faire ? Deux solutions si nous travaillons en
C:
f
. Qui dit réécriture du code dit risque
d'ajout d'erreurs.f_compl
, qui prendra un
nombre complexe comme paramètre... faisable, mais illogique,
sachant que f
et f_compl
font
exactement la même chose: si deux fonctions font la même chose,
on a envie de leur donner le même nom. Programmes plus simples à
lire, donc à comprendre, donc à maîtriser.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; }
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.
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.
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.
Un constructeur 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 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.
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.
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:
mult (x)
mult (x,3)
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 };
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).
Nous avons eu précédemment
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); }
Le code ci-dessus permet d'écrire:
main() { complexe A(5); complexe B=5; complexe C; complexe D = { 1.23, 4.56 }; };
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. La facilité d'un jour devient handicap le lendemain:
en effet, revenons sur la classe tableau ( );
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); };
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.
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.
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.
Les tables ci-dessous indiquent:
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 |
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.
Opérateurs (par priorité descendante) | Associativité | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|
() | [] | -> | . | --> | |||||||
! | ~ | ++ | -- | + | - | * | & | (int) | sizeof | <-- | |
* | / | % | --> | ||||||||
+ | - | --> | |||||||||
<< | >> | --> | |||||||||
< | <= | > | >= | --> | |||||||
== | != | --> | |||||||||
& | --> | ||||||||||
^ | --> | ||||||||||
| | --> | ||||||||||
&& | --> | ||||||||||
|| | --> | ||||||||||
?: | <-- | ||||||||||
= | += | -= | *= | /= | %= | &= | ^= | |= | <<= | >>= | <-- |
, | --> |
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:
++
). Fonction membre+
). Fonction ordinaireEn 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.
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 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 . 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 ++
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:
++i
, on incrémente, puis
on évalue le résultat et on le renvoiei++
, on évalue la variable, on
incrémente, mais on renvoie la variable avant incrémentationIl 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:
operator++()
pour la version préfixéeoperator++(int)
pour la version postfixée
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 .
Dans ce paragraphe on utilisera la fonction suivante:
tableau renvoie_tableau() { tableau t = { 1,2,3 }; return t; }
En 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:
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; };
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 mot noexcept situé à la fin de la déclaration est important, il sera expliqué au chapitre sur les exceptions
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.
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.
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..
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
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 ,
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.
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);
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.
Certains opérateurs seront évoqués un peu plus loin:
<<
et >>
sont utilisés
pour les entrées-sorties *
, ->
, ->*
,
new
et delete
permettent de
définir des fonctions avancées de gestion de mémoire []
permet de définir des opérateurs d'accès aux
tableaux.()
permet de simuler des accès à des tableaux
multidimensionnels()
permet également de définir des
objets fonctions: un objet fonction est un objet dont la
seule raison d'être est d'encapsuler un appel de
fonction. cf. les exercices sur ce chapitre pour plus de
détails, et le chapitre sur la bibliothèque standard, qui fait
largement appel à cette notion d'objets fonctionsCe 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.
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.
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.
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