Pointeurs intelligents

Le problème exposé dans ce sujet a été résolu.

Bonjour,

Je me pose deux questions à propos des pointeurs intelligents en C++11.

1 - std::shared_ptr et make_shared n'existe pas avec mon compilateur, MinGW / GCC 4.x sous windows; j'ai recours aux shared_ptr de boost à la place, qui sont apparament en tout point identiques. J'ai cru comprendre qu'il y avait des fix pour make_unique mais je n'ai rien vu pour make_shared.

En outre, pourquoi make_shared a-t-il été inventé ? Quelqu'un peut-il m'expliquer pourquoi ce code est considéré comme mauvais ?

1
shared_ptr<Foo> bar(new Foo(... ctor args ...) );

2 - A propos de unique_ptr, j'ai beau lire, relire et rerelire des docs et des explications, même celles contenant des exemples bidon, je ne comprends toujours pas l'intérêt qu'il y a à l'utiliser à la place de shared_ptr. IL me semble que le comptage de référence est quand même extrêmement stable, et en cas de boucles on a weak_ptr; il y a des cas concrets où on ne peut pas utiliser shared_ptr et/ou où unique_ptr est vraiment mieux ? pourquoi ?

Merci pour vos réponses.

+0 -0

0- Tu as bien activé le mode C++11 ? gcc sous windows le supporte normalement.

1- make_shared permet de travailler avec du AAA (Almost Always Auto)

1
auto ptr = make_shared<T>(arg1, arg2, .....);

Mais surtout, il corrige ce bug-ci

1
2
3
void f(UnSmartPtr a, UnSmartPtr b);
...
f(UnSmartPtr(new T(params1)), UnSmartPtr(new T(params2)));

Vois-tu le bug ?

Après, c'est plus optimisé – cherche "perfect forwarding" avec make_shared ou make_unique.

2- unique_ptr est vraiment beaucoup plus léger. On ne paie pas un compteur atomique qui n'est pas si gratuit que cela.
De plus, c'est un signe fort (nul besoin de documentation supplémentaire pour savoir ce qui arrive à la donnée et ce que l'on attend d'elle) quand il apparait dans des signatures.

1
2
3
4
void f(std::unique_ptr<T>);      // f s'approprie la ressource et décharge l'appelant de sa responsabilité
void f(T const&);                // f consulte une donnée qui n'est pas et qui ne sera jamais de sa responsabilité
void f(std::optional<T> const&); // f consulte une donnée optionnelle
std::unique_ptr<T> f();          // f force l'appelant à assumer la responsabilité de la ressource créée

Lu'!

Bon @lmghs est passé avant moi, donc en retirant ce qu'il a déjà dit :

2 - A propos de unique_ptr, j'ai beau lire, relire et rerelire des docs et des explications, même celles contenant des exemples bidon, je ne comprends toujours pas l'intérêt qu'il y a à l'utiliser à la place de shared_ptr.

QuentinC

std::unique_ptr est un cas particulier de gestion des ressources, c'est le cas où le responsable de la ressource est unique. Et ce cas particulier est clairement le plus répandu en fait. Il est très rare que la libération soit vraiment du ressort de plusieurs entités. Tu prends un conteneur d'objets polymorphiques :

1
std::vector<std::unique_ptr<TrucPoly>> mes_trucs;

Il n'y aucune raison que la responsabilité des ressources qu'il possède soit partagée. (Removed : coût de shared).

std::unique_ptr apporte plusieurs points :

  • Il est non copiable : on a la garantit qu'on est le seul possesseur de la ressource
  • Il est exactement aussi coûteux que faire new/delete à la main à l'exécution
  • Lui, n'oublie pas de libérer parce que le destructeur sera appelé quelque soit le scénario d'exécution.

Il y a d'ailleurs un point très important à propos de shared_ptr, c'est justement qu'actuellement il est décrié du fait que les développeurs ont tendance à le sur-utiliser. C'est à dire qu'ils ne réfléchissent plus à qui est responsable de la ressource. Le coût en performances est redoutable.

0- Tu as bien activé le mode C++11 ? gcc sous windows le supporte normalement.

J'ai bien mis le flag -std=gnu++11, et g++ –version me dit que j'ai la 4.8.1; je ne sais pas si c'est la dernière, peut-être pas. IL y aurait un autre flag ?

Mais surtout, il corrige ce bug-ci

Vois-tu le bug ?

