Comment implémenter un pointeur intelligent avec sémantique de partage?

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.

Déclaration d'intention

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.

// ...
{
   ptr_partage<T> p{ new int{ 3 } }; // p se voit confié un int* pointant sur un int de valeur 3
   ptr_partage<T> q = p; // q et p partagent le pointé
} // supposant destruction de q suivie de destruction de p, le destructeur de q ne
  // supprime pas le pointé, qui a encore un client : p. Le destructeur de p,
  // quant à lui, supprime le pointé qui n'a plus de clients 
// ...

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.

Exemple de code client

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.

#include "PtrPartage.h"
#include <iostream>
#include <thread>
#include <vector>
#include <atomic>

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.

struct Noisy {
   int val;
   Noisy(int val) : val{ val } {}
   }
   ~Noisy() {
      std::cout << "Urg!" << std::endl;
   }
   friend std::ostream& operator<<(std::ostream &os, const Noisy &n) {
      return os << n.val;
   }
};

Le programme de test en soi procède comme suit :

  • Il crée un ptr_partage<Noisy> nommé p pointant sur un Noisy de valeur 3
  • Il en fait un partage temporaire avec q, pour voir si aucun bogue grossier ne transparaît
  • Ensuite, il lance N threads font une copie de p, donc partagent son pointé, puis attendent tous un signal, la variable atomique go, sur laquelle ils font de la très inefficace attente active... Préférez une condition_variable, je vous en prie! Mais j'ai assemblé cet exemple en quelques minutes alors ne me blâmez pas trop
  • Le p de main() se déconnecte ensuite du pointé, laissant les p des divers threads co-responsables du pointé. Ceci permet de mettre à l'épreuve la finalisation du pointé... Notez qu'un test comme celui-ci donne confiance, mais n'est pas une garantie que le code soit correct (j'espère bien sûr ne rien avoir échappé, vous le comprendrez!)
  • Une fois le signal donné, ces threads affichent tous le pointé de leur « copie » p (lecture concurrente, sans besoin de synchronisation sur le pointé partagé), puis leur p décède, se déconnectant du pointé
  • À la finalisation du p du dernier thread à se compléter, le dernier client décède et le pointé est éliminé
int main() {
   enum { N = 10 };
   using namespace std;
   ptr_partage<Noisy> p{ new Noisy{3} };
   {
      auto q = p;
      cout << *q << endl;
   }
   atomic<bool> go = false;
   vector<thread> th;
   for (int i = 0; i < N; ++i)
      th.emplace_back(thread{ [&go,p]() {
         while (!go)
            this_thread::yield();
         cout << *p << endl;
      } });
   p.reset();
   cout << "Quand vous serez pret(e)..." << flush;
   char c;
   cin >> c;
   go = true;
   for (auto &thr : th) thr.join();
}

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!

Implémentation commentée

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 :

  • Nous inclurons <cassert> pour valider quelques préconditions
  • Nous inclurons <atomic> parce que le domaine d'application normal d'un ptr_partage<T> est multiprogrammé, ce qui signifie que nous sommes fortement susceptibles de rencontrer des courses lors de l'ajout ou du retrait d'un client sur un pointé donné, et
  • Nous inclurons <utility> pour avoir accès à std::swap() dans le but d'implémenter l'idiome d'affectation sécuritaire
#ifndef PTR_PARTAGE_H
#define PTR_PARTAGE_H
#include <atomic>
#include <utility>
#include <cassert>

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.

struct detruire {
   template <class T>
      void operator()(const T *p) const {
         delete p;
      }
};

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.

template <class T, class PS = detruire>
   class ptr_partage {
   public:
      using value_type = T;
      using pointer = T*;
      using const_pointer = const T*;
      using reference = T&;
      using const_reference = const T&;
      using counter_type = std::atomic<long long>;

Les attributs d'un ptr_partage<T,PS> donné sont :

  • Sa politique de suppression, ps
  • Le pointeur p sur la données partagée, et
  • Le compteur *cpt qui sera partagé entre les divers clients d'un même pointé. Nous avons recours à un pointeur ici pour que tous les ptr_partage<T> menant vers un même T partagent un même compteur
   private:
      PS ps;
      pointer p;
      counter_type *cpt;

Pour des raisons pragmatiques, les trois accesseurs utilitaires suivants sont exposés publiquement :

  • Le prédicat empty(), qui s'avère seulement si le ptr_partage<T> encapsule un pointeur nul
  • La méthode get_raw_pointer(), qui porte un nom lourd mais choisi volontairement pour décourager son utilisation, et
  • La méthode get_raw_counter(), qui expose un pointeur brut sur le compteur partagé de clients du pointé

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.

   public:
      bool empty() const noexcept {
         return !p;
      }
      pointer get_raw_pointer() const noexcept {
         return p;
      }
      counter_type* get_raw_counter() const noexcept {
         return cpt;
      }

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

   private:
      void add_ref() noexcept {
         assert(cpt);
         ++(*cpt);
      }
      void release() noexcept {
         assert(cpt);
         long long avant = *cpt;
         auto souhaite = avant - 1;
         while (!cpt->compare_exchange_weak(avant, souhaite)) // tant que c'est pas moi qui a fait la décrémentation...
            souhaite = avant - 1;
         if (souhaite == 0) { // si j'ai fait la dernière décrémentation
            ps(p);
            delete cpt;
         }
      }

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) :

