Voir ../Orthogonal/Etre-OO.html pour plus d'informations sur le sens à donner à ces relations.
La plupart des langages OO supportent les relations d'héritage (distinguant parfois l'héritage d'interface et l'héritage d'implémentation), de composition, d'agrégation, d'association, etc. Certains langages supportent aussi d'autres types de relations. En particulier, avec C++, on trouve la relation d'amitié, associée au mot-clé friend, qui donne des privilèges particuliers à une classe ou à une fonction.
L'amitié est une relation à fort couplage, ce qui lui donne parfois mauvaise presse, mais contrairement à certaines croyances populaires, c'est une relation qui, quand elle est bien utilisée, accroît l'encapsulation plutôt que la réduire.
Prenons un schéma de conception bien connu, soit le schéma de conception Fabrique, par lequel une entité (fonction, classe, objet) a pour responsabilité d'instancier un objet. Il existe plusieurs raisons pour avoir recours à cette technique; dans notre cas, imaginons que nous souhaitons une instanciation en deux temps : création de l'objet, suivi d'un post-traitement exigeant que l'objet ait été pleinement créé, donc que le constructeur ait été complété.
Par exemple, l'ébauche de code proposée à droite rend la classe FabriqueMachin responsable d'instancier Machin à travers sa méthode de classe creer(), et de s'assurer que ce Machin soit déplacé dans un thread qui exécutera sa méthode agir() concurremment avec le reste du programme. Le recours à la fabrique ici est dû à l'instanciation en deux temps du Machin (construire, puis déplacer dans un thread). Ce programme, tout simple, comporte toutefois une faille architecturale :
Ce que nous voulons vraiment, en fait, c'est que seule FabriqueMachin puisse instancier Machin. Pour cela, il nous faut (a) que le constructeur paramétrique de Machin soit privé, mais (b) qu'outre Machin elle-même, seule la classe FabriqueMachin y ait accès. En termes techniques : nous voulons que Machin déclare que FabriqueMachin est son amie. |
|
Le changement est simple :
Avant | Après |
---|---|
|
|
Avec la version utilisant l'amitié, FabriqueMachin peut instancier Machin, Machin peut (bien sûr) instancier Machin, mais personne d'autre ne le peut car le constructeur paramétrique est privé. Nous sommes donc plus près de notre intention initiale, et nous amélioré l'encapsulation en réduisant l'éventail d'entités pouvant accéder à ce constructeur.
L'amitié, associée au mot clé friend, est souvent décriée dans les milieux OO. On associe souvent ce concept à l'idée de bris d'encapsulation. Pourtant, si on applique le concept avec sagesse, il peut au contraire s'agir d'une manière d'éviter un bris d'encapsulation.
Voyons un peu de quoi il s'agit.
Notez qu'une déclaration friend peut introduire un nom, à la manière d'une déclaration a priori. Par exemple :
class X {
friend class Y; // déclare la classe Y et en fait une amie de X
// friend Z; // illégal car Z n'est pas encore déclarée
};
class Z {
};
class Y {
friend Z; // Ok, Z est déclarée plus haut
// (on aurait aussi pu écrire friend class Z ici)
};
Il faut immédiatement établir, à la lecture de cette définition, que la mention friend ne force pas un bris d'encapsulation dans une classe. En effet, une fonction ou une autre classe ne peut pas s'imposer ami(e) d'une classe, la mention friend devant être incluse à même la déclaration de la classe qui s'ouvre à ses amis.
Un ami d'une classe donnée fait partie de l'interface de cette classe, au même titre que ses membres publics et que les fonctions qui sont livrées avec elle. Par conséquent, les amis d'une classe devraient l'accompagner et faire partie du même module (.h/ .cpp) qu'elle.
Lorsqu'on présente deux classes, l'une étant l'amie de l'autre, il est en effet fortement préférable de fournir les deux classes dans un même module (même fichier d'en-tête, mêmes sources ou mêmes binaires). On agira ainsi pour éviter qu'un programmeur véreux remplace la classe ayant des droits en tant qu'amie par une autre, et commette par cette classe remplaçante un sérieux bris d'encapsulation. La même remarque s'applique aux fonctions amies, d'ailleurs. À droite, X est ami de Y et peut donc accéder aux membres privés et protégés de cette dernière. La fonction globale fy() a les mêmes privilèges sur Y que la classe X. Notez que la déclaration d'amitié de fy() est un prototype à part entière (pas besoin de le répéter), et celle de X est une déclaration a priori. |
|
Certains utilisent des stratégies OO reposant fortement sur l'amitié, comme par exemple une classe polymorphique servant d'interface et plusieurs classes dérivées dont chaque méthode est privée et qui implémentent les spécialisations des méthodes de leur parent. Dans un tel cas, on trouve, adjointe à chaque classe dont toutes les méthodes sont privées, une fonction amie servant à l'instancier.
Quelques remarques de fond doivent être faites quant à l'amitié dans un modèle OO.
L'amitié n'est pas héritéeAinsi, dans notre exemple plus haut, un dérivé de X ne serait pas, en tant que ce dérivé, l'ami de Y. De même, dans l'exemple à droite, Y est ami de X, mais Z (un enfant de Y) ne l'est pas. Conséquemment, il faut limiter (ou mieux, éviter!) les méthodes virtuelles dans une classe étant l'amie d'une autre; sinon, on dérivera de cette classe une classe malsaine et on pourra procéder à une fraude en passant par cette méthode. Cela permet de voir que l'amitié ne provoque pas a priori de bris d'encapsulation (en fait, lorsqu'elle est bien utilisée, l'amitié renforce l'encapsulation en réduisant l'espace public d'une classe au strict nécessaire). On ne peut contourner la protection d'une classe en se créant un dérivé ad hoc d'un de ses amis. |
|
L'amitié n'est pas symétriqueSi X est amie de Y, cela implique que Y a accès aux membres privés et protégés de X, mais cela n'implique pas en retour que X ait accès aux membres privés et protégés de Y. C'est injuste, diront certains, mais c'est comme ça. Les relations d'amitié se pensent sur une base individuelle et dirigée. L'amitié n'est pas transitiveEn termes simples : l'ami d'un ami n'est pas notre ami à moins qu'on ne l'ait explicitement indiqué au préalable. Dans l'exemple à droite, Y est amie de X, et Z est amie de Y, mais Z n'est pas amie de X. |
|
L'amitié est la relation ayant le plus fort couplage (plus même que l'héritage public!) dans une approche OO. Sachant cela, pourquoi donc voudrait-on la mettre en application?
En fait, on aura recours à l'amitié lorsque l'impact de ne pas y avoir recours serait pire encore. Par exemple :
Utiliser l'amitié avec modération peut être moins dommageable que l'alternative, soit offrir un accès public à un attribut ou à une méthode donnant un accès direct, bien que de manière détournée, à un attribut. De manière générale, l'amitié est préférable à une corruption du visage public d'une classe.
La même démarche intellectuelle se pose pour l'accès à une méthode devant rester privée ou protégée, mais pour laquelle un nombre restreint d'autres classes ou de sous-programmes devraient avoir un accès immédiat.
L'un des aléas de la mention friend est qu'un ami ainsi spécifié a accès à tous les membres privés ou protégés de la classe. Ce n'est pas une mention contrainte à un sous-ensemble de la classe, mais bien une ouverture complète devant l'ami en question.
L'amitié est un mécanisme à utiliser avec une grande prudence. Chaque apparition de cette spécification devrait vous amener à vous interroger sur la légitimité des choix de design qui vous ont mené jusque-là, mais pas nécessairement à les rejeter.
L'amitié est la relation la plus intime que peut entretenir une classe hormis celle résultant de l'exposition publique de ses attributs. Ceci signifie, tel qu'indiqué plus haut dans cette section, que l'amitié est aussi la relation à plus fort couplage que peut entretenir une classe. Sachant cela, tout abus devient malsain.
La manière la plus convenable de percevoir l'amitié est comme extension de l'interface d'une classe. Une classe amie de la classe X devrait être considérée comme faisant partie logiquement de l'interface de X, et il en va de même pour toute fonction amie de X. Par conséquent, les amis d'une classe devraient être insérés dans les mêmes fichiers que cette classe. Les amis doivent être déclarés ensembles et définis ensembles, pour éviter qu'un tiers hostile ne remplace ou ne personnifie l'un sans personnifier l'autre.
Autre exemple : soit une classe X que l'on souhaite instanciable seulement de manière dynamique (avec new). L'un des trucs les plus simples pour y arriver est de déclarer le destructeur de X privé, mais cela pose la bête question « mais comment détruire un X si son destructeur est privé? ». Un truc est d'offrir une autre méthode, par exemple destroy(), qui réaliserait un hara-kiri à l'interne :
class X {
~X() = default; // privé
public:
void destroy() const { delete this; } // hara-kiri
};
int main() {
// X x; // illégal, destructeur privé
X *p = new X;
// delete p; // illégal, destructeur privé
p->destroy();
}
Cependant, utiliser des pointeurs bruts n'est pas idiomatique en C++ moderne, et on préférerait utiliser des pointeurs intelligents, or utiliser un pointeur de méthode d'instance comme destroy() à titre de Custom Deleter dans un std::unique_ptr est... inconfortable. Par contre, utiliser un objet d'un certain type, ou encore utiliser un pointeur de fonction, est plus simple. Pour un pointeur de fonction :
#include <iostream>
#include <memory>
class X {
~X() = default; // privé
public:
friend void assassin(const X*);
};
void assassin(const X *x) { delete x; }
int main() {
using namespace std;
unique_ptr<X, void(*)(const X*)> p{ new X, assassin };
}
... et pour un objet :
#include <iostream>
#include <memory>
class X {
~X() = default; // privé
public:
friend struct Assassin;
};
struct Assassin {
void operator()(const X *p) const { delete p; }
};
int main() {
using namespace std;
unique_ptr<X, Assassin> p{ new X };
}
Une autre utilité directe de la mention friend dans une classe est qu'elle permet d'injecter une définition de fonction globale dans un programme. Pour un exemple concret, imaginons une classe Id qui ne serait modifiable qu'à la construction. Supposons que nous ne souhaitions par exposer la valeur du Id, mais que nous souhaitons qu'il soit possible d'utiliser des Id en tant que clé dans une collection, ce qui requiert de définir operator< pour deux Id.
Une manière simple d'implémenter une ébauche d'une telle classe Id serait :
class Id {
int val;
public:
Id(int val) : val{ val } {
}
bool operator<(const Id &autre) const {
return val < autre.val;
}
// ...
};
Cette approche repose sur une méthode d'instance. Si notre préférence va au respect des usages, il se peut que nous souhaitions définir les opérateurs relationnels binaires sous forme de fonction globale plutôt que sous forme de méthode d'instance, mais cela implique de donner à cette classe un accès privilégié aux membres privés de Id (en particulier, à son attribut val) :
class Id {
int val;
public:
Id(int val) : val{ val } {
}
friend inline bool operator<(const Id &a, const Id &b) {
return a.val < b.val;
}
// ...
};
Ceci crée et injecte dans le programme une fonction globale operator<() applicable à deux Id, et capable d'accéder à ses membres protégés et privés. Une technique pour compléter l'offre d'opérateurs relationnels d'inégalité sur la base d'un opérateur < correctement codé est le truc Barton-Nackman, qui utilise l'idiome CRTP et le mot-clé friend pour générer des services sur demande pour une classe :
template <class T>
class ordonnancement {
friend bool operator>(const T &a, const T &b) {
return b < a;
}
friend bool operator<=(const T &a, const T &b) {
return !(b < a);
}
friend bool operator>=(const T &a, const T &b) {
return !(a < b);
}
};
class Id : ordonnancement<Id> {
int val;
public:
Id(int val) : val{ val } {
}
friend inline bool operator<(const Id &a, const Id &b) {
return a.val < b.val;
}
// ... on gagne >, <= et >=
};
... mais ce truc risque de disparaître avec C++ 20 et l'avènement de l'opérateur <=>.
La bibliothèque standard utilise beaucoup ce qu'on nomme des hidden friends, soit des fonctions déclarées amies d'une classe mais déclarées de manière telle que le seul mécanisme permettant de les accéder soit l'Argument-Dependent Lookup (ADL). Par exemple, ci-dessous, il n'est pas possible de prendre l'adresse de la fonction operator<<(std::ostream&,const N::X&) ou de l'appeler directement, mais elle est implicitement accessible en présence d'un N::X :
#include <iostream>
namespace N {
class X {
friend inline std::ostream &operator<<(std::ostream &os, const X&) {
return os << "X";
}
};
}
int main() {
using namespace std;
using N::X;
X x;
cout << x;
}
Quelques liens pour enrichir le propos.