Pas vraiment. Mais j'imagine que ça doit être un coup tordu impliquant une exception lancée depuis un des constructeurs, qui provoque au choix une destruction prématurée ou alors une non-destruction et donc une fuite mémoire. Je ne vois pas de problème si la fonction lance une exception en tout cas…

std::unique_ptr est un cas particulier de gestion des ressources, c'est le cas où le responsable de la ressource est unique. Et ce cas particulier est clairement le plus répandu en fait. Il est très rare que la libération soit vraiment du ressort de plusieurs entités. Tu prends un conteneur d'objets polymorphiques :

1
std::vector<std::unique_ptr<TrucPoly>> mes_trucs;

Il n'y aucune raison que la responsabilité des ressources qu'il possède soit partagée. (Removed : coût de shared).

Oui, mais alors je ne pige pas un truc.

Si j'ai bien compris, si x et y sont des unique_ptr et que j'écris x=y, alors la responsabilité de détruire l'objet revient à x et y n'a plus à s'en inquiéter. Donc dans cette logique, si j'écris ce code :

1
2
3
4
5
6
7
void bidule (std::unique_ptr<TrucPoly> untruc) {
...
}

...

for (auto x: mesTrucs) bidule(x);

Alors boum, la fonction détruit mes objets un par un lorsqu'elle retourne et à la fin de la boucle je n'ai que des pointeurs invalides.

Ca me paraîtrait plutôt bizarre de passer des pointeurs par référence, même si c'est des pointeurs intelligents. ET ça me semble tout aussi bizarre de passer des pointeurs ou des références nus à la fonction, non ? Est-ce qu'on n'est justement pas censé éviter les pointeurs et les références nus dans un bon code C++ ?

Tiens au fait, où sont stockés les compteurs de référence des shared_ptr ?

Merci.

EDIT: Zut, on ne peut pas mettre de code dans une citation. Dommage!

+0 -0

Mais surtout, il corrige ce bug-ci

Vois-tu le bug ?

Pas vraiment. Mais j'imagine que ça doit être un coup tordu impliquant une exception lancée depuis un des constructeurs, qui provoque au choix une destruction prématurée ou alors une non-destruction et donc une fuite mémoire. Je ne vois pas de problème si la fonction lance une exception en tout cas…

QuentinC

Le bug est dû au fait que rien n'oblige le compilateur à mettre les instructions dans l'ordre :

  • new
  • shared
  • new
  • shared

Il peut très bien faire :

  • new
  • new
  • shared
  • shared

Le résultat c'est que si le second new échoue, le premier n'est pas encore en sécurité dans sa capsule. Et paf, memory-leak.

Si j'ai bien compris, si x et y sont des unique_ptr et que j'écris x=y, alors la responsabilité de détruire l'objet revient à x et y n'a plus à s'en inquiéter. Donc dans cette logique, si j'écris ce code :

1
2
3
4
5
6
7
void bidule (std::unique_ptr<TrucPoly> untruc) {
...
}

...

for (auto x: mesTrucs) bidule(x);

Alors boum, la fonction détruit mes objets un par un lorsqu'elle retourne et à la fin de la boucle je n'ai que des pointeurs invalides.

QuentinC

std::unique_ptr n'est pas copiable. La boucle for que tu écris ne compilera pas. Quant à la fonction bidule, elle ne pourra-t-être utilisé que si l'objet qu'on lui passe est (éventuellement implicitement) déplacé (std::move) à l'appel.

Ca me paraîtrait plutôt bizarre de passer des pointeurs par référence, même si c'est des pointeurs intelligents. ET ça me semble tout aussi bizarre de passer des pointeurs ou des références nus à la fonction, non ? Est-ce qu'on n'est justement pas censé éviter les pointeurs et les références nus dans un bon code C++ ?

QuentinC

La sémantique d'une référence est bien plus fort que celle d'un pointeur. Une référence doit être valide depuis sa création jusqu'à sa disparition. Ce n'est pas le cas d'un pointeur. Le principe de la référence est qu'on sait que la ressource associée est valide mais que l'on en est pas responsable.

Tiens au fait, où sont stockés les compteurs de référence des shared_ptr ?

QuentinC

Sur le tas à côté de la ressource quand make_shared fait le boulot si je me souviens bien.

Je rajouterai pour la boucle que si la fonction est un puits alors oui elle consommera et il sera normal que le container soit privé des ressources qu'il possédait. S'il ne doit rien perdre, alors la fonction devra pendre une référence constante (ou éventuellement une copie). Mais dans tous les cas, l'utilisation de move devra être explicite, le développeur ne pourra pas ne pas savoir ce qu'il fait.

Je rajouterai que si les shared_ptr se font "taper" dessus, c'est parce qu'en C++98 il n'y avait pas beaucoup d'alternatives. Cela a créé des habitudes, et de preux chevaliers cherchent à les corriger pour de meilleures.

Le bug est dû au fait que rien n'oblige le compilateur à mettre les instructions dans l'ordre.

Houlà! Disons que j'avais le quart le plus facile de la réponse. Au moins maintenant j'ai compris, merci!

Je ne vois pas bien quelle optimisation justifierait ce changement d'ordre, mais bon, passons.

std::unique_ptr n'est pas copiable. La boucle for que tu écris ne compilera pas.

Ah… alors comment je fais dans ce cas pour parcourir les éléments du vector ?

Par contre je ne suis pas si sûr que ça ne compilera pas; d'après ce que j'ai déjà pu constater avec auto, il prend des références quand il peut, donc logiquement il n'y a pas de copie dans la boucle.

Si je suis bien vos explications, alors corriger le code précédent en celui-ci n'est pas du tout du mauvais code, vous confirmez ?

1
2
3
void bidule (TrucPoly&);

for (auto x: mesTrucs) bidule(*x);

Je rajouterai que si les shared_ptr se font "taper" dessus, c'est parce qu'en C++98 il n'y avait pas beaucoup d'alternatives. Cela a créé des habitudes, et de preux chevaliers cherchent à les corriger pour de meilleures.

Mouais; donc finalement, mettre du shared_ptr partout, c'est pas si mauvais que ça donc. Au moins on ne s'amuse pas à réfléchir qui est responsable de quoi.

Merci

+0 -0

std::unique_ptr n'est pas copiable. La boucle for que tu écris ne compilera pas.

Ah… alors comment je fais dans ce cas pour parcourir les éléments du vector ?

Par contre je ne suis pas si sûr que ça ne compilera pas; d'après ce que j'ai déjà pu constater avec auto, il prend des références quand il peut, donc logiquement il n'y a pas de copie dans la boucle.

QuentinC

Tu peux regarder par toi même :) . Pour faire ton parcours, tu peux regarder les effets de chaque solution :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <memory>
#include <vector>

using pint = std::unique_ptr<int>;

void foo_r(pint& p){
  assert(p != nullptr);
  std::cout<<*p<<";";
}

void foo_c(pint p){
  assert(p != nullptr);
  std::cout<<*p<<";";
}

int main(){
  std::vector<pint> v;

  for(unsigned i{}; i < 4; ++i)
    v.push_back(std::make_unique<int>(i));

  for(auto p : v) //erreur
    foo_c(p); //erreur

  for(auto& p : v) //ok
    foo_c(p); //erreur

  for(auto p : v) //erreur
    foo_r(p); //ok

  for(auto& p : v) //ok
    foo_r(p); //ok
}

Si je suis bien vos explications, alors corriger le code précédent en celui-ci n'est pas du tout du mauvais code, vous confirmez ?

1
2
3
void bidule (TrucPoly&);

for (auto x: mesTrucs) bidule(*x);

QuentinC

Presque, rien ne garantit que tous les pointeurs sont alloués (std::unique_ptr est initialisé par défaut à nullptr). Avec un check avant déréférencement, c'est ok.

Je rajouterai que si les shared_ptr se font "taper" dessus, c'est parce qu'en C++98 il n'y avait pas beaucoup d'alternatives. Cela a créé des habitudes, et de preux chevaliers cherchent à les corriger pour de meilleures.

Mouais; donc finalement, mettre du shared_ptr partout, c'est pas si mauvais que ça donc. Au moins on ne s'amuse pas à réfléchir qui est responsable de quoi.

QuentinC

Sauf qu'en fait réfléchir à qui est responsable de quoi est un bon moyen de rendre sa conception plus robuste, sans parler du fait que comme @lmghs l'a dit plus tôt le compteur dans shared_ptr n'est pas si gratuit que cela.

Disons que les shared_ptr, c'est aussi bon qu'un singleton ou un setter. Cela a des applications, mais quand on a des meilleurs outils (pas de variables globales / ou penser OO et pas Orienté Données), il faut préférer ces meilleurs outils.

