Skip to content

Latest commit

 

History

History
580 lines (424 loc) · 20.6 KB

File metadata and controls

580 lines (424 loc) · 20.6 KB

🔝 Retour au Sommaire

9.2.2 Cycles de références et std::weak_ptr

Le problème des cycles

En section 9.2, nous avons vu qu'un cycle de shared_ptr crée un memory leak structurel : deux objets (ou plus) se référencent mutuellement, empêchant leurs compteurs de références d'atteindre zéro. Dans cette section, nous allons disséquer le problème en profondeur, identifier les structures de données qui y sont vulnérables, et maîtriser std::weak_ptr comme outil de résolution.


Anatomie d'un cycle simple

Reprenons l'exemple classique et traçons l'état des compteurs à chaque étape :

#include <memory>

struct Personne {
    std::string nom;
    std::shared_ptr<Personne> ami;  // ⚠️ Référence forte bidirectionnelle

    Personne(std::string n) : nom(std::move(n)) {
        std::print("[+] {} créé\n", nom);
    }
    ~Personne() {
        std::print("[-] {} détruit\n", nom);
    }
};

void creer_cycle() {
    auto alice = std::make_shared<Personne>("Alice");
    // Alice strong_count = 1 (variable alice)

    auto bob = std::make_shared<Personne>("Bob");
    // Bob strong_count = 1 (variable bob)

    alice->ami = bob;
    // Bob strong_count = 2 (variable bob + alice->ami)

    bob->ami = alice;
    // Alice strong_count = 2 (variable alice + bob->ami)

}   // Fin du scope :
    // alice détruit → Alice strong_count passe de 2 à 1 (bob->ami tient encore)
    // bob détruit   → Bob strong_count passe de 2 à 1 (alice->ami tient encore)
    //
    // Alice strong_count = 1 → PAS libéré
    // Bob strong_count = 1   → PAS libéré
    //
    // Output :
    // [+] Alice créé
    // [+] Bob créé
    // (silence — aucun destructeur n'est appelé)

Le cycle forme un verrouillage mutuel : Alice maintient Bob en vie, Bob maintient Alice en vie, et personne ne peut mourir en premier. La mémoire est irrémédiablement perdue pour le reste de l'exécution du programme.

Après la fin du scope — état en mémoire :

    ┌──────────────────────────────────────┐
    │                                      │
    ▼                                      │
┌────────────┐     ami (shared_ptr)    ┌────────────┐
│   Alice    │ ──────────────────────> │    Bob     │
│ strong: 1  │                         │ strong: 1  │
│            │ <────────────────────── │            │
└────────────┘     ami (shared_ptr)    └────────────┘
    ▲                                      │
    │                                      │
    └──────────────────────────────────────┘

Aucune variable externe ne pointe vers ces objets.  
Ils sont inaccessibles mais non libérés → MEMORY LEAK  

Structures vulnérables aux cycles

Les cycles ne se limitent pas aux relations bidirectionnelles entre deux objets. Ils apparaissent dans toute structure où des shared_ptr forment une boucle, quelle que soit sa longueur.

Cycle direct (longueur 2)

Le cas le plus évident — deux objets se référencent mutuellement, comme l'exemple Alice/Bob ci-dessus. On le rencontre typiquement dans les relations parent↔enfant, les listes doublement chaînées, ou les associations bidirectionnelles.

Cycle indirect (longueur 3+)

Trois objets ou plus forment une chaîne circulaire :

struct Noeud {
    std::string id;
    std::shared_ptr<Noeud> suivant;
    explicit Noeud(std::string i) : id(std::move(i)) {}
    ~Noeud() { std::print("[-] {} détruit\n", id); }
};

void cycle_triangulaire() {
    auto a = std::make_shared<Noeud>("A");
    auto b = std::make_shared<Noeud>("B");
    auto c = std::make_shared<Noeud>("C");

    a->suivant = b;   // A → B
    b->suivant = c;   // B → C
    c->suivant = a;   // C → A  → cycle de longueur 3

}   // Aucun destructeur n'est appelé — les trois objets fuient

Auto-référence (longueur 1)

Un objet qui se référence lui-même :

void auto_reference() {
    auto noeud = std::make_shared<Noeud>("X");
    noeud->suivant = noeud;  // X → X  → auto-cycle

}   // noeud détruit → strong_count passe de 2 à 1
    // L'objet se maintient en vie tout seul → LEAK

Structures couramment touchées

Structure Source du cycle Fréquence
Arbre avec pointeur parent enfant → parent + parent → enfant Très fréquent
Liste doublement chaînée nœud → suivant + nœud → précédent Fréquent
Graphe (orienté ou non) arêtes entre nœuds Fréquent
Observer pattern observable → observateur + observateur → observable Courant
Cache avec back-reference objet cache → propriétaire → cache Subtil
Callbacks / closures callback capture un shared_ptr vers l'objet qui détient le callback Très subtil

La solution : std::weak_ptr

std::weak_ptr brise les cycles en remplaçant l'une des références fortes par une référence faible — une référence qui observe la ressource sans la posséder. Le weak_ptr ne contribue pas au strong count : il ne prolonge pas la durée de vie de la ressource.

Résolution du cycle Alice/Bob

struct Personne {
    std::string nom;
    std::weak_ptr<Personne> ami;  // ✅ Référence faible — pas de cycle

    Personne(std::string n) : nom(std::move(n)) {
        std::print("[+] {} créé\n", nom);
    }
    ~Personne() {
        std::print("[-] {} détruit\n", nom);
    }
};

void sans_cycle() {
    auto alice = std::make_shared<Personne>("Alice");
    auto bob = std::make_shared<Personne>("Bob");

    alice->ami = bob;   // weak_ptr → n'incrémente PAS le strong_count de Bob
    bob->ami = alice;   // weak_ptr → n'incrémente PAS le strong_count d'Alice

    // Alice strong_count = 1 (variable alice uniquement)
    // Bob strong_count = 1 (variable bob uniquement)

}   // bob détruit   → Bob strong_count passe de 1 à 0 → Bob libéré ✅
    // alice détruit → Alice strong_count passe de 1 à 0 → Alice libérée ✅
    //
    // Output :
    // [+] Alice créé
    // [+] Bob créé
    // [-] Bob détruit
    // [-] Alice détruit

Le cycle est brisé parce que les liens ami ne maintiennent plus les objets en vie. Quand les variables locales sortent du scope, chaque compteur tombe à zéro normalement.

Règle générale pour choisir le sens du weak_ptr

Dans une relation bidirectionnelle, la question est : quel lien doit être fort, et lequel doit être faible ? La réponse repose sur la hiérarchie de propriété :

  • Le propriétaire utilise un shared_ptr vers la ressource possédée (lien fort, direction « vers le bas »).
  • La ressource possédée utilise un weak_ptr vers son propriétaire (lien faible, direction « vers le haut »).
                shared_ptr (propriété)
  Parent  ─────────────────────────────>  Enfant
          <─────────────────────────────
                weak_ptr (observation)

Dans un arbre, le parent possède ses enfants (forte), les enfants observent leur parent (faible). Dans un observer pattern, l'observable possède la liste des observateurs (forte), chaque observateur observe l'observable (faible).

Quand la relation est symétrique (deux nœuds d'un graphe, deux amis), le choix du sens est arbitraire — l'important est de briser le cycle en rendant au moins un lien faible.


Utiliser weak_ptr : l'API complète

Création

Un weak_ptr ne se crée jamais à partir de new ou make_shared. Il se crée exclusivement à partir d'un shared_ptr (ou d'un autre weak_ptr) :

auto shared = std::make_shared<std::string>("Hello");

// Création depuis un shared_ptr
std::weak_ptr<std::string> w1 = shared;  
std::weak_ptr<std::string> w2(shared);  

// Copie depuis un autre weak_ptr
std::weak_ptr<std::string> w3 = w1;

// Le strong_count n'a pas changé
std::print("strong_count = {}\n", shared.use_count());  // 1

lock() : promotion sûre en shared_ptr

lock() est la méthode centrale de weak_ptr. Elle tente de promouvoir le weak_ptr en shared_ptr :

  • Si la ressource existe encore (strong count > 0), lock() retourne un shared_ptr valide et incrémente le strong count.
  • Si la ressource a été détruite (strong count == 0), lock() retourne un shared_ptr nul.

L'opération est atomique : il n'y a pas de race condition entre le test et la promotion.

auto shared = std::make_shared<int>(42);  
std::weak_ptr<int> weak = shared;  

// Cas 1 : ressource encore vivante
if (auto locked = weak.lock()) {
    std::print("Valeur : {}\n", *locked);  // 42
    std::print("strong_count = {}\n", shared.use_count());  // 2 (shared + locked)
}
// locked détruit → strong_count revient à 1

// Cas 2 : ressource détruite
shared.reset();  // strong_count → 0 → int(42) libéré

if (auto locked = weak.lock()) {
    std::print("Ceci ne sera jamais affiché\n");
} else {
    std::print("Ressource expirée\n");  // ✅ Affiché
}

Le pattern if (auto locked = weak.lock()) est idiomatique et devrait être votre réflexe systématique pour accéder à une ressource via weak_ptr.

expired() : vérifier sans promouvoir

expired() retourne true si la ressource a été détruite (strong count == 0) :

auto shared = std::make_shared<int>(42);  
std::weak_ptr<int> weak = shared;  

std::print("Expiré ? {}\n", weak.expired());  // false

shared.reset();

std::print("Expiré ? {}\n", weak.expired());  // true

⚠️ Attention en multi-thread. expired() est sujet à une race condition : la ressource peut être détruite par un autre thread immédiatement après que expired() ait retourné false. Ne l'utilisez jamais comme garde avant un lock() — utilisez lock() directement :

// ❌ Race condition entre expired() et lock()
if (!weak.expired()) {
    auto locked = weak.lock();  // Peut retourner nullptr ici !
    *locked;                    // 💥 UB si locked est nul
}

// ✅ lock() est atomique — une seule opération
if (auto locked = weak.lock()) {
    *locked;  // Garanti valide
}

reset() : détacher le weak_ptr

std::weak_ptr<int> weak = shared;  
weak.reset();  // weak ne référence plus rien  
// Le weak_count sur le bloc de contrôle est décrémenté

use_count() : nombre de propriétaires forts

auto shared = std::make_shared<int>(42);  
std::weak_ptr<int> weak = shared;  

// Retourne le strong_count (pas le weak_count)
std::print("Propriétaires forts : {}\n", weak.use_count());  // 1

Patterns courants avec weak_ptr

Pattern 1 : arbre parent↔enfant

Le cas d'usage le plus classique. Le parent possède ses enfants, les enfants observent leur parent :

struct TreeNode {
    std::string valeur;
    std::weak_ptr<TreeNode> parent;                  // ✅ Faible vers le haut
    std::vector<std::shared_ptr<TreeNode>> enfants;  // Fort vers le bas

    explicit TreeNode(std::string v) : valeur(std::move(v)) {}

    void ajouter_enfant(std::shared_ptr<TreeNode> enfant) {
        enfant->parent = std::shared_ptr<TreeNode>(
            // Comment obtenir un shared_ptr vers this ? Voir encadré ci-dessous.
        );
        enfants.push_back(std::move(enfant));
    }

    std::string chemin() const {
        std::string resultat = valeur;
        // Remonter vers la racine via les weak_ptr
        auto p = parent.lock();
        while (p) {
            resultat = p->valeur + "/" + resultat;
            p = p->parent.lock();
        }
        return resultat;
    }
};

💡 enable_shared_from_this — Dans l'exemple ci-dessus, ajouter_enfant a besoin d'un shared_ptr vers this pour l'assigner au parent de l'enfant. C'est le rôle de std::enable_shared_from_this, une classe de base qui fournit la méthode shared_from_this(). Voici la version correcte :

struct TreeNode : public std::enable_shared_from_this<TreeNode> {
    std::string valeur;
    std::weak_ptr<TreeNode> parent;
    std::vector<std::shared_ptr<TreeNode>> enfants;

