Smart pointer dans Rust: Box, Rc et Arc
1. Vue d’ensemble
Par défaut, chaque valeur en Rust n’a qu’un seul propriétaire, et Rust doit connaître la taille de la valeur à la compilation. De nombreuses situations réelles ne satisfont pas ces deux conditions : les types de données récursifs, les listes contenant plusieurs types différents, ou une donnée nécessitant plusieurs propriétaires. Cet article présente les trois smart pointer les plus courants : Box<T>, Rc<T> et Arc<T>. Après la lecture, vous saurez quel type choisir pour chaque situation, comment les combiner avec Weak, RefCell, Mutex, et les erreurs courantes.
2. Qu’est-ce qu’un smart pointer ?
Un smart pointer est une struct qui encapsule un pointeur et implémente deux traits importants :
Deref: vous permet d’utiliser le smart pointer comme une référence ordinaire (*ptr, appeler des méthodes directement).Drop: libère automatiquement la ressource quand le smart pointer sort du scope.
String et Vec<T> sont en fait aussi des smart pointer. Box, Rc et Arc se distinguent en se concentrant sur la façon dont ils possèdent les données sur le heap. Les trois se trouvent dans la bibliothèque standard. Vous n’avez besoin d’ajouter aucune dépendance à Cargo.toml.
3. Box<T> : un propriétaire, les données sur le heap
3.1. Allocation basique
fn main() {
let order_total = Box::new(250_000u64);
println!("Total: {}", order_total);
let doubled = *order_total * 2;
println!("Double: {}", doubled);
}
Box::new alloue la valeur 250_000 sur le heap. Sur la stack, il ne reste que le pointeur, qui a une taille fixe. println! fonctionne immédiatement car Box implémente Display quand T implémente Display. L’opérateur * récupère la valeur interne grâce à Deref. Quand order_total sort de main, la mémoire du heap est automatiquement libérée.
Total: 250000
Double: 500000
Envelopper un u64 dans un Box comme dans l’exemple ci-dessus ne présente aucun avantage. Ce code illustre simplement la syntaxe. Box s’avère vraiment utile dans les deux cas ci-dessous.
3.2. Types de données récursifs
Supposons que vous décriviez le processus de traitement d’une commande comme une séquence d’étapes :
#[derive(Debug)]
enum OrderStep {
Done,
Next(String, Box<OrderStep>),
}
use OrderStep::{Done, Next};
fn count(step: &OrderStep) -> usize {
match step {
Done => 0,
Next(_, rest) => 1 + count(rest),
}
}
fn main() {
let flow = Next(
"Passer commande".into(),
Box::new(Next(
"Paiement".into(),
Box::new(Next("Livraison".into(), Box::new(Done))),
)),
);
println!("Nombre d'étapes: {}", count(&flow));
}
Si vous écriviez Next(String, OrderStep) sans Box, le compilateur signalerait une erreur :
error[E0072]: recursive type `OrderStep` has infinite size
La raison est qu’un OrderStep contient un autre OrderStep, qui en contient un autre, et ainsi de suite, la taille devient donc infinie. Box<OrderStep> a toujours la taille d’un pointeur, donc la taille de l’enum devient finie. Dans la fonction count, rest a le type &Box<OrderStep>. Rust le convertit automatiquement en &OrderStep grâce à la deref coercion.
Nombre d'étapes: 3
3.3. Trait object
Box<dyn Trait> vous permet de stocker plusieurs types différents dans une même collection :
trait PaymentMethod {
fn pay(&self, amount: u64) -> String;
}
struct Cash;
struct Card {
last4: String,
}
impl PaymentMethod for Cash {
fn pay(&self, amount: u64) -> String {
format!("Espèces: {amount}")
}
}
impl PaymentMethod for Card {
fn pay(&self, amount: u64) -> String {
format!("Carte ****{}: {amount}", self.last4)
}
}
fn main() {
let methods: Vec<Box<dyn PaymentMethod>> = vec![
Box::new(Cash),
Box::new(Card { last4: "4242".into() }),
];
for m in &methods {
println!("{}", m.pay(100_000));
}
}
Cash et Card ont des tailles différentes, on ne peut donc pas les placer directement dans le même Vec. En les enveloppant dans Box<dyn PaymentMethod>, tous les éléments ont la même taille (un fat pointer : un pointeur de données plus un pointeur de vtable). L’appel de pay est résolu à l’exécution.
Espèces: 100000
Carte ****4242: 100000
Remarque : Si tous les éléments ont le même type, utilisez plutôt les génériques (
impl PaymentMethodou<T: PaymentMethod>) au lieu deBox<dyn …>. Les génériques utilisent le static dispatch, sans allocation sur le heap et sans appel à travers la vtable.
4. Rc<T> : plusieurs propriétaires dans un thread
4.1. Comptage de références
Supposons que plusieurs Order appartiennent à un même Customer. Vous ne voulez pas copier les informations du client dans chaque commande, et vous ne pouvez pas déterminer quelle commande « vivra » le plus longtemps pour en être le propriétaire. C’est le moment d’utiliser Rc (Reference Counted) :
use std::rc::Rc;
struct Customer {
name: String,
}
struct Order {
id: u32,
customer: Rc<Customer>,
}
fn main() {
let alice = Rc::new(Customer { name: "Alice".into() });
println!("Après création: {}", Rc::strong_count(&alice));
let o1 = Order { id: 1, customer: Rc::clone(&alice) };
{
let o2 = Order { id: 2, customer: Rc::clone(&alice) };
println!("Dans le bloc: {}", Rc::strong_count(&alice));
println!("Commande {} de {}", o2.id, o2.customer.name);
}
println!("Après le bloc: {}", Rc::strong_count(&alice));
println!("Commande {} de {}", o1.id, o1.customer.name);
}
Rc::clone ne copie pas Customer. Il augmente simplement le compteur strong count de 1. Quand o2 est supprimé à la fin du bloc, le compteur diminue de 1. Customer n’est libéré que quand le compteur atteint 0.
Après création: 1
Dans le bloc: 3
Commande 2 de Alice
Après le bloc: 2
Commande 1 de Alice
Par convention, écrivez Rc::clone(&alice) plutôt que alice.clone(). Cette syntaxe montre au lecteur qu’il s’agit d’une opération peu coûteuse pour augmenter le compteur, et non une copie profonde.
4.2. Modifier les données avec Rc<RefCell<T>>
Rc<T> vous donne seulement une référence immuable (&T). Si plusieurs propriétaires ont besoin de modifier les données, combinez-le avec RefCell :
use std::cell::RefCell;
use std::rc::Rc;
struct Cart {
items: Vec<String>,
}
fn main() {
let cart = Rc::new(RefCell::new(Cart { items: vec![] }));
let web = Rc::clone(&cart);
let mobile = Rc::clone(&cart);
web.borrow_mut().items.push("T-shirt".into());
mobile.borrow_mut().items.push("Chapeau".into());
println!("{:?}", cart.borrow().items);
}
RefCell déplace la vérification des règles d’emprunt de la compilation vers l’exécution. borrow_mut() retourne un guard. Ce guard expire à la fin de l’instruction, donc l’appel suivant est toujours valide.
["T-shirt", "Chapeau"]
Remarque : Si vous appelez
borrow_mut()tandis qu’unborrow()ouborrow_mut()autre est encore actif, le programme paniquera avecalready borrowed: BorrowMutError. Gardez le guard aussi peu de temps que possible. Utiliseztry_borrow_mut()si vous préférez gérer l’erreur au lieu de paniquer.
4.3. Cycles de références et Weak<T>
Si Customer contient une liste Rc<Order>, et que chaque Order contient un Rc<Customer>, les deux compteurs ne descendront jamais à 0. Le résultat est une fuite mémoire. Rust ne peut pas prévenir cette erreur à la compilation. La solution est d’utiliser Weak pour l’une des directions de référence :
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Customer {
name: String,
orders: RefCell<Vec<Rc<Order>>>,
}
struct Order {
id: u32,
customer: Weak<Customer>,
}
fn main() {
let bob = Rc::new(Customer {
name: "Bob".into(),
orders: RefCell::new(vec![]),
});
let order = Rc::new(Order { id: 7, customer: Rc::downgrade(&bob) });
bob.orders.borrow_mut().push(Rc::clone(&order));
if let Some(c) = order.customer.upgrade() {
println!("Commande {} appartient à {}", order.id, c.name);
}
println!("strong={}, weak={}", Rc::strong_count(&bob), Rc::weak_count(&bob));
drop(bob);
println!("Client encore présent? {}", order.customer.upgrade().is_some());
}
Rc::downgrade crée un Weak. Weak n’augmente que le weak count et ne garde pas les données actives. Quand vous avez besoin de l’utiliser, vous appelez upgrade() pour obtenir un Option<Rc<T>>. Le résultat est None si les données ont déjà été libérées. Après drop(bob), le strong count de Customer atteint 0 donc Customer est libéré, même si le Weak dans order persiste.
Commande 7 appartient à Bob
strong=1, weak=1
Client encore présent? false
Règle générale : la direction « parent → enfant » utilise Rc, la direction « enfant → parent » utilise Weak.
5. Arc<T> : plusieurs propriétaires entre plusieurs threads
5.1. Pourquoi Rc ne fonctionne pas avec les threads
Rc met à jour le compteur en utilisant une addition ordinaire, ce qui n’est pas sûr quand plusieurs threads manipulent les données. C’est pourquoi Rc n’implémente pas le trait Send. Si vous passez un Rc à thread::spawn, le compilateur refuse :
error[E0277]: `Rc<Customer>` cannot be sent between threads safely
Ceci est l’une des forces de Rust : les erreurs de data race sont bloquées à la compilation. Arc (Atomically Reference Counted) a une API identique à Rc, mais utilise des opérations atomiques pour mettre à jour le compteur.
5.2. Partager des données en lecture seule
use std::sync::Arc;
use std::thread;
struct ShopConfig {
currency: String,
vat_percent: u64,
}
fn main() {
let config = Arc::new(ShopConfig { currency: "EUR".into(), vat_percent: 20 });
let handles: Vec<_> = [100u64, 200]
.into_iter()
.map(|price| {
let config = Arc::clone(&config);
thread::spawn(move || {
let vat = price * config.vat_percent / 100;
println!("{} + TVA = {} {}", price, price + vat, config.currency);
})
})
.collect();
for h in handles {
h.join().unwrap();
}
}
Chaque thread reçoit sa propre copie Arc via move. Toutes les copies pointent vers le même ShopConfig. Les données sont lues uniquement, donc aucun verrou n’est nécessaire. L’ordre des deux lignes de sortie peut varier entre les exécutions.
100 + TVA = 120 EUR
200 + TVA = 240 EUR
5.3. Modifier les données avec Arc<Mutex<T>>
RefCell n’est pas sûr entre les threads. L’équivalent pour la programmation multi-thread est Mutex (ou RwLock) :
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let revenue = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for amount in [100u64, 250, 400] {
let revenue = Arc::clone(&revenue);
handles.push(thread::spawn(move || {
*revenue.lock().unwrap() += amount;
}));
}
for h in handles {
h.join().unwrap();
}
println!("Revenu: {}", *revenue.lock().unwrap());
}
lock() retourne un MutexGuard. Le verrou est automatiquement libéré quand le guard est supprimé, ici à la fin de l’instruction. unwrap() traite le cas où le mutex est empoisonné, c’est-à-dire qu’un autre thread a paniqué en tenant le verrou.
Revenu: 750
Remarque : Pour un simple compteur d’entiers,
Arc<AtomicU64>est plus rapide queArc<Mutex<u64>>. De plus, n’utilisez pasArcaux endroits où il n’y a qu’un seul thread. Les opérations atomiques coûtent plus cher qu’une addition ordinaire, bien que la différence ne soit pas énorme.
6. Écrire des tests pour le comportement du comptage de références
Rust dispose d’un framework de test intégré, aucune crate supplémentaire n’est nécessaire. Placez les tests suivants à la fin de src/lib.rs ou src/main.rs :
#[cfg(test)]
mod tests {
use std::rc::{Rc, Weak};
use std::sync::{Arc, Mutex};
use std::thread;
struct Customer {
name: String,
}
#[test]
fn rc_clone_increases_strong_count_and_drop_decreases_it() {
let alice = Rc::new(Customer { name: "Alice".into() });
let copy = Rc::clone(&alice);
assert_eq!(Rc::strong_count(&alice), 2);
assert!(Rc::ptr_eq(&alice, ©));
drop(copy);
assert_eq!(Rc::strong_count(&alice), 1);
assert_eq!(alice.name, "Alice");
}
#[test]
fn weak_upgrade_returns_none_after_owner_dropped() {
let bob = Rc::new(Customer { name: "Bob".into() });
let weak: Weak<Customer> = Rc::downgrade(&bob);
assert!(weak.upgrade().is_some());
drop(bob);
assert!(weak.upgrade().is_none());
}
#[test]
fn arc_mutex_sums_correctly_across_threads() {
let revenue = Arc::new(Mutex::new(0u64));
let handles: Vec<_> = (1..=100)
.map(|i| {
let revenue = Arc::clone(&revenue);
thread::spawn(move || *revenue.lock().unwrap() += i)
})
.collect();
handles.into_iter().for_each(|h| h.join().unwrap());
assert_eq!(*revenue.lock().unwrap(), 5050);
assert_eq!(Arc::strong_count(&revenue), 1);
}
}
Le premier test vérifie le compteur et utilise Rc::ptr_eq pour confirmer que les deux Rc pointent vers la même région mémoire, c’est-à-dire qu’il n’y a pas de copie profonde. Le deuxième test confirme que Weak ne garde pas les données actives. Le troisième test exécute 100 threads en ajoutant à un total, le résultat doit être exactement 5050. Après que tous les threads aient rejoint, il ne reste qu’une seule référence forte.
Exécutez les tests :
cargo test
running 3 tests
test tests::rc_clone_increases_strong_count_and_drop_decreases_it ... ok
test tests::weak_upgrade_returns_none_after_owner_dropped ... ok
test tests::arc_mutex_sums_correctly_across_threads ... ok
test result: ok. 3 passed; 0 failed
7. Quel type choisir ?
| Besoin | Choix |
|---|---|
| Un propriétaire, données sur heap (type récursif, trait object, données volumineuses à déplacer économiquement) | Box<T> |
| Plusieurs propriétaires, un thread, lecture seule | Rc<T> |
| Plusieurs propriétaires, un thread, modification | Rc<RefCell<T>> |
| Plusieurs propriétaires, plusieurs threads, lecture seule | Arc<T> |
| Plusieurs propriétaires, plusieurs threads, modification | Arc<Mutex<T>> / Arc<RwLock<T>> |
| Référence rétroactive, cache, éviter les cycles | Weak<T> |
Quelques erreurs courantes :
- Utiliser un smart pointer quand une emprunt suffirait. Si une fonction ne fait que lire les données, acceptez
&Customerplutôt queRc<Customer>. Ainsi la fonction est plus flexible et ne doit pas incrémenter ou décrémenter le compteur. - Abuser de
Rc<RefCell<T>>. Un code chargé de ce pattern est souvent le signe que vous transportez une mentalité OOP issue de Java ou C#. Envisagez de réorganiser l’ownership, ou utilisez des indices dans unVec(arena pattern). - Cycles de références. Tant
RcqueArcpeuvent causer des fuites mémoire quand deux objets se pointent l’un l’autre avec des références fortes. UtilisezWeakpour une direction. - Garder un
MutexGuardtrop longtemps. C’est particulièrement dangereux en gardant le guard à travers un.awaitdans du code async, ou en verrouillant plusieurs mutex dans un ordre différent entre threads, ce qui peut causer un deadlock.
8. Conclusion
Box<T> s’utilise quand vous devez placer une valeur sur le heap avec un seul propriétaire, typiquement pour les types récursifs et les trait objects. Rc<T> permet à plusieurs propriétaires de partager la propriété dans un seul thread, tandis que Arc<T> fait la même chose entre plusieurs threads grâce aux compteurs atomiques. Les smart pointer eux-mêmes n’autorisent que la lecture. Quand vous avez besoin de modifier les données, vous les combinez avec RefCell (un thread) ou Mutex/RwLock (plusieurs threads), et utilisez Weak pour éviter les cycles de références. Commencez par les emprunts ordinaires, et ne basculez vers les smart pointer que si les règles d’ownership par défaut ne suffisent vraiment pas.