Retour au cours

backend / rust

Smart pointers : Box, Rc, RefCell

Leçon 131 exercice

Explication

Ce que vous allez apprendre

  • Utiliser Box<T> pour des types récursifs ou de taille inconnue à la compilation
  • Partager la propriété d'une donnée entre plusieurs parties du code avec Rc<T>
  • Comprendre la mutabilité intérieure de RefCell<T>, vérifiée à l'exécution
  • Combiner Rc<RefCell<T>> pour un état partagé et mutable mono-thread
  • Savoir pourquoi Rc seul ne suffit jamais à muter une donnée partagée

Dans quel contexte ?

Un développeur veut modéliser un arbre binaire en Rust, où chaque nœud contient potentiellement deux sous-arbres. Il découvre rapidement que enum Arbre { Noeud(Arbre, Arbre) } ne compile jamais : le compilateur ne peut pas connaître la taille d'un Arbre à la compilation, puisqu'elle dépendrait d'elle-même de façon infinie. Box<Arbre> résout ce problème en ajoutant une indirection de taille fixe (un pointeur) entre chaque niveau.

D'abord, Box<T> est le plus simple des smart pointers

Box<T> alloue une valeur sur le heap plutôt que sur la pile, avec un ownership unique classique — exactement comme n'importe quelle autre valeur Rust, sauf que sa taille en mémoire (celle d'un pointeur) est toujours fixe, quelle que soit la taille réelle de la donnée qu'il contient. C'est ce qui rend possible les types récursifs comme un arbre ou une liste chaînée.

Une fois ce premier smart pointer maîtrisé, un besoin différent apparaît : le partage

Rc<T> (Reference Counted) permet à plusieurs parties du code de posséder simultanément la même donnée, en comptant combien de références actives existent. La donnée n'est libérée que lorsque le compteur retombe à zéro, c'est-à-dire quand plus aucune référence ne l'utilise.

Smart pointerOwnershipMutabilitéThread-safe
Box<T>UniqueStandard (via &mut)Oui (si T l'est)
Rc<T>Partagé (compteur)Non par défautNon, mono-thread uniquement
RefCell<T>Unique ou partagé (combiné)Vérifiée à l'exécutionNon, mono-thread uniquement

Prérequis

Il faut avoir bien assimilé l'ownership et le borrowing (leçons précédentes) : ces smart pointers existent précisément pour contourner, de façon contrôlée, certaines limitations strictes du borrow checker classique.

Il reste un problème non résolu par Rc seul : la mutation

Rc<T> donne un accès partagé, mais en lecture seule — le borrow checker interdit toujours plusieurs &mut simultanées, même à travers un Rc. RefCell<T> déplace cette vérification de la compilation vers l'exécution : .borrow_mut() panique immédiatement si un autre emprunt est déjà actif, plutôt que d'empêcher la compilation.

Piège dangereux

Appeler .borrow_mut() deux fois simultanément sur le même RefCell (par exemple en gardant le résultat du premier emprunt actif trop longtemps) provoque un panic à l'exécution, précisément le genre d'erreur que le borrow checker évite habituellement à la compilation. RefCell déplace le risque, il ne l'élimine pas : à utiliser uniquement quand le borrow checker statique est réellement trop restrictif pour le besoin.

Enfin, la combinaison la plus fréquente en pratique mono-thread

Rc<RefCell<T>> combine partage (Rc) et mutabilité intérieure (RefCell) : plusieurs parties du code peuvent posséder et modifier la même donnée, dans un contexte mono-thread. C'est un pattern extrêmement courant pour modéliser des structures de données comme des graphes ou des arbres avec des références croisées.

Bonne pratique

N'utilise Rc<RefCell<T>> qu'après avoir vérifié qu'une conception plus simple (ownership unique, références classiques) est réellement impossible pour ton cas : ce pattern déplace une partie de la sécurité du compilateur vers l'exécution, et doit rester une solution ciblée, pas un réflexe systématique.

Maintenant que tu maîtrises le partage mono-thread, la prochaine leçon passe au multi-thread avec Arc, l'équivalent thread-safe de Rc, et les mécanismes de synchronisation associés.

Commandes & code

Smart pointers : Box, Rc, RefCell

Au-delà des références simples, ces types gèrent des cas de propriété plus complexes.

rust
use std::cell::RefCell;
use std::rc::Rc;

// Box<T> : allocation heap avec ownership unique, utile pour taille inconnue a la compilation
#[derive(Debug)]
enum Arbre {
    Feuille(i32),
    Noeud(Box<Arbre>, Box<Arbre>), // recursion : impossible sans indirection (taille infinie sinon)
}

fn somme_arbre(a: &Arbre) -> i32 {
    match a {
        Arbre::Feuille(v) => *v,
        Arbre::Noeud(gauche, droite) => somme_arbre(gauche) + somme_arbre(droite),
    }
}

// Rc<T> : Reference Counted, ownership PARTAGE (mono-thread uniquement)
struct Graphe {
    noeuds: Vec<Rc<Noeud>>,
}
struct Noeud {
    nom: String,
}

fn main() {
    let arbre = Arbre::Noeud(
        Box::new(Arbre::Noeud(Box::new(Arbre::Feuille(1)), Box::new(Arbre::Feuille(2)))),
        Box::new(Arbre::Feuille(3)),
    );
    println!("{}", somme_arbre(&arbre));

    // Rc : plusieurs proprietaires de la meme donnee, compteur de references
    let a = Rc::new(Noeud { nom: String::from("A") });
    println!("compteur apres creation: {}", Rc::strong_count(&a)); // 1

    let b = Rc::clone(&a); // n'incremente que le compteur, ne copie pas les donnees
    println!("compteur apres clone: {}", Rc::strong_count(&a)); // 2

    let graphe = Graphe { noeuds: vec![Rc::clone(&a), b] };
    println!("compteur avec graphe: {}", Rc::strong_count(&a)); // 3
    println!("{}", graphe.noeuds[0].nom);

    // RefCell<T> : mutabilite interieure, verifiee a l'EXECUTION plutot qu'a la compilation
    // Utile quand on doit muter une donnee partagee via Rc (Rc seul ne permet pas &mut)
    let compteur_partage = Rc::new(RefCell::new(0));

    let c1 = Rc::clone(&compteur_partage);
    let c2 = Rc::clone(&compteur_partage);

    *c1.borrow_mut() += 1; // borrow_mut() panique si un autre emprunt est deja actif
    *c2.borrow_mut() += 10;

    println!("valeur finale: {}", compteur_partage.borrow());

    // Combinaison Rc<RefCell<T>> : pattern tres courant pour un etat partage mutable mono-thread
    #[derive(Debug)]
    struct CompteurPartage {
        valeur: RefCell<i32>,
    }
    let cs = Rc::new(CompteurPartage { valeur: RefCell::new(0) });
    let cs2 = Rc::clone(&cs);
    *cs2.valeur.borrow_mut() += 5;
    println!("{:?}", cs.valeur.borrow());
}

Résumé

  • Box<T> : ownership unique sur le heap — indispensable pour les types récursifs ou de taille inconnue.
  • Rc<T> : ownership partagé par comptage de références, mono-thread uniquement (utiliser Arc<T> en multi-thread).
  • RefCell<T> déplace la vérification d'emprunt de la compilation à l'exécution (panique si violée).
  • Rc<RefCell<T>> est le pattern classique pour un état partagé et mutable dans un contexte mono-thread.

Exercices pratiques

1 disponible
1

Mission : un cache partage qui panique en production

Objectif : Diagnostiquer un panic de RefCell double-borrow et une struct recursive qui ne compile pas, puis les corriger.

Contexte

Un cache mono-thread partage entre plusieurs composants d'une appli utilise Rc<RefCell<HashMap<String, i32>>>. Un dev a ecrit une fonction qui, en cas de cle absente, lit la valeur par defaut puis l'insere dans le meme appel :

rust
fn lire_ou_defaut(cache: &Rc<RefCell<HashMap<String, i32>>>, cle: &str) -> i32 {
    let emprunt = cache.borrow_mut();
    if let Some(v) = emprunt.get(cle) {
        *v
    } else {
        drop(emprunt);
        let mut emprunt2 = cache.borrow_mut();
        emprunt2.insert(cle.to_string(), 0);
        0
    }
}

Ce code fonctionne, mais un collegue a copie-colle un pattern similaire ailleurs sans le drop(emprunt), et l'application panique en production avec 'already borrowed: BorrowMutError'. Separement, l'equipe veut aussi modeliser une liste chainee simple et n'arrive pas a la faire compiler.

Résoudre l’exercice →