Pointeurs intelligents#
Les références &T et &mut T (chapitre 6) sont les pointeurs les plus courants en Rust : elles empruntent une valeur sans en prendre la possession. Les pointeurs intelligents (smart pointers) vont plus loin : ce sont des structures qui se comportent comme des pointeurs tout en possédant la donnée vers laquelle elles pointent, et en fournissant des fonctionnalités supplémentaires — comptage de références, mutabilité intérieure, allocation sur le tas. Ils implémentent les traits Deref et Drop (chapitre 13), ce qui leur permet de s’intégrer naturellement dans le système de types de Rust.
Box<T> — allocation sur le tas#
Définition 106 (Box<T>)
Un Box<T> est le pointeur intelligent le plus simple : il alloue une valeur de type T sur le tas et en conserve un pointeur unique sur la pile. Box<T> possède la donnée au sens de la possession (chapitre 5) : lorsque le Box sort de la portée, la mémoire du tas est libérée automatiquement.
let b = Box::new(42);
println!("Valeur sur le tas : {}", b);
println!("Taille du Box sur la pile : {} octets", std::mem::size_of::<Box<i32>>());
Valeur sur le tas : 42
Taille du Box sur la pile : 8 octets
Un cas d’usage classique est la définition de types récursifs, dont la taille serait autrement infinie. L’indirection via Box donne au compilateur une taille connue (celle d’un pointeur).
Exemple 97 (Type récursif avec Box)
Voir le code ci-dessous.
#[derive(Debug)]
enum Liste {
Cons(i32, Box<Liste>),
Nil,
}
let liste = Liste::Cons(1, Box::new(Liste::Cons(2, Box::new(Liste::Cons(3, Box::new(Liste::Nil))))));
println!("{:?}", liste);
Cons(1, Cons(2, Cons(3, Nil)))
Box<T> est égalément utilisé pour créer des objets-traits (Box<dyn Trait>), comme vu au chapitre 13, permettant le dispatch dynamique.
Le trait Deref#
Définition 107 (Trait Deref et coercition de déréférencement)
Le trait Deref permet de surcharger l’opérateur de déréférencement *. Plus important encore, il active les coercitions de déréférencement (deref coercions) : le compilateur peut convertir automatiquement &T en &U lorsque T: Deref<Target = U>. Cela s’applique en cascade jusqu’à trouver le type attendu.
Proposition 26 (Règles de coercition de déréférencement)
Le compilateur applique automatiquement les coercitions suivantes :
&Tvers&UlorsqueT: Deref<Target = U>;&mut Tvers&mut UlorsqueT: DerefMut<Target = U>;&mut Tvers&UlorsqueT: Deref<Target = U>(une référence mutable peut être rétrogradée en référence partagée).
Box<T> implémente Deref<Target = T>, ce qui signifie qu’un &Box<T> est automatiquement converti en &T.
Exemple 98 (Coercition de déréférencement avec Box)
Voir le code ci-dessous.
fn afficher_longueur(s: &str) {
println!("Longueur : {}", s.len());
}
let boite = Box::new(String::from("coercition"));
// Box<String> -> String -> str : double coercition automatique
afficher_longueur(&boite);
Longueur : 10
Remarque 85
Les coercitions de déréférencement expliquent pourquoi on peut passer un &String là où un &str est attendu, ou un &Vec<T> là où un &[T] est requis. Ce mécanisme évite de joncher le code de conversions explicites et constitue l’un des fondements de l’ergonomie de Rust.
Le trait Drop#
Le trait Drop a été introduit au chapitre 5 dans le contexte de la possession. Il est au coeur du fonctionnement des pointeurs intelligents : chaque pointeur intelligent implémente Drop pour libérer les ressources qu’il détient (mémoire du tas, descripteurs de fichiers, verrous, etc.).
Proposition 27 (Ordre de libération et drop prématuré)
Le compilateur appelle automatiquement drop() lorsqu’une valeur sort de la portée. Les variables sont libérées dans l”ordre inverse de leur déclaration. Il est interdit d’appeler drop() directement ; pour forcer une libération anticipée, on utilise std::mem::drop, qui prend la possession de la valeur.
struct Tampon {
nom: String,
}
impl Drop for Tampon {
fn drop(&mut self) {
println!("[drop] Libération du tampon '{}'", self.nom);
}
}
let a = Tampon { nom: String::from("premier") };
let b = Tampon { nom: String::from("second") };
println!("Tampons créés");
std::mem::drop(a); // libération anticipée
println!("Fin du bloc");
// b est libéré automatiquement ici
Tampons créés
[drop] Libération du tampon 'premier'
Fin du bloc
Rc<T> — comptage de références#
Définition 108 (Rc<T>)
Rc<T> (Reference Counted) est un pointeur intelligent à comptage de références qui permet a plusieurs propriétaires de partager la même valeur allouée sur le tas. Chaque appel à Rc::clone incrémente le compteur ; lorsque le dernier Rc est détruit, la valeur est libérée. Rc<T> est réservé à un usage mono-thread.
use std::rc::Rc;
let a = Rc::new(vec![1, 2, 3]);
println!("Compteur après creation : {}", Rc::strong_count(&a));
let b = Rc::clone(&a);
println!("Compteur après clone : {}", Rc::strong_count(&a));
{
let c = Rc::clone(&a);
println!("Compteur avec c : {}", Rc::strong_count(&a));
} // c est détruit ici
println!("Compteur après destruction de c : {}", Rc::strong_count(&a));
println!("Données partagées : {:?}", b);
Compteur après creation : 1
Compteur après clone : 2
Compteur avec c : 3
Compteur après destruction de c : 2
Données partagées : [1, 2, 3]
[drop] Libération du tampon 'second'
Remarque 86
Rc::clone n’effectue pas de copie profonde des données : il incrémente simplement le compteur de références. C’est une opération à coût constant \(O(1)\). La convention en Rust est d’utiliser Rc::clone(&x) plutôt que x.clone() pour distinguer visuellement un clonage de compteur d’un clonage profond.
Proposition 28 (Rc<T> n’est ni Send ni Sync)
Rc<T> n’implémente ni Send ni Sync. Son compteur de références n’est pas atomique, ce qui le rend inadapté à un usage multi-thread. Le compilateur rejettera toute tentative de partage d’un Rc entre threads.
RefCell<T> — mutabilité intérieure#
Définition 109 (Mutabilité intérieure et RefCell<T>)
La mutabilité intérieure (interior mutability) est un patron de conception qui permet de modifier une donnée même lorsqu’on ne dispose que d’une référence partagée &T. RefCell<T> en est l’implementation principale : il déplace la vérification des règles d’emprunt de la compilation vers l”exécution. Les methodes borrow() et borrow_mut() retournent respectivement des gardes Ref<T> et RefMut<T>.
Proposition 29 (Règles d’emprunt à l’exécution)
RefCell<T> applique à l’exécution les mêmes règles que le compilateur applique statiquement (chapitre 6) :
plusieurs emprunts partagés (
borrow()) simultanés sont autorisés ;un seul emprunt mutable (
borrow_mut()) est autorisé à la fois ;un emprunt mutable est incompatible avec tout emprunt partagé actif.
La violation de ces règles provoque un panic à l’exécution, et non une erreur de compilation.
use std::cell::RefCell;
let donnees = RefCell::new(vec![1, 2, 3]);
// Emprunt partagé
{
let r = donnees.borrow();
println!("Lecture : {:?}", *r);
}
// Emprunt mutable
{
let mut w = donnees.borrow_mut();
w.push(4);
}
println!("Après modification : {:?}", donnees.borrow());
Lecture : [1, 2, 3]
Après modification : [1, 2, 3, 4]
La violation des règles d’emprunt provoque un panic :
use std::cell::RefCell;
let c = RefCell::new(5);
let r1 = c.borrow();
let r2 = c.borrow_mut(); // panic : emprunt mutable alors qu'un emprunt partagé existe
let r1 = c.borrow();
let r1 = c.borrow();
^^
The variable `r1` contains a reference with a non-static lifetime so
can't be persisted. You can prevent this error by making sure that the
variable goes out of scope - i.e. wrapping the code in {}.
let r2 = c.borrow_mut(); // panic : emprunt mutable alors qu'un emprunt partagé existe
let r2 = c.borrow_mut(); // panic : emprunt mutable alors qu'un emprunt partagé existe
^^
The variable `r2` contains a reference with a non-static lifetime so
can't be persisted. You can prevent this error by making sure that the
variable goes out of scope - i.e. wrapping the code in {}.
Rc<RefCell<T>> — mutabilité partagée#
La combinaison Rc<RefCell<T>> est un patron courant qui permet d’avoir plusieurs propriétaires d’une donnée mutable. Rc fournit la propriété partagée, et RefCell la mutabilité intérieure.
Exemple 99 (Graphe avec propriété partagée et mutable)
Voir le code ci-dessous.
use std::rc::Rc;
use std::cell::RefCell;
#[derive(Debug)]
struct Noeud {
valeur: i32,
voisins: Vec<Rc<RefCell<Noeud>>>,
}
let a = Rc::new(RefCell::new(Noeud { valeur: 1, voisins: vec![] }));
let b = Rc::new(RefCell::new(Noeud { valeur: 2, voisins: vec![] }));
// a et b se connaissent mutuellement
a.borrow_mut().voisins.push(Rc::clone(&b));
b.borrow_mut().voisins.push(Rc::clone(&a));
println!("a.valeur = {}", a.borrow().valeur);
println!("Voisins de a : {:?}", a.borrow().voisins.len());
a.valeur = 1
Voisins de a : 1
Remarque 87
Rc<RefCell<T>> reporte les vérifications d’emprunt à l’exécution, ce qui sacrifie une partie des garanties statiques de Rust. Ce patron est utile pour les structures de données complexes (graphes, arbres avec références parentes), mais il faut l’employer avec discernement. Privilégiez les références classiques et la possession simple chaque fois que c’est possible.
Weak<T> — références faibles#
Définition 110 (Weak<T>)
Weak<T> est une référence faible obtenue à partir d’un Rc<T> via Rc::downgrade. Contrairement à Rc::clone, elle n’incrémente pas le compteur fort (strong count) mais un compteur faible (weak count). Une référence faible ne garantit pas que la valeur existe encore : la methode upgrade() retourne un Option<Rc<T>>.
Les références faibles sont essentielles pour éviter les cycles de références, qui provoqueraient des fuites mémoire avec Rc seul.
Exemple 100 (Eviter un cycle avec Weak)
Voir le code ci-dessous.
use std::rc::{Rc, Weak};
use std::cell::RefCell;
#[derive(Debug)]
struct Noeud {
valeur: i32,
parent: RefCell<Weak<Noeud>>,
enfants: RefCell<Vec<Rc<Noeud>>>,
}
let racine = Rc::new(Noeud {
valeur: 0,
parent: RefCell::new(Weak::new()),
enfants: RefCell::new(vec![]),
});
let enfant = Rc::new(Noeud {
valeur: 1,
parent: RefCell::new(Rc::downgrade(&racine)),
enfants: RefCell::new(vec![]),
});
racine.enfants.borrow_mut().push(Rc::clone(&enfant));
// upgrade() retourne Some si la valeur existe encore
match enfant.parent.borrow().upgrade() {
Some(p) => println!("Parent de l'enfant : valeur = {}", p.valeur),
None => println!("Le parent a été libéré"),
}
println!("strong_count de racine : {}", Rc::strong_count(&racine));
println!("weak_count de racine : {}", Rc::weak_count(&racine));
The type of the variable a was redefined, so was lost.
The type of the variable b was redefined, so was lost.
Parent de l'enfant : valeur = 0
strong_count de racine : 1
weak_count de racine : 1
Remarque 88
Sans Weak, un arbre ou chaque noeud possède un Rc vers son parent créerait un cycle : parent -> enfant -> parent. Le compteur de références ne tomberait jamais à zero, et la mémoire ne serait jamais libérée. En utilisant Weak pour la référence vers le parent, le cycle est brisé.
Arc<T> — comptage de références atomique#
Définition 111 (Arc<T>)
Arc<T> (Atomically Reference Counted) est la version thread-safe de Rc<T>. Son compteur de références utilise des opérations atomiques, ce qui lui permet d’implémenter Send et Sync (lorsque T: Send + Sync). Le surcoût par rapport à Rc est celui des opérations atomiques sur le compteur.
use std::sync::Arc;
let donnees = Arc::new(vec![1, 2, 3, 4, 5]);
// Simuler un partage (normalement entre threads)
let copie1 = Arc::clone(&donnees);
let copie2 = Arc::clone(&donnees);
println!("strong_count : {}", Arc::strong_count(&donnees));
println!("copie1 : {:?}", copie1);
println!("copie2 : {:?}", copie2);
strong_count : 3
copie1 : [1, 2, 3, 4, 5]
copie2 : [1, 2, 3, 4, 5]
Pour obtenir de la mutabilité partagée entre threads, on combine Arc avec un Mutex (qui sera approfondi au chapitre consacré à la concurrence).
Exemple 101 (Arc<Mutex<T>> pour mutabilité partagée entre threads)
Voir le code ci-dessous.
use std::sync::{Arc, Mutex};
let compteur = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let c = Arc::clone(&compteur);
let handle = std::thread::spawn(move || {
let mut val = c.lock().unwrap();
*val += 1;
});
handles.push(handle);
}
for h in handles {
h.join().unwrap();
}
println!("Compteur final : {}", *compteur.lock().unwrap());
Compteur final : 5
Remarque 89
Arc et Rc ont la même interface. La règle pratique est simple : utiliser Rc en contexte mono-thread (moins coûteux), et Arc des qu’un partage entre threads est nécessaire. Le compilateur rejettera toute tentative d’envoyer un Rc à un autre thread, guidant naturellement vers Arc.
Cow<T> — clone-on-write#
Définition 112 (Cow<T>)
Cow<'a, T> (Clone on Write) est une énumération qui encapsule soit une référence empruntée (Borrowed(&'a T)), soit une valeur possédée (Owned(T)). La méthode to_mut() retourne une référence mutable : si la donnée est empruntée, elle est clonée à ce moment-la ; si elle est déjà possédée, aucune allocation n’a lieu.
Cow est un outil d”optimisation : il permet d’éviter des clonages inutiles lorsque la mutation n’est pas toujours nécessaire.
Exemple 102 (Cow pour éviter un clonage inutile)
Voir le code ci-dessous.
use std::borrow::Cow;
fn normaliser(s: &str) -> Cow<str> {
if s.contains(' ') {
// Mutation nécessaire : on clone et on modifie
Cow::Owned(s.replace(' ', "_"))
} else {
// Pas de mutation : on emprunte sans allouer
Cow::Borrowed(s)
}
}
let a = normaliser("deja_bon");
let b = normaliser("a modifier");
println!("a = {} (emprunt : {})", a, matches!(a, Cow::Borrowed(_)));
println!("b = {} (emprunt : {})", b, matches!(b, Cow::Borrowed(_)));
a = deja_bon (emprunt : true)
b = a_modifier (emprunt : false)
Remarque 90
Cow est particulièrement utile dans les fonctions qui ne modifient leur entrée que dans certains cas. Sans Cow, on serait contraint de toujours retourner un String (et donc toujours allouer), ou de complexifier l’interface avec des variantes de retour. Cow unifie les deux chemins derrière un type unique.
Résumé#
Les pointeurs intelligents de Rust offrent des stratégies de gestion mémoire adaptées à différents besoins, tout en préservant les garanties du système de possession :
Pointeur |
Propriété |
Thread-safe |
Mutabilité |
Cas d’usage principal |
|---|---|---|---|---|
|
Unique |
Oui |
Via |
Allocation sur le tas, types récursifs |
|
Partagée |
Non |
Non |
Propriété partagée mono-thread |
|
Partagée |
Oui |
Non |
Propriété partagée multi-thread |
|
Unique |
Non |
Intérieure |
Mutation derrière |
|
Aucune |
Non |
Non |
Briser les cycles de |
|
Conditionnelle |
Oui |
Via |
Clonage à la demande |
Le choix du pointeur intelligent dépend du contexte : Box pour l’allocation simple, Rc/Arc pour la propriété partagée, RefCell pour la mutabilité intérieure, Weak pour éviter les cycles, et Cow pour les optimisations d’emprunt. En pratique, la majorité du code Rust s’appuie sur les références classiques et la possession directe ; les pointeurs intelligents interviennent lorsque ces mécanismes atteignent leurs limites.