shared_ptr, weak_ptr et graphes de propriété
Comprenez la propriété partagée par comptage de références avec shared_ptr, brisez les cycles avec weak_ptr, découvrez les blocs de contrôle et l'aliasing, et sachez quand shared_ptr n'est pas le bon choix.
shared_ptr : la propriété partagée
std::shared_ptr permet la propriété partagée — plusieurs instances de shared_ptr peuvent posséder le même objet. L'objet est détruit lorsque le dernier shared_ptr propriétaire est détruit ou réinitialisé.
En interne, shared_ptr maintient un bloc de contrôle contenant :
- Un compteur de références fortes (nombre de propriétaires shared_ptr)
- Un compteur de références faibles (nombre d'observateurs weak_ptr)
- Le suppresseur et l'allocateur
shared_ptr est plus grand qu'un pointeur brut (généralement 2 pointeurs : un vers l'objet, un vers le bloc de contrôle) et le comptage de références entraîne un surcoût atomique à chaque copie ou destruction. Ne l'utilisez que lorsque la propriété est réellement partagée.
make_shared et comptage de références
std::make_shared alloue l'objet ET le bloc de contrôle en une seule allocation (au lieu de deux). C'est plus favorable au cache, et cela évite le problème de sûreté vis-à-vis des exceptions du new brut.
#include <iostream>
#include <memory>
#include <string>
struct Logger {
std::string name;
Logger(std::string n) : name(std::move(n)) {
std::cout << name << " created\n";
}
~Logger() { std::cout << name << " destroyed\n"; }
void log(const std::string& msg) {
std::cout << "[" << name << "] " << msg << '\n';
}
};
int main() {
auto logger = std::make_shared<Logger>("AppLogger");
std::cout << "use_count: " << logger.use_count() << '\n'; // 1
{
auto copy = logger; // shared ownership
std::cout << "use_count: " << logger.use_count() << '\n'; // 2
copy->log("inside scope");
} // copy destroyed, count drops to 1
std::cout << "use_count: " << logger.use_count() << '\n'; // 1
logger->log("still alive");
} // logger destroyed — count drops to 0, Logger deletedweak_ptr : briser les cycles
std::weak_ptr observe un objet géré par shared_ptr sans prolonger sa durée de vie. Pour accéder à l'objet, appelez lock(), qui retourne un shared_ptr — ou nullptr si l'objet a déjà été détruit. weak_ptr est indispensable pour briser les références circulaires qui, sinon, provoqueraient des fuites.
#include <iostream>
#include <memory>
#include <string>
struct Employee;
struct Team {
std::string name;
std::vector<std::shared_ptr<Employee>> members;
Team(std::string n) : name(std::move(n)) {}
~Team() { std::cout << "Team " << name << " destroyed\n"; }
};
struct Employee {
std::string name;
std::weak_ptr<Team> team; // weak — does NOT own the team
Employee(std::string n) : name(std::move(n)) {}
~Employee() { std::cout << "Employee " << name << " destroyed\n"; }
void print_team() {
if (auto t = team.lock()) { // try to get shared_ptr
std::cout << name << " belongs to " << t->name << '\n';
} else {
std::cout << name << " has no team\n";
}
}
};
int main() {
auto team = std::make_shared<Team>("Engineering");
auto alice = std::make_shared<Employee>("Alice");
auto bob = std::make_shared<Employee>("Bob");
team->members.push_back(alice);
team->members.push_back(bob);
alice->team = team; // weak_ptr — no circular ownership
bob->team = team;
alice->print_team(); // "Alice belongs to Engineering"
team.reset(); // destroy team
alice->print_team(); // "Alice has no team" — weak_ptr expired
}Si deux objets détiennent chacun un shared_ptr vers l'autre, aucun des deux ne peut jamais voir son compteur de références tomber à zéro — ils se maintiennent mutuellement en vie pour toujours. C'est une fuite de mémoire, alors même que vous utilisez des pointeurs intelligents.
struct Node {
std::shared_ptr<Node> next; // si next pointe en retour : FUITE !
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a; // circulaire — ni a ni b ne seront jamais détruitsLa solution : faire de l'une des deux directions un weak_ptr. Dans les structures arborescentes, les enfants détiennent un weak_ptr vers leur parent (ou un pointeur brut observateur). Dans les listes doublement chaînées, utilisez weak_ptr pour le pointeur prev.
Le constructeur d'aliasing et enable_shared_from_this
Le constructeur d'aliasing crée un shared_ptr qui partage la propriété avec un shared_ptr existant, mais qui pointe vers un objet différent (généralement un membre). L'objet d'origine reste en vie tant qu'il existe un shared_ptr alias :
struct Pair { int first; int second; };
auto p = std::make_shared<Pair>(Pair{1, 2});
std::shared_ptr<int> sp_first(p, &p->first); // partage le bloc de contrôle de p
// p peut sortir de portée — Pair reste en vie car sp_first y fait encore référenceenable_shared_from_this est une classe de base CRTP qui permet à un objet géré par shared_ptr d'obtenir un shared_ptr vers lui-même via shared_from_this(). Sans elle, construire un nouveau shared_ptr à partir de this crée un second bloc de contrôle, ce qui mène à une double libération.
shared_ptr est surutilisé. N'y recourez que lorsque la propriété est réellement partagée entre des durées de vie indépendantes. N'utilisez PAS shared_ptr comme un moyen commode d'éviter de réfléchir à la propriété.
Évitez shared_ptr lorsque :
- Il n'existe qu'un seul propriétaire — utilisez plutôt unique_ptr (aucun surcoût).
- L'objet vit sur la pile — utilisez simplement l'objet directement.
- Vous avez juste besoin de passer une référence — passez T& ou T*.
- Vous l'utilisez « parce que c'est sûr » — shared_ptr a un coût à l'exécution (compteur de références atomique, allocation supplémentaire) et masque la sémantique de propriété.
- Dans les chemins critiques pour les performances — les opérations atomiques sur le compteur de références sont étonnamment coûteuses en cas de contention.
Scénarios légitimes de propriété partagée : les caches (plusieurs recherches retournent le même objet), les systèmes pub/sub, les objets de configuration partagés et les structures de données de graphe dont les nœuds sont réellement partagés.
Fonctionnement interne du bloc de contrôle
Comprendre le bloc de contrôle aide à expliquer les coûts de shared_ptr. make_shared regroupe l'objet et le bloc de contrôle en une seule allocation. La construction à partir d'un new brut nécessite deux allocations distinctes.
#include <memory>
#include <iostream>
struct Widget {
int data[4];
};
int main() {
// Two allocations: one for Widget, one for control block
std::shared_ptr<Widget> p1(new Widget{});
// Single allocation: Widget + control block together
auto p2 = std::make_shared<Widget>();
// Sizes (typical 64-bit platform)
std::cout << "sizeof(Widget*): " << sizeof(Widget*) << '\n'; // 8
std::cout << "sizeof(unique_ptr<Widget>): " << sizeof(std::unique_ptr<Widget>) << '\n'; // 8
std::cout << "sizeof(shared_ptr<Widget>): " << sizeof(std::shared_ptr<Widget>) << '\n'; // 16
// shared_ptr is 2 pointers: one to object, one to control block
// Control block contains: strong count, weak count, deleter, allocator
std::cout << "p2 use_count: " << p2.use_count() << '\n'; // 1
std::weak_ptr<Widget> w = p2;
std::cout << "p2 use_count: " << p2.use_count() << '\n'; // still 1
// weak_ptr does NOT increase strong count
}shared_ptrutilise le comptage de références — l'objet meurt lorsque le dernier propriétaire disparaîtmake_sharedalloue l'objet et le bloc de contrôle en une seule allocation (préférez-le toujours)weak_ptrobserve sans posséder — utilisezlock()pour accéder à l'objet en toute sécurité ; il brise les références circulairesshared_ptroccupe la taille de 2 pointeurs et subit le surcoût d'un compteur de références atomique — il n'est pas gratuit- Utilisez
unique_ptrpar défaut ; n'utilisezshared_ptrque lorsque la propriété est réellement partagée - Ne construisez jamais deux
shared_ptrà partir du même pointeur brut — utilisezenable_shared_from_thispour les auto-références
Quiz — Testez vos connaissances
(15 XP)1. Que se passe-t-il lorsque deux objets détiennent chacun un `shared_ptr` vers l'autre ?
2. Pourquoi préfère-t-on `std::make_shared` à `shared_ptr<T>(new T(...))` ?
3. Comment accéder en toute sécurité à un objet via un `weak_ptr` ?