// ...
void release() {
   assert(cpt);
   if (*cpt) {
      --(*cpt); // atomique
      if (*cpt == 0) { // course ici
         ps(p);
         delete cpt;
      }
   }
}
// ...
void release() {
   assert(cpt);
   if (*cpt) {
      if (*cpt == 1) { // course ici
         ps(p);
         delete cpt;
      }
      else
         --(*cpt); // atomique
   }
}

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

   public:
      void reset() noexcept {
         if (!empty()) release();
      }

La constructeur par défaut sera conceptuellement équivalent à un pointeur nul.

      ptr_partage() noexcept
         : p{}, cpt{}
      {
      }

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.

      explicit ptr_partage(pointer p)
         : p{ p }, cpt{}
      {
         if (p) {
            try {
               cpt = new counter_type{ 1 };
            } catch (...) {
               ps(p);
               throw;
            }
         }
      }

Trois autres constructeurs sont offerts, soit un constructeur de copie, un constructeur de mouvement et un constructeur covariant.

  • Le constructeur de copie implémente un partage du pointé, ce qui est bien sûr le comportement attendu ici. Si le ptr_partage<T> source (objet autre) n'est pas vide, le ptr_partage<T> en construction partage son pointé et son compteur, puis incrémente le compteur pour refléter l'ajout d'un client au pointé. Notez que ce constructeur est noexcept, ce qui est rare pour un constructeur de copie
  • Le constructeur de mouvement arrache au ptr_partage<T> source (objet autre) son contenu pour le déplacer dans le ptr_partage<T> en construction. Son implémentation est triviale
  • Le constructeur covariant accepte en paramètre un ptr_partage<U> tel qu'un U* est implicitement convertible en T*. J'aurais pu utiliser un trait comme is_convertible ici pour expliciter cette contrainte, mais le fait de déposer un U* dans un T*, comme nous le faisons ici, suffit à bloquer la compilation de programmes incorrects.

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.

      ptr_partage(const ptr_partage &autre) noexcept
         : p{ autre.p }, cpt{ autre.cpt }
      {
         if (!empty()) add_ref();
      }
      ptr_partage(ptr_partage &&autre) noexcept
         : p{ autre.p }, cpt{ autre.cpt }
      {
         autre.p = {};
         autre.cpt = {};
      }
      template <class U>
         ptr_partage(const ptr_partage<U, PS> &autre) noexcept
            : p{ autre.get_raw_pointer() }, cpt{ autre.get_raw_counter() }
         {
         }

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.

      void swap(ptr_partage &autre) noexcept {
         using std::swap;
         swap(p, autre.p);
         swap(cpt, autre.cpt);
      }

Nous implémentons trois opérateurs d'affectation :

  • L'affectation classique, écrite ici dans le respect de l'idiome d'affectation sécuritaire. Cette implémentation est correcte car le constructeur de copie ajoute un client à autre, alors que le destructeur déconnecte un client vers le pointé de *this tel qu'il était au début de la méthode : nous avons ainsi précisément la sémantique souhaitée. De manière amusante, cette implémentation de l'opérateur est noexcept
  • L'affectation de mouvement, implémentée de manière triviale, et
  • L'affectation covariante, qui est elle aussi écrite dans le respect de l'idiome d'affectation sécuritaire, à ceci près que le constructeur covariant est sollicité en lieu et place du constructeur de copie
      ptr_partage& operator=(const ptr_partage &autre) {
         ptr_partage{ autre }.swap(*this);
         return *this;
      }
      ptr_partage& operator=(ptr_partage &&autre) noexcept {
         swap(autre);
         return *this;
      }
      template <class U>
         ptr_partage& operator=(const ptr_partage<U, PS> &autre) {
            ptr_partage{ autre }.swap(*this);
            return *this;
         }

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.

      reference operator*() noexcept {
         return *p; }
      }
      const_reference operator*() const noexcept {
         return *p;
      }
      pointer operator->() noexcept {
         return p;
      }
      const_pointer operator->() const noexcept {
         return p;
      }

Enfin, le destructeur déconnecte simplement le ptr_partage<T> de son pointé, s'il y a lieu.

      ~ptr_partage() {
         reset();
      }
   };
#endif

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.


Valid XHTML 1.0 Transitional

CSS Valide !