Qui est responsable de la mémoire ?

Article original : Who owns the memory? | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Avez-vous déjà entendu parler de « propriété » (en anglais : « ownership« ) de la mémoire, en C++, dans le contexte des pointeurs ?

Quand vous utilisez des pointeurs bruts, ils doivent à un moment ou à un autre être libérés (sinon, vous aurez une fuite mémoire). Mais si ledit pointeur est passé à des fonctions et à travers des fonctionnalités complexes, ou renvoyé par une factory, vous devez savoir qui a la responsabilité de le libérer.

La « propriété » signifie la « responsabilité de nettoyer ». Le propriétaire de la mémoire est celui qui doit libérer le pointeur associé.

La libération peut tantôt être explicite (à travers le mot-clé delete ou la fonction free() dans le cadre des pointeurs bruts) ou rattachée au cycle de vie d’un objet (à travers des pointeurs intelligents — des smart pointers — et la RAII1).

Dans cet article, le terme « pointeur » sera utilisé pour parler à la fois des pointeurs bruts et des pointeurs intelligents.

La problématique de la propriété de mémoire

Ce problème est résolu par l’article suivant : You Can Stop Writing Comments About Pointer Ownership (gpfault.net).

TL;DR : les pointeurs intelligents peuvent remplacer tous les pointeurs bruts, quelle que soit la situation, du coup n’utilisez pas les pointeurs bruts. Les sémantiques de move peuvent permettre de gérer la propriété, et sont vérifiées à la compilation.

L’article est intéressant, mais rate une problématique pourtant importante : que doit-on faire avec les pointeurs déjà existants ? Que doit-on faire quand on est forcé d’utiliser des pointeurs bruts2 ?

Dans la suite de l’article, ce seront les questions auxquelles j’essaierai de répondre.

Pour commencer, quand vous avez une fonctionnalité qui requiert des pointeurs bruts, vous devez vous poser la question: comment cette fonctionnalité se comporte-t-elle au regard de la propriété de mémoire ?

Quand cette question est répondue, on peut distinguer quatre cas :

  • Quand on reçoit un pointeur et qu’on en devient propriétaire.
  • Quand on reçoit un pointeur mais qu’on n’en devient pas propriétaire.
  • Quand on transmet un pointeur mais qu’on n’en devient pas propriétaire.
  • Quand on transmet un pointeur et qu’on en devient propriétaire.

Quand on reçoit un pointeur et qu’on en devient propriétaire

Ce cas est probablement le plus simple. Comme on peut construire un std::unique_ptr ou un std::shared_ptr avec un pointeur brut, tout ce qu’on a à faire c’est de positionner ledit pointeur brut dans un pointeur intelligent et il sera proprement libéré à la fin de son cycle de vie.

Exemple

#include <memory>
#include <iostream>
 
struct Foo
{
    Foo() { std::cout << "Fuite ?" << std::endl; }
    ~Foo() { std::cout << "Pas de fuite" << std::endl; }
};
 
// On n'est pas propriétaire de cette fonction, on ne peut donc pas changer le type renvoyé
Foo * make_Foo()
{
    return new Foo();
}
 
int main()
{
    std::unique_ptr<Foo> foo_ptr(make_Foo());
    // L'instance de Foo est proprement libérée à la fin de la fonction
    return 0;
}

La sortie ressemble à ça:

Fuite ?
Pas de fuite

Quand on reçoit un pointeur mais qu’on n’en devient pas propriétaire

Ce cas est un peu plus complexe. Parfois, pour des raisons particulières, une fonctionnalité vous donne un pointeur que vous ne devez pas libérer.

Dans ce cas-ci, on ne peut pas utiliser un pointeur intelligent (comme dans le premier cas), parce que ce dernier va libérer le pointeur à sa destruction.

Par exemple, dans l’exemple suivant, la classe IntContainer créé un pointeur sur un int et le libère à la fin de son propre cycle de vie :

// On n'est pas propriétaire de cette classe, on ne peut pas la modifier
struct IntContainer
{
    IntContainer(): int_ptr(new int(0)) {}
    ~IntContainer() { delete int_ptr; }
 
    int * get_ptr() { return int_ptr; }
 
    int * int_ptr;
};

Si on essaie d’utiliser un unique_ptr, comme ceci :

int main()
{
    IntContainer int_cont;
    std::unique_ptr<int>(int_cont.get_ptr());
    // Double delete
    return 0;
}

On aura un comportement indéfini. Avec mon compilateur (GCC 11.2), j’ai une exception qui est levée : `free(): double free detected in tcache 2`.

Il y a une solution simple à ce problème. À la place d’utiliser un pointeur, on peut récupérer une référence sur l’objet pointé. De cette manière, on pourra l’utiliser sans risquer de le détruire.

int main()
{
    IntContainer int_cont;
    int & int_ref = *int_cont.get_ptr();
    // On a accès à la valeur de int_ptr via la référence
    return 0;
}

Quand on transmet un pointeur mais qu’on n’en devient pas propriétaire

Certaines librairies ont besoin que vous leur passiez un pointeur brut en paramètre. Dans la plupart des cas, vous gardez la propriété de ces pointeurs, mais le problème de devoir passer un pointeur brut est bien présent.

Il y a deux situations :

  • L’objet à transmettre est une valeur ou une référence.
  • L’objet à transmettre est dans un pointeur intelligent.

Situation 1 : L’objet à transmettre est une valeur ou une référence

Dans cette situation, tout ce que vous avez à faire est d’utiliser l’opérateur & pour passer l’adresse de l’objet à la fonctionnalité qui le demande. Comme elle n’essaiera pas de libérer ce pointeur, rien de néfaste arrivera.

#include <iostream>
 
struct Foo
{
    Foo() { std::cout << "Fuite ?" << std::endl; }
    ~Foo() { std::cout << "Pas de fuite" << std::endl; }
};
 
// Fonction qui nécessite un pointeur brut
void compute(Foo *)
{
    // ...
}
 
int main()
{
    Foo foo;
    // ...
    compute(&foo);
    return 0;
}   

Situation 2 : L’objet à transmettre est dans un pointeur intelligent

Quand tout ce que vous avez est un pointeur intelligent vers l’objet qu’il faut passer à la fonction, vous pouvez utiliser la fonction membre get() pour récupérer le pointeur brut associé au pointeur intelligent. unique_ptr et shared_ptr implémentent tous deux cette fonction3.

#include <memory>
#include <iostream>
 
struct Foo
{
    Foo() { std::cout << "Fuite ?" << std::endl; }
    ~Foo() { std::cout << "Pas de fuite" << std::endl; }
};
 
// Fonction qui nécessite un pointeur brut
void compute(Foo *)
{
    // ...
}
 
int main()
{
    std::unique_ptr<Foo> foo_ptr = std::make_unique<Foo>();
    // ...
    compute(foo_ptr.get());
    return 0;
}

Quand on transmet un pointeur et qu’on en devient propriétaire

Probablement le cas le plus rare d’entre tous4, mais qui peut hypothétiquement exister, d’une fonctionnalité qui demande un pointeur brut et se charge elle-même de le libérer.

Situation 1 : L’objet à transmettre est une valeur ou une référence

Si vous avez l’objet en tant que valeur ou référence, la seule manière d’avoir un pointeur brut qui peut être détruit par autrui est d’appeler new.

Cependant, juste faire un new va copier l’objet est ce n’est pas souhaitable. Comme la propriété est théoriquement passée à la fonctionnalité, on peut faire un std::move sur l’objet pour appeler le constructeur de move (s’il existe) et éventuellement éviter une copie coûteuse.

On a donc juste besoin de faire un new sur l’objet, dans lequel applique move, cela créera le pointeur voulu, qu’on a juste à passer à la fonction.

#include <iostream>
 
struct Foo
{
    Foo() { std::cout << "Fuite?" << std::endl; }
    Foo(const Foo &&) { std::cout << "Constructeur de move" << std::endl; }
    ~Foo() { std::cout << "Pas de fuite" << std::endl; }
};
 
void compute(Foo *foo)
{
    // ...
    delete foo;
}
 
int main()
{
    Foo foo;
    // ...
    compute(new Foo(std::move(foo)));
}

Situation 2 : L’objet à transmettre est dans un pointeur intelligent

La fonction membre get() ne permet pas de transmettre la propriété, donc si on l’utilise pour passer le pointeur brut, la mémoire sera libérée deux fois.

La fonction membre release(), par contre, relâche la propriété en même temps qu’elle renvoie le pointeur brut. C’est ce qu’on voudra utiliser dans cette situation.

#include <iostream>
#include <memory>
 
struct Foo
{
    Foo() { std::cout << "Fuite ?" << std::endl; }
    ~Foo() { std::cout << "Pas de fuite" << std::endl; }
};
 
void compute(Foo *foo)
{
    // ...
    delete foo;
}
 
int main()
{
    std::unique_ptr<Foo> foo_ptr = std::make_unique<Foo>();
    // ...
    compute(foo_ptr.release());
    return 0;
}

Le souci c’est que release() n’est une que membre de unique_ptr, pas de shared_ptr. Les pointeurs « partagés » peuvent avoir de multiples instances qui pointent sur la même ressource, de ce fait ils ne sont pas vraiment propriétaires de la mémoire en premier lieu.

Comment reconnaître l’intention d’une fonctionnalité ?

C’est la question clé quand on fait du refactoring, parce que mal identifier l’intention de la fonctionnalité concernant la propriété de la mémoire va mener soit à des fuites de mémoire, soit à des comportements indéfinis.

En règle générale, la documentation d’une fonctionnalité permet d’obtenir la réponse à cette question.

Comment faire avec les pointeurs alloués par malloc ?

Les cas présentés dans cet article ne concernent que la mémoire qui est allouée avec new et libérée avec delete.

Mais il y a de rares cas où les fonctionnalités utilisées ont recours malloc() et free() à la place.

Quand une fonctionnalité requiert un pointeur brut et que vous avez à le libérer, le problème est inexistant (vous avez le contrôle de l’allocation et de la libération).

Quand une fonctionnalité vous renvoie un pointeur brut (créé par malloc) et que vous ne devez pas le libérer, vous n’avez rien à faire de spécial (vous pouvez utiliser une référence sur l’objet pointé comme indiqué plus haut).

Quand une fonctionnalité requiert un pointeur et que vous ne devez pas le libérer (parce qu’elle utilise free dessus), vous aurez à faire le malloc vous-même. Si vous utilisez un pointeur intelligent, vous devrez malheureusement faire quand un malloc.

Dernièrement, quand une fonctionnalité vous donne un pointeur brut (créé par malloc) et que vous avez à le libérer, ça devient compliqué. La meilleure façon de faire cela est d’utiliser un unique_ptr, avec un « libérateur » personnalisé, en tant que second template du type. En effet, le second template de unique_ptr est un foncteur (c’est-à-dire une classe qui implémente operator()) et qui sera appelé quand on aura besoin de libérer la mémoire. Dans notre cas spécifique, le libérateur dont on a besoin n’a qu’à appeler la fonction free(). Voici un exemple :

#include <memory>
#include <iostream>
 
struct Foo {};
 
// On n'est pas propriétaire de cette fonction, on ne peut pas changer le type renvoyé
Foo * make_Foo()
{
    return reinterpret_cast<Foo*>(malloc(sizeof(Foo)));
}
 
// Ce libérateur est implémenté pour Foo spécifiquement, 
// mais on pourrait écrire un libérateur générique templaté qui appelle free()
struct FooFreer
{
    void operator()(Foo* foo_ptr)
    {
        free(foo_ptr);
    }
};
 
int main()
{
    std::unique_ptr<Foo, FooFreer> foo_ptr(make_Foo());
    // L'instance de Foo est bien libérée à la fin de la fonction
    return 0;
}

Conclusion

Voici un tableau résumant ce qui a été montré ici :

Je reçois un pointeur brutJe transmet un pointeur brut
Je dois le libérerLe garder dans un unique_ptr ou shared_ptrUtiliser l’opérateur & ou la fonction .get()
Je ne dois pas le libérerRécupérer une référence sur l’objetUtiliser l’opérateur new avec un move ou la fonction .release()

Avec ces outils, vous pouvez retirer les pointeurs bruts de votre code en toute sécurité, même si certaines de vos librairies clientes les utilises.

Les solutions proposées sont très simples, mais il est critique d’identifier laquelle utiliser dans chaque situation. Le principal problème avec cette méthode est que la personne qui refactorise le code doit être capable d’identifier la propriété (mais c’est un problème qu’il est impossible d’éviter).

Merci de votre attention et à la prochaine!

Article original : Who owns the memory? | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addendum

Notes

  1. Dans le cas où vous ne le sauriez pas, la RAII est une technique fondamentale en C++ moderne, et qui concerne l’acquisition et la libération de ressources. Souvent, on dit que « telle chose est RAII » pour dire qu’elle nettoie correctement la mémoire et prévient toute forme de fuite, dès qu’elle est détruite de la pile. Par exemple, les pointeurs bruts ne sont pas RAII, car si vous oubliez le delete, il y aura une fuite mémoire. Au contraire, std::string et std::vector sont RAII car il nettoient leur allocation interne dès qu’ils sont libérés de la pile.
  2. Il est parfois difficile pour certains développeurs de comprendre comment on peut être « forcé » de faire telle ou telle chose dans son code. Voici une petite liste de situations à titre d’exemple :
    – Quand on arrive sur un projet existant. On ne peut pas tout refactoriser directement, de son propre chef. Il faut s’adapter et prendre son temps pour faire bouger les choses.
    – Quand on n’est pas propriétaire de certaines partie du code. Sur beaucoup de projets, certaines parties fondamentales du code sont développées par une autre équipe, dans laquelle il est impossible d’ingérer.
    – Quand on doit mettre des priorités sur les fonctionnalités à refactoriser. On ne peut pas tout refaire d’un coup, il faut y aller étape par étape.
    – Quand la hiérarchie managériale fait blocus, faut de budget ou de personnel. Ça peut arriver, et on ne peut pas faire grand chose contre ça.
  3. Aucun cas présenté dans cet article ne fonctionne avec les std::weak_ptr.
  4. En écrivant cet article, je n’ai pu trouver aucun exemple (sur internet ou dans mes souvenirs) d’une fonctionnalité qui requiert un pointeur et le libère elle-même, à votre place.

Laisser un commentaire