    explicit TreeNode(std::string v) : valeur(std::move(v)) {}

    void ajouter_enfant(std::shared_ptr<TreeNode> enfant) {
        enfant->parent = shared_from_this();  // ✅ shared_ptr vers this
        enfants.push_back(std::move(enfant));
    }
};

// Utilisation
auto racine = std::make_shared<TreeNode>("racine");  
auto fils = std::make_shared<TreeNode>("fils");  
racine->ajouter_enfant(fils);  

std::print("{}\n", fils->chemin());  // "racine/fils"

⚠️ shared_from_this() ne fonctionne que si l'objet est déjà géré par un shared_ptr. L'appeler sur un objet stack ou un objet géré par unique_ptr est un comportement indéfini.

Pattern 2 : cache avec expiration automatique

Un cache stocke des weak_ptr vers des ressources coûteuses. Si la ressource est encore utilisée ailleurs, le cache peut la retrouver instantanément. Si plus personne ne l'utilise, elle a été automatiquement libérée et le cache le détecte :

class TextureCache {
    std::unordered_map<std::string, std::weak_ptr<Texture>> cache_;

public:
    std::shared_ptr<Texture> obtenir(const std::string& chemin) {
        // Tenter de récupérer depuis le cache
        auto it = cache_.find(chemin);
        if (it != cache_.end()) {
            if (auto texture = it->second.lock()) {
                std::print("[cache] Hit pour '{}'\n", chemin);
                return texture;  // ✅ Encore en vie — réutilisation
            }
            // Le weak_ptr a expiré — nettoyer l'entrée
            cache_.erase(it);
        }

        // Charger depuis le disque
        std::print("[cache] Miss pour '{}' — chargement\n", chemin);
        auto texture = std::make_shared<Texture>(chemin);
        cache_[chemin] = texture;  // Stocker un weak_ptr
        return texture;
    }

    void purger_expires() {
        std::erase_if(cache_, [](const auto& pair) {
            return pair.second.expired();
        });
    }
};

Avec ce design, le cache n'empêche jamais la libération d'une texture inutilisée. La mémoire est récupérée dès que le dernier utilisateur relâche son shared_ptr. Le cache sert uniquement d'accélérateur pour les ressources encore vivantes.

Pattern 3 : observer pattern sûr

Un système d'événements où l'observable ne prolonge pas la durée de vie des observateurs :

class EventEmitter {
    std::vector<std::weak_ptr<EventListener>> listeners_;

public:
    void ajouter(std::shared_ptr<EventListener> listener) {
        listeners_.push_back(listener);  // Stocke un weak_ptr
    }

    void emettre(const Event& event) {
        // Parcours avec nettoyage des observateurs expirés
        auto it = listeners_.begin();
        while (it != listeners_.end()) {
            if (auto listener = it->lock()) {
                listener->on_event(event);  // ✅ Encore vivant
                ++it;
            } else {
                it = listeners_.erase(it);  // Nettoyage automatique
            }
        }
    }
};

