Ce qui suit est un exemple d'implémentation d'un pointeur avec sémantique de partage, sorte de « std::shared_ptr des pauvres »; en pratique, utilisez std::shared_ptr, boost::shared_ptr ou un autre outil qui a été testé et rodé par des tas de gens sur une longue période de temps, pas un exemple jouet comme ce qui suit!
Je vous propose le présent texte pour faire un petit tour d'horizon des enjeux qu'implique le design d'une telle classe (c'est subtil!), et pour mettre en relief quelques pièges qui nous guettent lorsque l'on entreprend une telle tâche.
Nous allons créer le type ptr_partage<T,PS>, où T est le type du pointé et PS est une politique de suppression. Par défaut, la politique de suppression sera d'appliquer delete sur le T* pris en charge par le ptr_partage<T>, ce qui fait que nous allons généralement écrire ptr_partage<T> si le comportement de suppression n'a pas d'importance dans notre discours.
L'idée derrière ptr_partage<T> est que les diverses instances de ptr_partage<T> menant vers un même T partageront ce T. Le dernier ptr_partage<T> menant sur un T donné sera responsable de supprimer le pointé. Il sera tenu pour acquis que passer un T* brut à la construction d'un ptr_partage<T> signifiera confier à ce ptr_partage<T> la responsabilité sur le pointé. Le constructeur paramétrique acceptant un T* en paramètre sera rédigé en conséquence. |
|
Visiblement, le principal intérêt d'utiliser un ptr_partage<T> survient quand la responsabilité pour ce qui est de la suppression du pointé est difficile à établir, ce qui signifie typiquement que l'ordre dans lequel les ptr_partage<T> pointant vers un même T seront détruits est a priori indéterminé. Le cas type en ce sens est celui où plusieurs threads partagent l'accès à une même ressource. Conséquemment, il importera que les opérations de gestion interne d'un ptr_partage<T> soient Thread-Safe.
Pour illustrer l'intention derrière le design, examinons brièvement un exemple possible de code client pour ptr_partage<T>.
Nous n'utiliserons, dans notre design, que des outils standards, outre bien sûr notre propre « création » que sera ptr_partage, et ses outils auxiliaires, mais même ceux-ci seront écrits de manière à être indépendante de la plateforme. |
|
J'ai emprunté à Stephan T. Lavavej l'idée d'une classe Noisy, réduite au nécessaire pour mes besoins, c'est-à-dire m'assurer que le pointé d'un ptr_partage ne meurt qu'une fois sont dernier client déconnecté. On aurait pu enrichir la classe présentée ici par un constructeur de copie bruyant ou par une affectation bruyante si nous avions souhaité valider qu'aucune copie du pointé n'est faite « sous la couverture » (ce n'est pas ce qui me préoccupait ici, mais c'est utile à savoir si c'est une préoccupation pour vous). L'implémentation de l'opérateur de projection sur un flux aurait pu être localisée dans un fichier source distinct si nous avions eu ici un projet plus complexe, mais pour mes fins, ce n'était pas utile. |
|
Le programme de test en soi procède comme suit :
|
|
Ce test est simpliste. Outre le recours à une condition_variable, on pourrait faire des tests beaucoup plus exhaustifs ici, mais ceci devrait nous donner une idée raisonnable de la rectitude de notre type. Normalement, avec N==10, l'exécution devrait donner ceci (si l'usager entre « x » pour provoquer le déblocage des threads) :
3
Quand vous serez pret(e)...x
3
3
3
3
3
3
3
3
3
3
Urg!
Comment peut-on implémenter un type tel que ptr_partage<T>? Tout d'abord, tel qu'indiqué plus haut, dans un monde idéal, on utilise std::shared_ptr<T>, ou encore boost::shared_ptr<T>. Une version « maison » peut difficilement avoir été mieux conçue et mieux testée que celles-là, et ce qui suit ne l'a définitivement pas été. Cela dit, pour de l'inspiration, voici une implémentation simple.
Notre implémentation reposera strictement sur des outils standards, et à plus fort partie sur un éventail restreint de tels outils. Ainsi :
|
|
La classe detruire représentera notre politique de suppression par défaut : étant donné un T* pour un type T donné, elle y appliquera delete. |
|
La classe ptr_partage<T,PS> sera paramétrée sur le type T du pointé et sur le type PS de politique de suppression, où PS sera par défaut associé à detruire. Une suite de types internes et publics découle de ces paramètres, mais je pense qu'ils sont somme toute assez clairs. Le plus subtil est sans doute counter_type, qui correspond à un atomic<long long> du fait que les incrémentations et les décrémentations du compteur de clients sur un pointé donné, en tant qu'accès concurrents sur une donnée accédée par au moins un thread en écriture, doivent être synchronisées. |
|
Les attributs d'un ptr_partage<T,PS> donné sont :
|
|
Pour des raisons pragmatiques, les trois accesseurs utilitaires suivants sont exposés publiquement :
Ces deux dernières méthodes ne sont pas essentielles, sauf si nous souhaitons exposer un constructeur covariant (ce que j'ai choisi de faire ici). Nous reviendrons sur leur rôle un peu plus bas, lorsque nous les utiliserons. |
|
Pour usage interne, les méthodes privées add_ref() et release() implémentent les mécanismes d'ajout et de retrait du ptr_partage<T> sur un pointé donné. Ces deux méthodes ont pour précondition que p soit non-nul, et à plus forte partie que cpt soit non-nul. L'un implique l'autre, mais nous testons cpt ici puisque c'est le compteur, par le pointeur p, qui est principalement utilisé dans les implémentations de ces deux méthodes. La méthode add_ref() réalise une incrémentation (atomique, dû à la nature de *cpt) du nombre de clients sur le pointé. Le côté atomique de cette incrémentation est nécessaire à la cohérence du compteur sur un ordinateur contemporain (ayant plus d'un coeur) : sans synchronisation, la connexion et la déconnexion concurrente de clients sur un même pointé résulteraient en un vilain cas d'Undefined Behavior, ce qui se manifesteraient probablement en pratique par un pointé dont la durée de vie est gérée incorrectement La méthode release() est plus subtile. En effet, puisque le dernier client à se déconnecter doit détruire le pointé (et le compteur), il importe que seul le ptr_partage<T> qui aura bel et bien amené *cpt à zéro détruise le pointé. Pour y arriver, nous avons recours à une répétitive réalisant un Compare-Exchange atomique, qui s'exprime en mots comme « tant que ce n'est pas moi qui ai réalisé la décrémentation la plus récente ». La valeur de souhaite après l'opération et la valeur de *cpt suite à l'action de cette fonction. Si cette valeur est nulle, c'est que c'est bel et bien ce client qui a amené le compteur à zéro, et il est donc temps de supprimer le compteur et le pointé. |
|
Parenthèse : un problème subtil...
On pourrait être tenté d'écrire ce qui suit, simplement (deux options avec des problèmes différents; merci à Michel Hébert pour avoir suggéré celle de droite) :
|
|
Le fait que la décrémentation soit atomique peut être trompeur ici, et ne résout pas le problème :
Ceci montre pourquoi il ne suffit pas de décrémenter correctement le compteur : il faut aussi obtenir une information cruciale, soit savoir qui a réalisé la « décrémentation fatidique ».
La méthode publique reset() n'appellera release() que si le ptr_partage<T> est client d'un pointé. Ceci est sans risque du fait que l'état constaté, !empty(), ne peut changer sans que release() n'ait été complété, par définition : si *this était client de *p au début de l'exécution de la méthode, il le demeurera jusqu'à ce qu'il se soit effectivement déconnecté. |
|
La constructeur par défaut sera conceptuellement équivalent à un pointeur nul. |
|
Le constructeur paramétrique acceptant un T* prendra la responsabilité sur son pointé, et deviendra par définition son premier client, ce qui explique l'initialisation à 1 de *cpt. Cette responsabilité nous force à passer par un bloc try...catch pour allouer dynamiquement le compteur de clients, destiné à être partagé. En effet, si les ressources venaient à manquer à un point tel que cette allocation échouerait, alors nous aurions une vilaine fuite de ressources, p étant perdu et ne pouvant donc plus être finalisé. Ceci peut être très vilain si T représente un composant d'un système critique, par exemple, ou une entité financière. |
|
Trois autres constructeurs sont offerts, soit un constructeur de copie, un constructeur de mouvement et un constructeur covariant.
Le recours à get_raw_pointer() et à get_raw_counter() dans le constructeur covariant tient au fait que ptr_partage<T> et ptr_partage<U> sont deux types distincts, qui n'ont pas de relation d'héritage entre eux. Ainsi, *this ne peut accéder directement à autre.p ou à autre.cpt, et il nous faut faire un choix pragmatique plutôt qu'esthétique. Remarque syntaxique : il aurait été possible de remplacer autre.get_raw_pointer() par &*autre, peut-être au risque de réduire la lisibilité du code source, mais pour le compteur, une méthode est de mise. Remarque sémantique : le constructeur de copie et le constructeur covariant acceptent tous deux des opérandes const mais partagent les attributs de ces opérandes de manière à pouvoir modifier ce vers quoi ils pointent. Ceci cause un bris de constance logique... mais C++ n'implémente que la constance bit à bit, ce qui explique que ce soit les pointeurs, pas les pointés, qui soient const dans ce cas-ci. |
|
La méthode swap() est triviale, mais notons au passage qu'une implémentation véritablement robuste de cette méthode encapsulerait les appels à std::swap() de manière à ce qu'ils soient réalisés de manière essentiellement atomique, ce qui n'est pas possible pour le moment. Avec C++ 17, il semble probable que la mémoire transactionnelle devienne un mécanisme du langage, ce qui permettrait de parfaire notre implémentation. |
|
Nous implémentons trois opérateurs d'affectation :
|
|
Les opérateurs de déréférencement et d'accès à un membre (l'opérateur * unaire et l'opérateur ->, pour prendre des noms plus vulgaires, au sens propre du terme) sont triviaux ici. Notez qu'il est de la responsabilité du code client de s'assurer (sans doute implicitement) que !empty() avant d'utiliser ces services. |
|
Enfin, le destructeur déconnecte simplement le ptr_partage<T> de son pointé, s'il y a lieu. |
|
Il se peut que vous jugiez pertinent d'enrichir cette gamme de services d'opérateurs auxiliaires, par exemple == et !=. Je vous propose ce qui suit :
template<class T>
bool operator==(const ptr_partage<T> &p, const ptr_partage<T> &q) {
return p.get_raw_pointer() == q.get_raw_pointer();
}
template<class T, class U>
bool operator==(const ptr_partage<T> &p, const ptr_partage<U> &q) {
return p.get_raw_pointer() == q.get_raw_pointer();
}
template<class T>
bool operator!=(const ptr_partage<T> &p, const ptr_partage<T> &q) {
return !(p == q);
}
template<class T, class U>
bool operator!=(const ptr_partage<T> &p, const ptr_partage<U> &q) {
return !(p == q);
}
Personnellement, je n'implémenterais pas les opérateurs relationnels d'ordonnancement que sont <, >, <= et >=, qui ne sont probablement pas pertinents si on éviter l'arithmétique de pointeurs (ce qui est sans doute la chose a faire ici). On pourrait implémenter l'opérateur [] dans le cas où notre objet encapsule un tableau, mais je vous laisse cela en exercice, tiens.