POO et relation d'amitié – mot clé friend

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.

Mise en situation

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 :

  • Puisque FabriqueMachin et Machin sont deux classes distinctes, FabriqueMachin n'a accès qu'aux membres publics de Machin
  • Ceci force Machin à exposer son constructeur paramétrique de manière publique, or cela nuit à notre design : en étant public, ce service devient disponible partout et à tous les clients, et n'importe qui peut instancier un Machin sans réaliser l'étape de le faire prendre en charge par un thread immédiatement après
  • On pourrait être tenté de faire de FabriqueMachin un enfant de Machin et de réduire un peu l'exposition du constructeur de Machin en le qualifiant de protégé, mais c'est une mauvaise idée : de un, il n'y a pas de réelle raison de traiter un FabriqueMachin comme une sorte de Machin (il n'y a pas de relation d'héritage effective ici; l'imposer ne fait pas de sens); de deux, protected et à peine mieux que public, puisqu'il est possible d'injecter d'autres classes dérivées de Machin à loisir
  • D'autres options existent, comme par exemple cacher Machin dans FabriqueMachin (en faire une classe interne et privée), quitte peut-être à lui faire implémenter une interface particulière, mais ces options changent le design de deux classes distinctes que nous avions mis de l'avant au préalable.

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.

#include <iostream>
#include <thread>
#include <string>
#include <string_view>
#include <utility>
class Machin {
   std::string nom_;
public:
   Machin(std::string_view nom) : nom_{ nom } {
   }
   auto nom() const {
      return nom_;
   }
   void agir() const;
   // ...
};
class FabriqueMachin {
   // ...
public:
   // ...
   static auto creer(std::string_view nom) {
      Machin machin{ nom };
      return std::thread{ [machin = std::move(machin)] {
         std::cout << "Execution en asynchrone de " << machin.nom() << '\n';
         machin.agir();
      } };
   }
};
int main() {
   auto th = FabriqueMachin::creer("R2D2");
   // ...
   th.join();
}

Le changement est simple :

Avant Après
// ...
class Machin {
   std::string nom_;
public:
   //
   // Note : constructeur public (zut!)
   //
   Machin(std::string_view nom) : nom_{ nom } {
   }
   auto nom() const {
      return nom_;
   }
   void agir() const;
   // ...
};
// ...
// ...
class Machin {
   std::string nom_;
   //
   // Note : constructeur privé (yay!)
   //
   Machin(std::string_view nom) : nom_{ nom } {
   }
   //
   // Note : on accorde des privilèges à FabriqueMachin
   // (et on la déclare au passage)
   //
   friend class FabriqueMachin;
public:
   auto nom() const {
      return nom_;
   }
   void agir() const;
   // ...
};
// ...

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.

Le mot clé friend

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.

class Y {
   // ...
   friend class X;
   friend void fy();
   // ...
};
class X {
   // ...
};

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.

Ce que l'amitié n'est pas

Quelques remarques de fond doivent être faites quant à l'amitié dans un modèle OO.

L'amitié n'est pas héritée

Ainsi, 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.

class X {
   int val;
   friend class Y;
};
class Y {
protected:
   X &x;
   // Ok (Y est l'ami de X)
   int f() const {
      return x.val;
   }
public:
   Y(X &x) : x{x} {
   }
};
class Z : public Y {
public:
   Z(X &x) : Y{ x } {
   } // Ok
   int g() const {
      return f(); // Ok
   }
   // illégal (l'amitié n'est pas héritée)
   int h() const {
      return x.val;
   }
};

L'amitié n'est pas symétrique

Si 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 transitive

En 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.

class X {
   int val;
   friend class Y;
};
class Y {
protected:
   X &x;
public:
   Y(X &x) : x{ x } {
   }
private:
   int f() const {
      return x.val;
   }
   friend class Z;
};
class Z {
   Y &y;
public:
   Z(Y &y) : y{ y } {
   }
   // Ok (Z est amie de Y)
   int g() const {
      return y.f();
   }
   // illégal (Z n'est pas amie de X)
   int h() const {
      return y.x.val;
   }
};

Utiliser sainement l'amitié

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

Amitié et injection de fonctions globales

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 <=>.

Les amis cachés (Hidden Friends)

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

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !