Aller au contenu

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(args...) 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.

shared_ptr_basics.cpp
#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 deleted

weak_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.

weak_ptr_example.cpp
#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
}
Piège

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étruits

La 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érence

enable_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.

Bonne pratique

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.

control_block.cpp
#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
}
Points clés
  • shared_ptr utilise le comptage de références — l'objet meurt lorsque le dernier propriétaire disparaît
  • make_shared alloue l'objet et le bloc de contrôle en une seule allocation (préférez-le toujours)
  • weak_ptr observe sans posséder — utilisez lock() pour accéder à l'objet en toute sécurité ; il brise les références circulaires
  • shared_ptr occupe 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_ptr par défaut ; n'utilisez shared_ptr que lorsque la propriété est réellement partagée
  • Ne construisez jamais deux shared_ptr à partir du même pointeur brut — utilisez enable_shared_from_this pour 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` ?