13.6. Gestion personnalisée de la mémoire : les allocateurs

L'une des plus grandes forces de la librairie standard est de donner aux programmeurs le contrôle total de la gestion de la mémoire pour leurs objets. En effet, les conteneurs peuvent être amenés à créer un grand nombre d'objets, dont le comportement peut être très différent selon leur type. Si, dans la majorité des cas, la gestion de la mémoire effectuée par la librairie standard convient, il peut parfois être nécessaire de prendre en charge soi-même les allocations et les libérations de la mémoire pour certains objets.

La librairie standard utilise pour cela la notion d'allocateur. Un allocateur est une classe C++ disposant de méthodes standards que les algorithmes de la librairie peuvent appeler lorsqu'elles désirent allouer ou libérer de la mémoire. Pour cela, les conteneurs de la librairie standard C++ prennent tous un paramètre template représentant le type des allocateurs mémoire qu'ils devront utiliser. Bien entendu, la librairie standard fournit un allocateur par défaut, et ce paramètre template prend par défaut la valeur de cet allocateur. Ainsi, les programmes qui ne désirent pas spécifier un allocateur spécifique pourront simplement ignorer ce paramètre template.

Les autres programmes pourront définir leur propre allocateur. Cet allocateur devra évidemment fournir toutes les fonctionnalités de l'allocateur standard, et satisfaire à quelques contraintes particulières. L'interface des allocateurs est fournie par la déclaration de l'allocateur standard, dans l'en-tête memory :

template <class T>
class allocator
{
public:
    typedef size_t    size_type;
    typedef ptrdiff_t difference_type;
    typedef T         *pointer;
    typedef const T   *const_pointer;
    typedef T         &reference;
    typedef const T   &const_reference;
    typedef T         value_type;
    template <class U>
    struct rebind
    {
        typedef allocator<U> other;
    };

    allocator() throw();
    allocator(const allocator &) throw();
    template <class U>
    allocator(const allocator<U> &) throw();
    ~allocator() throw();
    pointer address(reference objet);
    const_pointer address(const_reference objet) const;
    pointer allocate(size_type nombre,
        typename allocator<void>::const_pointer indice);
    void deallocate(pointer adresse, size_type nombre);
    size_type max_size() const throw();
    void construct(pointer adresse, const T &valeur);
    void destroy(pointer adresse);
};

// Spécialisation pour le type void pour éliminer les références :
template <>
class allocator<void>
{
public:
    typedef void       *pointer;
    typedef const void *const_pointer;
    typedef void       value_type;
    template <class U>
    struct rebind
    {
        typedef allocator<U> other;
    };
};
Vous noterez que cet allocateur est spécialisé pour le type void, car certaines méthodes et certains typedef n'ont pas de sens pour ce type de donnée.

Le rôle de chacune des méthodes des allocateurs est très clair et n'appelle pas beaucoup de commentaires. Les deux surcharges de la méthode address permettent d'obtenir l'adresse d'un objet alloué par cet allocateur à partir d'une référence. Les méthodes allocate et deallocate permettent respectivement de réaliser une allocation de mémoire et la libération du bloc correspondant. La méthode allocate prend en paramètre le nombre d'objets qui devront être stockés dans le bloc à allouer et un pointeur fournissant des informations permettant de déterminer l'emplacement où l'allocation doit se faire de préférence. Ce dernier paramètre peut ne pas être pris en compte par l'implémentation de la librairie standard que vous utilisez et, s'il l'est, son rôle n'est pas spécifié. Dans tous les cas, s'il n'est pas nul, ce pointeur doit être un pointeur sur un bloc déjà alloué par cet allocateur et non encore libéré. La plupart des implémentations chercheront à allouer un bloc adjacent à celui fourni en paramètre, mais ce n'est pas toujours le cas. De même, notez que le nombre d'objets spécifié à la méthode deallocate doit exactement être le même que celui utilisé pour l'allocation dans l'appel correspondant à allocate. Autrement dit, l'allocateur ne mémorise pas lui-même la taille des blocs mémoire qu'il a fourni.

