Pages

lundi 22 avril 2013

Placement new, allocateur et container

New est généralement utilisé pour allouer un bloc mémoire et −où il diffère de malloc(),− appelle le constructeur de la classe demandée (si constructeur il y a).
New fait donc deux choses en une.

En fait, new fait une troisième chose: il lance une exception std::bad_alloc si l'espace mémoire est insuffisant.
Pour qu'un pointeur nul soit retourné, il existe le type std::nothrow_t et la variable std::nothrow (tous 2 dans <new>) qui, conjointement avec new s'utilise ainsi:

Machin * machin = new (std::nothrow) Machin(/*params...*/);

Voici ce qui clôt son utilisation courante et voyons comment le faire en deux étapes au moins.

Allouer de la mémoire

Comme on veut juste réserver un espace mémoire, malloc() peut suffire mais prenons les bonnes habitudes, utilisons la fonction ::operator new() !

void * p = ::operator new(sizeof(Machin));

Ou la version sans exception.

#include <new>
//...
void * p = ::operator new(sizeof(Machin), std::nothrow);

Maintenant qu'on a un joli espace mémoire tout fraîchement alloué, construisons l'objet.

Placement new

Le placement new permet de construire un objet dans une zone mémoire prédéfinie et appelle le constructeur.

Machin * machin = new (p) Machin(/*params...*/); // machin == p (même zone mémoire)

Et maintenant que c'est construit, on détruit :D

Destructeur

Un appel explicite au destructeur et le tour est joué.

machin->~Machin();

Étape inutile pour les types scalaires. De toute façon ils n'ont pas de destructeur.

Évidemment, si machin possède un destructeur virtuel et est en réalité un objet héritant de Machin, alors c'est le destructeur de la classe fille qui est appelé.

Il ne reste plus qu'à libérer la mémoire.

Libération de la mémoire

Comme pour ::operator new (), il existe un ::operator delete() auquel il suffit de donner notre pointeur.

::operator delete (p);

Allocation de tableau

::operator new[] et ::operator delete[] fonctionnent de la même façon et sont plus sûrs qu'une gestion manuelle avec leurs homologues sans crochets. Ne serait-ce que pour éviter les fuites mémoire.

void * p = ::operator new [] (sizeof(Machin) * n); //identique que la version sans crochet
Machin * machin = new (p) Machin[n]/*{params...}*/; //si un des constructeurs jettes une exception alors le destructeur des objets construit sera automatiquement appeler.

Surcharge de new et delete

Toutes les formes de new et delete sont surchargeables de façon locale ou globale. Local quand l'opérateur est implémenté à l'intérieur d'une classe (son prototype sera implicitement statique) et global lorsqu'implémenté dans le namespace global.

De plus, comme le new peux prendre des paramètres, il est possible de les personnaliser et d'en ajouter.

#include <new>
struct A{
 A(int i=0)
 {std::cout << "A("<<i<<")" << std::endl;}

 void * operator new (std::size_t size, int x, int y) throw(std::bad_alloc)
 {
  std::cout << "new A " << x << ' ' << y << std::endl;
  return ::operator new (sizeof(A));
 }
};

new(1,2) A;//affiche "new A 1 2 A(0)"

L'alignement mémoire

L'alignement mémoire est une histoire à part entière, je n'en parle donc pas ^^.
Toutefois, renseignez-vous dessus, des variables non alignées peuvent faire chuter les performances et planter certaines architectures de processeur.
Présent dans boost et la dernière norme du C++, il existe aligned_storage et co pour aider dans l'alignement.

Allocateurs

Les allocateurs sont des objets qui s'occupent de faire tout ce qui a été dit auparavant à travers des méthodes comme allocate/desallocate, construct/destroy, address et max_size mais sans faire de surcharge. En fait tous les containers de la std utilisent un std::allocator.

Ce qui m’amène au dernier point, les containers.

Allocateurs et containers

Chaque container (ou presque), que ce soit des std::string, des std::vector ou encore des std::list possèdent tous un allocateur. Son type se définit en dernier paramètre de template.
Évidemment, l'allocateur peut être personnalisable et dans certaines circonstances permet un gain de performance en évitant l'allocation/dés-allocation répéter.

Par exemple, il y quelques semaines, j'ai fait un algorithme qui faisait au total 2'100'000 new pour au final ne garder que 100'000 objets. Donc 2'000'000 de delete.
Dans le pire des cas, il y avait une suite de 25 objets à supprimer. Avec un allocateur qui ne vide pas la mémoire mais garde un tableau des pointeurs alloués je n'avais plus qu'à faire 25 dés-allocations au lieu de 2'000'000 et le nombre de new effectuées descendait quant à lui à 100'025.
Seul le nombre d'appels au destructeur et au placement new restait inchangé. Respectivement 2'000'000 et 2'100'000.

Au final l'algorithme était quand même 30% plus rapide :).

Ce type d'optimisation reste toutefois exceptionnel et n'est pas adapté à tous les containers. Par exemple, std::vector se prête mal à ce genre d'exercice car il demande toujours une allocation d'au moins la taille du nombre d'éléments qu'il possède.
Par contre, les containers comme std::list ou std::map, qui allouent toujours un seul élément à la fois sont un meilleur choix pour utiliser ce type d'allocateur.
Cependant, comme les containers retournent une copie de leur allocateur, il sera difficile de supprimer de manière simple la mémoire non utilisée par l'allocateur du container.

En ce moment, j'ajoute plusieurs allocateurs dans falcon/memory, même si celui dont je viens de parler n'est pas encore présent car son implémentation était vraiment basique et spécifique, il fait quand même partie de ma todo-list.