Le new positionnel (Placement new)

Le langage C++ permet de spécialiser les opérateurs new, new[], delete et delete[] avec certaines signatures standards, et avec un nombre arbitrairement grand de signatures « maison ». Vous trouverez des exemples de telles spécialisations sur ../../Sources/allocateur_arenafixe.html, ../../Sources/ArenaFixe.html, ../../Sources/Detection-Fuites.html et ../../Sources/allocation_dynamique-Exemple.html entre autres. Il est d'ailleurs probablement sage de vous familiariser avec la surcharge de ces opérateurs avant de lire ce qui suit.

Un cas particulier et essentiel au bon fonctionnement du langage est la variante que l'on nomme le Placement new, ou new positionnel. Cette variante ne peut être spécialisée, et permet au code client de choisir l'emplacement où un objet sera construit. Ceci permet entre autres à un conteneur de gérer sa mémoire de manière optimale en termes d'efficacité à l'exécution, quitte à compliquer la rédaction du code source.

Bref rappel

Lors d'une allocation dynamique de mémoire pour un objet, de la forme suivante :

X *p = new X;

... l'opérateur new a pour rôle de trouver l'espace requis pour entreposer un X (sa signature prend d'ailleurs un std::size_t en paramètre, qui vaudra dans ce cas au moins sizeof X), ce qui mène à l'obtention d'un bloc de mémoire brute d'au moins sizeof X bytes contigüs (si cette opération échoue, std::bad_alloc sera levé).

Par la suite, le constructeur (ici, X::X() ou un équivalent s'il y a des paramètres avec valeurs par défaut) est appliqué sur cette zone de mémoire comme s'il s'agissait de peinture, et initialise ce bloc de mémoire, le transformant en objet (si ce constructeur échoue, une exception dont le type dépend de ce que X fait et représente sera levée).

Pour sa part, présumant le même p que ci-dessus, l'opération suivante :

delete p;

... appellera d'abord X::~X() sur la mémoire pointée par p, transformant ce X en mémoire brute, suite à quoi operator delete(void*) sera appelé avec en paramètre l'adresse que représente p, ce qui retournera la mémoire brute ainsi pointée à un état tel qu'elle puisse être réutilisée par la suite.

Une implémentation naïve des opérateurs new, new[], delete et delete[] serait la suivante :

void* operator new(size_t n) {
   if (auto p = malloc(n); p)
      return p;
   throw bad_alloc{};
}
void* operator new[](size_t n) {
   if (auto p = malloc(n); p)
      return p;
   throw bad_alloc{};
}
void operator delete(void *p) {
   free(p); // no-op si !p
}
void operator delete[](void *p) {
   free(p); // no-op si !p
}

En pratique, on peut presque toujours faire mieux.

Ce qu'est le new positionnel

Le new positionnel est un mécanisme permettant au code client de placer un objet à un endroit de son choix. C'est un mécanisme extrêmement efficace, mais dont le sain usage demande une compréhension fine des opérations et de l'alignement en mémoire.

L'implémentation du new positionnel (et des opérations associées) va comme suit :

#include <new>
void* operator new(std::size_t, void *p) {
   return p;
}
void* operator new[](std::size_t, void *p) {
   return p;
}
void operator delete(void*, void*) {
}
void operator delete[](void*, void*) {
}

Ceci rappelle à l'esprit que le rôle de new est de trouver un endroit où placer un objet; allouer de la mémoire pour ce faire est une tâche auxiliaire, pas sa tâche principale. Le new positionnel tient compte du fait que le code client sait où placer l'objet, donc qu'aucune allocation de mémoire ne doit être faite.

Exemple comparatif simpliste

Comment utiliser le new positionnel? Commençons par un exemple simpliste.

La fonction inutilement_complique_A() crée un tableau de bytes bruts buf de taille suffisante pour entreposer un int, puis crée à cet endroit un int de valeur val pour l'utiliser indirectement à travers le pointeur p par la suite.

Il est sage de finaliser ce int à la fin de la fonction (appel à p->~int()), bien ce ne que soit pas essentiel dans ce cas car le destructeur d'un primitif est trivial.

Notez que nous n'appellons pas delete p; car nous ne souhaitons pas libérer la mémoire associée à *p, celle-ci n'ayant jamais été allouée dynamiquement!

void inutilement_complique_A(int val) {
   char buf[sizeof(int)];
   int *p = new (static_cast<void*>(&buf[0])) int{ val };
   // utiliser *p...
   p->~int(); // pourrait être omis car trivial
}
void equivalent_simple_A(int val) {
   int n = val;
   // utiliser n...
}

La fonction inutilement_complique_B() crée quant à elle un tableau de bytes bruts buf de taille suffisante pour entreposer une string, puis crée à cet endroit une string dont le contenu correspondra à ce vers quoi pointe s pour l'utiliser indirectement à travers le pointeur p par la suite.

Il est nécessaire de finaliser cette string à la fin de la fonction (appel à p->~string()), du fait que ce destructeur n'est pas trivial. De même, notez la présence d'un bloc try...catch, inséré ici de manière préventive du fait que la finalisation de *p doit être manuelle, même lors d'une levée d'exception.

void inutilement_complique_B(const char *s) {
   char buf[sizeof(string)];
   int *p = new (static_cast<void*>(&buf[0])) string{ s };
   try {
      // utiliser *s...
   } catch(...) {
   }
   p->~string();
}
void equivalent_simple_B(const char *s) {
   string str{ s };
   // utiliser str...
}

Dans les deux cas, qui ne sont pas de bons exemples de recours au new positionnel mais montrent tout de même la mécanique de cette opération, la colonne de droite montre du code « normal », dans ces deux cas à la fois plus rapide et plus simple.

Exemple comparatif plus sérieux

Pour examiner un exemple simplifié d'une telle gestion de mémoire, comparons deux implémentations d'une fonction creer_tableau<T>(n,init) qui aura pour rôle de :

Nous comparerons une implémentation « naïve » mais simple (et relativement efficace) de cette fonction avec une implémentation beaucoup moins simple mais aussi beaucoup plus rapide (du moins pour les types dont les constructeurs ne sont pas triviaux) de la même fonction.

L'implémentation naïve crée donc un tableau de T, remplace les valeurs des éléments qui s'y trouvent par des copies de la valeur initiale init indiquée en paramètre, puis retourne le tableau une fois celui-ci initialisé.

Pour un T primitif, ou du moins dont le constructeur par défaut est trivial, cette fonction est probablement optimale (et plus rapide que les alternatives). Cependant, pour un T dont le constructeur par défaut n'est pas trivial, cette fonction fait beaucoup de travail inutile :

  • Elle initialise chacune des n instances de T à sa valeur par défaut, puis
  • Par la suite, remplace l'état de chacune de ces instances de T par une copie de init à l'aide d'une affectation

La construction par défaut, qui n'est pas gratuite, est donc faite inutilement... n fois.

#include <memory>
#include <algorithm>
template <class T>
   auto creer_tableau(std::size_t n, const T &init) {
      auto p = std::make_unique<T[]>(n);
      std::fill(p.get(), p.get() + n, init);
      return p;
   }

L'implémentation positionnelle obtient d'abord (dynamiquement) un espace suffisamment grand pour entrepose n instances du type T. Cet espace, initialement, n'est que de la mémoire brute.

Par la suite, chaque espace de la taille d'un T dans cet espace est initialisé, à l'aide du constructeur de copie, de manière à devenir une copie de la valeur initiale init passée en paramètre.

Puisque l'allocation initiale obtient des char, nous prenons soin de faire en sorte que la déallocation se fasse sur des char. De plus, avant de libérer la mémoire, nous appliquons le destructeur de T sur chaque espace de la taille d'un T pour retransformer cet espace en mémoire brute.

En gros, donc : nous transformons un constructeur par défaut (essentiellement inutile) suivi d'une affectation (potentiellement dispendieuse) par un constructeur de copie

#include <memory>
#include <functional>
template <class T>
   auto creer_tableau(std::size_t n, const T &init) {
      // créer un espace pour entrepose n instances de T
      // cet espace n'est que de la mémoire brute
      auto p = std::make_unique<char[]>(n * sizeof(T));
      // remplir le tableau avec des copies de init; appelle T::T(const T&) n fois
      {
         auto q = reinterpret_cast<T*>(p.get());
         std::uninitialized_fill(q, q + n, init);
      }
      // à ce stade, p pointe (à son insu) sur n instances de T
      // lorsque son destructeur sera appelé, il lui faudra d'abord détruire chaque T (en
      // appelant T::~T()), puis libérer la mémoire (un tableau de char)
      return std::unique_ptr<T[], std::function<void(T*)>>{
         reinterpret_cast<T*>(p.release()),
         [n](T *p) { std::destroy(p, p + n); delete[] reinterpret_cast<char*>(p); }
      };
   }

À titre informatif, une implémentation possible de uninitialized_fill() (algorithme que vous trouverez dans <memory>) est présentée à droite. C'est cet algorithme qui sollicite le constructeur de copie et le new positionnel.

template<class It, class T>
   void uninitialized_fill(It debut, It fin, const T &val) {
      auto p = debut;
      try {
         for(; p != fin; ++p)
            new (static_cast<void*>(addressof(*p))) T(val); // <-- ICI
      } catch(...) {
         destroy(debut, p);
         throw;
      }
   }

Enfin, l'algorithme destroy() (que vous trouverez aussi dans <memory>) transforme les éléments d'une séquence en mémoire brute. Une implémentation possible est présentée à droite.

template<class It, class T>
   void destroy(It debut, It fin) {
      for(; debut != fin; ++debut)
         debut->~T();
      }
   }

Un exemple de test suit :

#include <memory>
#include <algorithm>
#include <functional>
namespace naif {
   template <class T>
   auto creer_tableau(std::size_t n, const T &init) {
      auto p = std::make_unique<T[]>(n);
      std::fill(p.get(), p.get() + n, init);
      return p;
   }
}

namespace positionnel {
   template <class T>
   auto creer_tableau(std::size_t n, const T &init) {
      auto p = std::make_unique<char[]>(n * sizeof(T));
      {
         auto q = reinterpret_cast<T*>(p.get());
         std::uninitialized_fill(q, q + n, init);
      }
      return std::unique_ptr<T[], std::function<void(T*)>>{
         reinterpret_cast<T*>(p.release()),
            [n](T *p) { std::destroy(p, p + n); delete[] reinterpret_cast<char*>(p); }
      };
   }
}

#include <iostream>
#include <string>
#include <chrono>
using namespace std;
using namespace std::chrono;

template <class F, class ... Args>
auto test(F f, Args &&...args) {
   auto pre = high_resolution_clock::now();
   auto res = f(std::forward<Args>(args)...);
   auto post = high_resolution_clock::now();
   return pair{ res, post - pre };
}

int main() {
   enum { N = 10'000, M = 1'000 };
   auto[r0, dt0] = test([] {
      size_t n = 0;
      for (int i = 0; i != N; ++i)
         n += naif::creer_tableau(N, 3)[0];
      return n;
   });
   auto[r1, dt1] = test([] {
      size_t n = 0;
      for (int i = 0; i != N; ++i)
         n += positionnel::creer_tableau(N, 3)[0];
      return n;
   });
   auto[r2, dt2] = test([] {
      size_t n = 0;
      for (int i = 0; i != N; ++i)
         n += naif::creer_tableau(N, "J'aime mon prof, vraiment, t'sais"s)[0].size();
      return n;
   });
   auto[r3, dt3] = test([] {
      size_t n = 0;
      for (int i = 0; i != N; ++i)
         n += positionnel::creer_tableau(N, "J'aime mon prof, vraiment, t'sais"s)[0].size();
      return n;
   });
   cout << "Naif,        " << N << " ints, ecoule : "
        << duration_cast<microseconds>(dt0).count() << " us\n";
   cout << "Positionnel, " << N << " ints, ecoule : "
        << duration_cast<microseconds>(dt1).count() << " us\n";
   cout << "Naif,        " << N << " strings, ecoule : "
        << duration_cast<microseconds>(dt2).count() << " us\n";
   cout << "Positionnel, " << N << " strings, ecoule : "
        << duration_cast<microseconds>(dt3).count() << " us\n";
}

À l'exécution, nous obtenons (Visual Studio 2017.8, 32 bits, Release) :

Naif,        10000 ints,    ecoule : 23507 us
Positionnel, 10000 ints,    ecoule : 55401 us
Naif,        10000 strings, ecoule : 8633490 us
Positionnel, 10000 strings, ecoule : 7864319 us

Tel que suggéré, la version naïve sied aux primitifs, mais la version reposant sur un new positionnel gagne haut la main dans le cas où le constructeur par défaut n'est pas trivial. Il serait possible de choisir entre les deux versions selon la nature du type T à l'aide de traits et d'un mécanisme tel qu'enable_if ou encore if constexpr. Par exemple :

template <class T>
   auto creer_tableau(std::size_t n, const T &init) {
      if constexpr(std::is_fundamental_v<T>) // on pourrait faire mieux
         return naif::creer_tableau(n, init);
      else
         return positionnel::creer_tableau(n, init);
   }

Cas d'utilisation concrets

Qui utilise le new positionnel? Quelques exemples importants suivent, mais la liste n'est pas exhaustive.

Le type vector<T,A>

Un vector<T> (qui est en fait un vector<T,A>, où A est le type de l'allocateur associé au vector) est un tableau dynamique faisant une gestion extrêmement rigoureuse de la mémoire. Bien que vector<T,A> n'utilise pas directement le new positionnel, son allocateur (le A) offre un service construct() qui (à quelques rares exceptions près) utilisera le new positionnel pour réaliser sa tâche.

Notez que dû à cette gestion rigoureuse de la mémoire, un vector<T> est typiquement plus rapide qu'un tableau de T géré manuellement, même si cela  peut sembler contre-intuitif.Voir ../../Sources/comparatif_vecteur_tableau.html pour des exemples.

Le type optional<T>

Le type optional<T> modélise l'idée d'être peut-être un T. Pour cette raison, un optional<T> garde essentiellement, à titre d'états internes, un tableau de sizeof(T) bytes aligné comme un T et un booléen indiquant la présence ou l'absence d'un T dans cet optional<T>.

Le new positionnel permet de distinguer l'espace à occuper de l'objet qui l'occupera dans un optional<T>. Sans new positionnel, un optional<T> devrait être modélisé autrement, probablement comme un pointeur soit nul, soit pointant sur un T, ce qui imposerait un coût d'allocation dynamique de mémoire et rendrait l'utilisation d'un optional<T> moins Cache-Friendly.

Voir optional.html pour plus de détails, et voir ../TrucsScouts/maybe.html pour voir comment il serait possible d'implémenter votre propre optional<T> si tel était votre souhait.

Le type variant<T0,T1,...,Tn>

Le type variant<T0,T1,...,Tn> modélise un objet contenant au plus un objet de l'un des types de la liste T0,T1,...,Tn. La taille occupée par un tel variant est essentiellement le maximum des tailles des objets susceptibles d'y apparaître (plus la taille d'un entier permettant de savoir lequel de ces types y est entreposé à un instant donné).

Représenter l'objet entreposé dans un variant<T0,T1,...,Tn> se fait avec un tampon de bytes bruts de taille max({ sizeof(T0,T1,...,Tn)... }) et aligné comme max({ alignof(T0,T1,...,Tn)... }). Insérer un objet dans le variant implique faire un new positionnel dans ce tampon.

Voir variant.html pour plus de détails.

Les algorithmes uninitialized_fill(), uninitialized_copy(), etc.

La bibliothèque <memory> comprend plusieurs fonctions pour manipuler de la mémoire brute. Les conteneurs standards n'utilisent pas ces algorithmes (les conteneurs utilisent plutôt des allocateurs), mais les algorithmes demeurent utiles pour qui souhaiterait implémenter ses propres conteneurs, tout en gérant la mémoire avec plus de rigueur que dans une implémentations naïve.

Notez que, depuis C++ 17, un algorithme nommé destroy() permet de retransformer des objets en mémoire brute, en appelant leurs destructeurs sans libérer la mémoire sous-jacente, offrant une certaine symétrie avec les fonctions qui initialisent des objets dans de la mémoire brute sans toutefois allouer cette mémoire (donc qui utilisent le new positionnel).

Risques

Il y a évidemment des risques à recourir à un mécanisme de bas niveau comme celui-ci.

Le premier est d'écrire du code menant à un accès non-aligné à un objet. Par exemple, supposons cet extrait bien intentionné, mais dangereux :

class X {
   short s;
   int n;
   // ...
public:
   X(short s, int n) : s{ s }, n{ n } {
   }
   short getShort() const { // bof
      return s;
   }
   int getInt() const { // bof
      return n;
   }
};
class X_compact {
   static constexpr int pos_short = 0;
   static constexpr int pos_int = post_short + sizeof(short);
   alignas(short) char buf[sizeof(short) + sizeof(int)];
   void* raw(int n = 0) {
      return static_cast<void*>(buf + n);
   }
   const void* raw(int n = 0) const {
      return static_cast<const void*>(buf + n);
   }
public:
   X_compact(short s, int n) {
      new (raw(pos_short)) short{s}; // Ok
      new (raw(pos_int)) int{n}; // Oups, peut ne pas être aligné!
   }
   short getShort() const { // bof
      return *static_cast<const short*>(raw(pos_short)); // Ok
   }
   int getInt() const { // bof
      return *static_cast<const int*>(raw(pos_int)); // Oups, peut ne pas être aligné! 
   }
};

Ici, X:compact::buf est un tableau de char, et alignof(char)==1, mais la programmeuse ou le programmeur, sachant qu'elle ou qu'il allait placer un short puis un int, l'alignement de buf a été rendu équivalent à celui d'un short.

Toutefois, ceci ne garantit pas que le int, placé juste après le short dans buf, sera correctement aligné. Ainsi, bien que sizeof(X_compact) soit probablement inférieur à sizeof(X) (sur la plupart des ordinateurs contemporains, on peut s'attendre à sizeof(X_compact)==6 et à sizeof(X)==8 dû à l'alignement de X::n), appeler getInt() sur un X_compact mènera à un accès non-aligné à un int, ce qui peut causer des problèmes allant d'un bénin ralentissement à un plantage violent, en passant par des bogues extrêmement désagréables en situation de multiprogrammation.

Ensuite, reprenons un exemple vu précédemment :

void inutilement_complique_B(const char *s) {
   char buf[sizeof(string)];
   int *p = new (static_cast<void*>(&buf[0])) string{ s };
   try {
      // utiliser *s...
   } catch(...) {
   }
   p->~string();
}
void equivalent_simple_B(const char *s) {
   string str{ s };
   // utiliser str...
}

L'exemple de gauche est nettement plus complexe (et porte bien son nom : il est inutilement compliqué!), mais notez que l'exemple de droite ne laissera pas fuire de ressources lors d'une levée d'exception car le destructeur de str sera alors appelé de manière implicite. À gauche, la construction de la string dans buf étant explicite, la gestion des ressources lors d'une levée d'exception doit être manuelle, ce qui ajoute à la complexité.

Évidemment, il faut se souvenir qu'appeler delete à partir d'un pointeur mène habituellement à la libération de la mémoire associée... mais encore faut-il que cette mémoire ait été obtenue dynamiquement au préalable, ce qui n'est pas nécessairement le cas lors du recours à un new positionnel. Qui plus est, le recours au new positionnel suppose habituellement un substrat de mémoire brute, dans laquelle des objets sont construits manuellement. L'allocation initiale étant sur un char*, vaut mieux éviter que la libération soit sur un T*.

En particulier, pensez à un type complexe comme vector<T> où la mémoire d'un objet v est typiquement occupée en partie par des T (zone allant de begin(v) à end(v), où end(v) correspond à begin(v)+size(v)) et est en partie « inoccupée » (la région allant de end(v) à begin(v)+v.capacity()). Il est clair qu'un simple delete[] sur le bloc de mémoire ne peut convenir à une organisation si subtile.

À retenir, donc : le new positionnel est puissant et polyvalent, mais demande de la délicatesse. Mieux vaut l'encapsuler.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !