Ça sert à quoi make_shared ?

Quel est l’intérêt de make_shared ? Après tout, on peut très bien s’en passer pour créer des shared_ptr :

shared_ptr<int> age{new int(42)};

Par rapport à la ligne qui précède, quelle est la valeur ajoutée de la ligne suivante :

shared_ptr<int> age = make_shared<int>(42);

TL;DR : c’est plus un peu plus efficace, et un peu moins risqué.

Mais au fait, ça marche comment, shared_ptr ?

Pour répondre à la question, il faut comprendre comment est implémenté shared_ptr : voici ce qu’en dit cppreference :

In a typical implementation, shared_ptr holds only two pointers:

  • the stored pointer (one returned by get());

  • a pointer to control block.

The control block is a dynamically-allocated object that holds:

  • either a pointer to the managed object or the managed object itself;

  • […​]

  • the number of shared_ptrs that own the managed object;

Ainsi, dans l’exemple précédent, on a :

  • l’objet managé, ici un int, heap-allocated

  • le control-block du shared_ptr, heap-allocated

  • l’objet shared_ptr lui-même, stack-allocated, et qui contient des pointeurs vers les deux objets précédents

Il dit qu’il voit pas le rapport

Le rapport, c’est que les heap-allocations sont lentes ; en même temps, quand on voit tout ce que malloc doit gérer, c’est pas étonnant…​

Et que sans make_shared, on en fait deux : une pour créer l’objet managé, et une pour allouer le control-block, alors qu’avec make_shared, les deux sont faites en une seule allocation.

En plus d’être plus rapide, c’est également plus cache-friendly : comme une seule allocation est faite pour l’objet managé et pour le control-block, les données seront nécessairement contiguës en mémoire.

Et c’est tout ?

C’est déjà pas mal, mais c’est pas tout.

Sans make_shared, on a deux expressions, l’une pour créer l’objet managé, et l’autre pour créer le shared_ptr. Dans certains cas, les deux expressions ne sont pas évaluées l’une à la suite de l’autre, ça peut poser problème. Par exemple, dans le cas suivant :

do_something(shared_ptr<int>(new int(42)), dangerous_function());

C’est pas obligatoire, mais il se pourrait que cette ligne soit évaluée dans l’ordre suivant :

  1. création de l’int 42

  2. appel de dangerous_function

  3. création du shared_ptr gérant 42

  4. appel de do_something

Si dangerous_function throw, flip-flap le leak : l’objet managé a été créé, mais ne sera jamais détruit (>_<')

Alors qu’avec make_shared, la création de l’objet managé, et le contrôle de son cycle de vie par le shared_ptr ont lieu dans la même expression et sont indissociables :

do_something(make_shared(42), dangerous_function());

En généralisant un peu, on voit pourquoi il est déconseillé de différer la création d’un objet et son contrôle par le shared_ptr :

int* age = new int(42);
// some code
shared_ptr<int> safe_age{age};

Si some code throw ou retourne, on aura le même problème que plus haut : l’objet ne sera jamais détruit car jamais passé au shared_ptr censé gérer son cycle de vie.

Le mot de la fin

Ça n’a pas grand chose à voir avec make_shared, mais vu qu’on a discuté de l’implémentation de shared_ptr…​

On voit que la bonne façon de partager des shared_ptr sur un objet managé est par copie/assignation du shared_ptr (plutôt que du pointeur vers l’objet managé), car sinon, le control-block ne sera pas partagé :

shared_ptr<int> age1 = make_shared<int>(42);

// GOOD : tous les shared_ptr partagent le même control-block :
shared_ptr<int> age2 = age1;

// BAD : age3 a un control-block indépendant de celui de age2 et age3 :
shared_ptr<int> age3{age1.get()};

Ici, l’int 42 va être détruit deux fois : lorsque age1 et age2 seront détruits, ET lorsque age3 sera détruit !

The ownership of an object can only be shared with another shared_ptr by copy constructing or copy assigning its value to another shared_ptr.

Constructing a new shared_ptr using the raw underlying pointer owned by another shared_ptr leads to undefined behavior.