Après, en C++98, cela ne m'empêche pas d'utiliser couramment les décriés std::auto_ptr<> et les boost::ptr_vector<>.

Presque, rien ne garantit que tous les pointeurs sont alloués (std::unique_ptr est initialisé par défaut à nullptr). Avec un check avant déréférencement, c'est ok.

Très logique. A mon sens il n'y a pas vraiment de raison de vouloir ranger des nullptr dans une collection de toute façon. Ayant été bercé au Java avant d'en venir au C++, ça ne me paraît pas pertinent.

Je commence à y voir plus clair maintenant. Merci.

Sauf qu'en fait réfléchir à qui est responsable de quoi est un bon moyen de rendre sa conception plus robuste, sans parler du fait que comme @lmghs l'a dit plus tôt le compteur dans shared_ptr n'est pas si gratuit que cela.

En réalité, c'est le « pas si gratuit que cela » qui mériterait d'être éthayé. Concrètement qu'est-ce que ça coûte ?

Pour le fun je m'étais amusé une fois à faire un shared_ptr maison; je stockais les compteurs dans une unordered_map<void*,int> globale. Je n'ai pas l'impression que ce soit dramatique, et la bibliothèque standard fait sûrement mieux. Je verrais bien un truc comme ce que fait GCC avec les string, mettre le compteur dans quelque chose comme ((size_t*)str.data())[-1].

Je n'étais pas allé jusqu'à faire du thread safe par contre, c'est peut-être là le noeud du problème.

+0 -0

A mon sens il n'y a pas vraiment de raison de vouloir ranger des nullptr dans une collection de toute façon.

QuentinC

Non, effectivement. Mais ta fonction, elle, ne sait pas d'où vient le pointeur qu'on lui passe et on n'est jamais à l'abri d'une bourde. Donc on ajoute un assert comme contrat (pré-condition ici) de notre fonction : le pointeur que je reçois doit être valide, ce qui pour nous correspond à "le pointeur unique est différent de nullptr" (à noter : la validité d'un pointeur ne se limite malheureusement pas à "le pointeur n'est pas nul", mais c'est le mieux qu'on puisse faire dans un programme).

En réalité, c'est le « pas si gratuit que cela » qui mériterait d'être éthayé. Concrètement qu'est-ce que ça coûte ?

QuentinC

Un incrément atomique(*) à chaque fois que l'on copie le shared_ptr. Un décrément atomique(*) + deux tests à chaque fois que l'on en détruit un qui contient une ressource (un test, quand il n'en contient pas). Là où unique_ptr coûte … ben rien en fait. Le surcoût mémoire par pointeur créé est de l'ordre de un compteur par instance.

(*) donc : lock du bus + lecture/écriture + flush des buffers d'écriture + unlock du bus.

Le coût est bien une histoire de thread-safety. Un int ne suit pas. Il est nécessaire d'employer des opérations de type CAS (compare and swap), et elles sont plus chères qu'un "simple" if(--int == 0) – tout en étant moins chères que des mutex.

Et bien évidemment un bon shared_ptr ne peut pas être non thread safe.

Et bien évidemment un bon shared_ptr ne peut pas être non thread safe.

Merci.

Je crois qu'on a à peu près fait le tour, juste encore une dernière question: le shared_ptr fourni en standard et celui de boost sont-il bien thread safe ?

Merci.

+0 -0

Pour le standard :

All member functions (including copy constructor and copy assignment) can be called by multiple threads on different instances of shared_ptr without additional synchronization even if these instances are copies and share ownership of the same object. If multiple threads of execution access the same shared_ptr without synchronization and any of those accesses uses a non-const member function of shared_ptr then a data race will occur; the shared_ptr overloads of atomic functions can be used to prevent the data race.

cppreference

Pour boost :

shared_ptr objects offer the same level of thread safety as built-in types. A shared_ptr instance can be "read" (accessed using only const operations) simultaneously by multiple threads. Different shared_ptr instances can be "written to" (accessed using mutable operations such as operator= or reset) simultaneously by multiple threads (even when these instances are copies, and share the same reference count underneath.)

Boost Documentation

En résumé : même garanties, plusieurs instances d'un shared_ptr sur une même ressources peuvent être modifiées de manière thread-safe si tout le monde manipule son instance. Une même instance peut être lue de manière thread-safe mais pas modifiée.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte