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 le C++ est un langage récursif, 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; };
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.
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; }
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); };
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,C)
.
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 .
En 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; };
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 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. Sinon, gros risques de plantages, le
compilateur se chargeant de fournir ses versions "à lui" de la ou des
fonctions manquantes...
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 = .
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 = (float)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 fonctionsAutres langages objets
Langage | Surcharge d'une fonction | Valeurs par défaut des arguments | Surcharge des opérateurs |
---|---|---|---|
C++ | OUI | OUI | OUI |
perl | Possible (1) | OUI | Possible (2) |
java | OUI | NON | NON |
python | NON | OUI | OUI |
php5 | NON | OUI | NON |
tie
peut être considérée comme une sorte de surcharge
des opérateurs d'accès aux tableaux.