Note : Le pointeur passé en paramètre à la méthode allocate n'est ni libéré, ni réalloué, ni réutilisé par l'allocateur. Il ne s'agit donc pas d'une modification de la taille mémoire du bloc fourni en paramètre, et ce bloc devra toujours être libéré indépendamment de celui qui sera alloué. Ce pointeur n'est utilisé par les implémentations que comme un indice fourni à l'allocateur afin d'optimiser les allocations de blocs dans les algorithmes et les conteneurs internes.

La méthode allocate peut lancer l'exception bad_alloc en cas de manque de mémoire ou si le nombre d'objets spécifié en paramètre est trop gros. Vous pourrez obtenir le nombre maximal que la méthode allocate est capable d'accepter grâce à la méthode max_size de l'allocateur.

Les deux méthodes construct et destroy permettent respectivement de construire un nouvel objet et d'en détruire un à l'adresse indiquée en paramètre. Elles doivent être utilisées lorsqu'on désire appeler le constructeur ou le destructeur d'un objet stocké dans une zone mémoire allouée par cet allocateur et non par les opérateurs new et delete du langage (rappelons que ces opérateurs effectuent ce travail automatiquement). Pour effectuer la construction d'un nouvel objet, construct utilise l'opérateur new avec placement, et pour le détruire, destroy appelle directement le destructeur de l'objet.

Note : Les méthodes construct et destroy n'effectuent pas l'allocation et la libération de la mémoire elles-mêmes. Ces opérations doivent être effectuées avec les méthodes allocate et deallocate de l'allocateur.

Exemple 13-9. Utilisation de l'allocateur standard

#include <iostream>
#include <memory>

using namespace std;

class A
{
public:
    A();
    A(const A &);
    ~A();
};

A::A()
{
    cout << "Constructeur de A" << endl;
}

A::A(const A &)
{
    cout << "Constructeur de copie de A" << endl;
}

A::~A()
{
    cout << "Destructeur de A" << endl;
}

int main(void)
{
    // Construit une instance de l'allocateur standard pour la classe A :
    allocator<A> A_alloc;

    // Alloue l'espace nécessaire pour stocker cinq instances de A :
    allocator<A>::pointer p = A_alloc.allocate(5);

    // Construit ces instances et les initialise :
    A init;
    int i;
    for (i=0; i<5; ++i)
        A_alloc.construct(p+i, init);
    // Détruit ces instances :
    for (i=0; i<5; ++i)
        A_alloc.destroy(p+i);

    // Reconstruit ces 5 instances :
    for (i=0; i<5; ++i)
        A_alloc.construct(p+i, init);
    // Destruction finale :
    for (i=0; i<5; ++i)
        A_alloc.destroy(p+i);

    // Libère la mémoire :
    A_alloc.deallocate(p, 5);
    return 0;
}

Vous voyez ici l'intérêt que peut avoir les allocateurs de la librairie standard. Les algorithmes peuvent contrôler explicitement la construction et la destruction des objets, et surtout les dissocier des opérations d'allocation et de libération de la mémoire. Ainsi, un algorithme devant effectuer beaucoup d'allocations mémoire pourra, s'il le désire, effectuer ces allocations une bonne fois pour toutes grâce à l'allocateur standard, et n'effectuer les opérations de construction et de destruction des objets que lorsque cela est nécessaire. En procédant ainsi, le temps passé dans les routines de gestion de la mémoire est éliminé et l'algorithme est d'autant plus performant. Inversement, un utilisateur expérimenté pourra définir son propre allocateur mémoire adapté aux objets qu'il voudra stocker dans un conteneur. En imposant au conteneur de la librairie standard d'utiliser cet allocateur personnalisé, il obtiendra des performances optimales.

La définition d'un allocateur maison consiste simplement à implémenter une classe template disposant des mêmes méthodes et types que ceux définis par l'allocateur allocator. Toutefois, il faut savoir que la librairie impose des contraintes sur la sémantique de ces méthodes :

Pour terminer ce tour d'horizon des allocateurs, sachez que la librairie standard définit également un type itérateur spécial permettant de stocker des objets dans une zone de mémoire non initialisée. Cet itérateur, nommé raw_storage_iterator, est de type Output et n'est utilisé qu'en interne par la librairie standard. De même, la librairie définit des algorithmes permettant d'effectuer des copies brutes de blocs mémoire et d'autres manipulations sur les blocs alloués par les allocateurs. Ces algorithmes sont également utilisés en interne, et ne seront donc pas décrits plus en détail ici.