Si un observateur est détruit (son shared_ptr n'est plus détenu par personne), l'émetteur le détecte au prochain emettre() et le retire silencieusement de la liste. Aucune fuite, aucun dangling pointer.

Pattern 4 : callbacks et captures de lambda

Un piège subtil survient quand une lambda capture un shared_ptr vers l'objet qui détient cette lambda :

class Worker : public std::enable_shared_from_this<Worker> {
    std::function<void()> callback_;

public:
    void demarrer() {
        // ❌ CYCLE : callback_ capture shared_ptr → Worker → callback_
        callback_ = [self = shared_from_this()]() {
            self->traiter();
        };
    }

    void demarrer_safe() {
        // ✅ PAS DE CYCLE : capture un weak_ptr
        callback_ = [weak_self = weak_from_this()]() {
            if (auto self = weak_self.lock()) {
                self->traiter();
            }
        };
    }

    void traiter() { std::print("Traitement\n"); }
};

weak_from_this() (disponible depuis C++17) est le pendant faible de shared_from_this(). Il retourne un weak_ptr vers l'objet courant, idéal pour les captures de lambda.


Détecter les cycles

Les cycles de références ne produisent ni erreur de compilation ni crash à l'exécution. Ils fuient silencieusement. Voici les outils pour les détecter.

Valgrind

Valgrind détecte la mémoire non libérée en fin de programme, y compris les fuites causées par des cycles :

$ valgrind --leak-check=full ./mon_programme

==12345== 96 bytes in 2 blocks are definitely lost in loss record 1 of 1
==12345==    at 0x...: operator new(unsigned long)
==12345==    by 0x...: std::make_shared<Personne>(...)

Le message « definitely lost » signale une fuite. Si les objets impliqués sont des shared_ptr, un cycle est la cause probable. Voir section 5.5.1 pour l'utilisation détaillée de Valgrind.

AddressSanitizer (LeakSanitizer)

Compilez avec -fsanitize=address pour activer la détection de fuites intégrée :

$ g++ -std=c++20 -fsanitize=address -g main.cpp -o main
$ ./main

==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 48 byte(s) in 1 object(s) allocated from:
    ...

Voir section 5.5.2.

Revue de code : les questions à se poser

La meilleure détection reste la prévention. Lors d'une revue de code, posez-vous ces questions :

  • Un objet A stocke-t-il un shared_ptr vers B, et B (directement ou indirectement) un shared_ptr vers A ?
  • Une lambda capture-t-elle un shared_ptr vers l'objet qui détient cette lambda ?
  • Une structure de données forme-t-elle un graphe avec des cycles potentiels ?

Si la réponse est oui à l'une de ces questions, au moins un lien doit devenir un weak_ptr.


Erreurs courantes avec weak_ptr

Oublier de vérifier le résultat de lock()

std::weak_ptr<Data> weak = /* ... */;

// ❌ Aucune vérification — crash si expiré
auto shared = weak.lock();  
shared->traiter();  // 💥 UB si shared est nul  

// ✅ Toujours vérifier
if (auto shared = weak.lock()) {
    shared->traiter();
}

Stocker le résultat de lock() trop longtemps

Le shared_ptr retourné par lock() prolonge la durée de vie de la ressource tant qu'il existe. Si vous le stockez dans un membre de classe, vous recréez potentiellement un lien fort — et donc un cycle :

class Observer {
    std::shared_ptr<Subject> subject_;  // ❌ Lien fort recréé

    void actualiser() {
        // Le weak_ptr original est contourné
        subject_ = weak_subject_.lock();  // Stocké en membre → lien fort permanent
    }
};

Le résultat de lock() devrait être une variable locale temporaire, utilisée et détruite dans le même scope :

class Observer {
    std::weak_ptr<Subject> subject_;  // ✅ Lien faible

    void actualiser() {
        if (auto s = subject_.lock()) {  // Temporaire local
            s->lire_etat();
        }   // s détruit ici → strong_count redescend
    }
};

Créer un weak_ptr à partir d'un pointeur brut

C'est impossible — et c'est logique. Un weak_ptr a besoin du bloc de contrôle pour fonctionner, et seul un shared_ptr possède un bloc de contrôle :

int* raw = new int(42);
// std::weak_ptr<int> w(raw);  // ❌ Ne compile pas

auto shared = std::make_shared<int>(42);  
std::weak_ptr<int> w = shared;  // ✅ Via un shared_ptr  

Résumé

Concept Détail
Cause des cycles Boucle de shared_ptr (longueur 1, 2, ou N) empêchant les compteurs d'atteindre 0
Conséquence Memory leak silencieux — pas de crash, pas de warning
Solution Remplacer au moins un lien fort par un weak_ptr
Règle de direction Liens forts vers le bas (parent → enfant), liens faibles vers le haut (enfant → parent)
lock() Promotion atomique weak_ptrshared_ptr. Retourne nullptr si expiré.
expired() Vérification rapide mais non thread-safe. Préférer lock().
enable_shared_from_this Permet d'obtenir un shared_ptr/weak_ptr vers this
Détection Valgrind, LeakSanitizer, revue de code
Patterns clés Arbres parent↔enfant, caches, observer, callbacks avec capture

⏭️ std::make_unique et std::make_shared