Aller au contenu

Weak et Cow en Rust

Sur la page Les pointeurs intelligents, vous avez découvert Box, Rc et RefCell. Cette page ajoute deux outils de plus, qui règlent deux problèmes bien réels. Weak<T> vous permet de pointer vers une valeur sans la garder en vie. Cow<T> permet à une fonction de renvoyer des données empruntées quand c’est possible, et de ne faire une copie que quand c’est nécessaire.

Voyez Rc comme une clé de maison : tant que quelqu’un a une clé, la maison reste. Weak ressemble plutôt à l’adresse notée sur un bout de papier. Elle vous permet de trouver la maison, mais si tout le monde rend sa clé et que la maison disparaît, l’adresse ne mène plus nulle part. Personne ne se retrouve à payer pour une maison qu’il a oubliée.

Le problème : les cycles de Rc ne sont jamais nettoyés

Section intitulée « Le problème : les cycles de Rc ne sont jamais nettoyés »

Une valeur Rc est libérée quand son compteur fort (strong count) tombe à 0. Ça marche très bien, jusqu’au jour où deux valeurs se tiennent l’une l’autre avec un Rc. Chacune garde alors l’autre en vie, pour toujours.

Pour le voir, le type Person ci-dessous implémente Drop : il affiche un message quand il est nettoyé. Regardez bien quels messages apparaissent :

use std::cell::RefCell;
use std::rc::Rc;
struct Person {
name: String,
friend: RefCell<Option<Rc<Person>>>,
}
impl Drop for Person {
fn drop(&mut self) {
println!("libération de {}", self.name);
}
}
fn main() {
let alice = Rc::new(Person { name: String::from("alice"), friend: RefCell::new(None) });
let bob = Rc::new(Person { name: String::from("bob"), friend: RefCell::new(None) });
// alice pointe vers bob, et bob pointe en retour vers alice
*alice.friend.borrow_mut() = Some(Rc::clone(&bob));
*bob.friend.borrow_mut() = Some(Rc::clone(&alice));
println!("compteur fort d'alice : {}", Rc::strong_count(&alice));
println!("compteur fort de bob : {}", Rc::strong_count(&bob));
println!("fin de main");
}

Sortie :

compteur fort d'alice : 2
compteur fort de bob : 2
fin de main
▶ Essayez par vous-même

Remarquez ce qui manque : il n’y a ni libération de alice, ni libération de bob. Quand main se termine, les variables alice et bob disparaissent, donc chaque compteur passe de 2 à 1. Mais il n’atteint jamais 0, car chaque personne est encore tenue par le champ friend de l’autre. Cette mémoire a fui (on parle de fuite de mémoire, memory leak).

Ne vous en voulez pas si ça vous surprend. Le compilateur ne peut pas le détecter, et ce n’est pas un plantage. C’est simplement de la mémoire qui ne revient jamais. Dans un programme qui tourne longtemps et crée des cycles sans arrêt, ça finit par s’accumuler.

La solution est de rendre l’un des liens, ou les deux, faibles (weak). On obtient un Weak à partir d’un Rc avec Rc::downgrade. Un lien faible ne compte pas comme propriétaire, il n’empêche donc pas la valeur d’être libérée.

Comme la valeur a peut-être déjà disparu, vous ne pouvez pas utiliser un Weak directement. Vous appelez .upgrade(), qui vous donne une Option<Rc<T>> : Some si la valeur est encore en vie, None sinon.

use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Person {
name: String,
friend: RefCell<Weak<Person>>,
}
impl Drop for Person {
fn drop(&mut self) {
println!("libération de {}", self.name);
}
}
fn main() {
let alice = Rc::new(Person { name: String::from("alice"), friend: RefCell::new(Weak::new()) });
let bob = Rc::new(Person { name: String::from("bob"), friend: RefCell::new(Weak::new()) });
*alice.friend.borrow_mut() = Rc::downgrade(&bob);
*bob.friend.borrow_mut() = Rc::downgrade(&alice);
if let Some(friend) = alice.friend.borrow().upgrade() {
println!("l'ami d'alice est {}", friend.name);
}
println!("alice : fort = {}, faible = {}", Rc::strong_count(&alice), Rc::weak_count(&alice));
println!("fin de main");
}

Sortie :

l'ami d'alice est bob
alice : fort = 1, faible = 1
fin de main
libération de bob
libération de alice
▶ Essayez par vous-même

Cette fois, les deux lignes libération apparaissent. Alice a un propriétaire fort (la variable alice) et un lien faible (celui de Bob). Quand main se termine, le compteur fort atteint 0, et Rust la nettoie. Bob part en premier, car les variables sont libérées dans l’ordre inverse de leur création.

Quelques points à retenir :

  • Weak::new() crée un pointeur faible vide, qui ne pointe vers rien. upgrade() sur lui renvoie toujours None. C’est une valeur de départ bien pratique.
  • Rc::strong_count compte les propriétaires. Rc::weak_count compte les liens faibles. Seul le compteur fort décide du moment où la valeur est libérée.
  • Le Rc que vous rend upgrade() est un vrai propriétaire, temporaire. Quand il sort de la portée, le compteur fort redescend.

Une arborescence de dossiers : les enfants possèdent, les parents sont faibles

Section intitulée « Une arborescence de dossiers : les enfants possèdent, les parents sont faibles »

Les arbres sont le terrain classique de Weak. Un dossier possède ses sous-dossiers, donc children contient des Rc. Un sous-dossier veut aussi connaître son parent, mais il ne doit pas le posséder. Si les deux sens utilisaient Rc, vous auriez de nouveau un cycle. parent est donc un Weak :

use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Folder {
name: String,
parent: RefCell<Weak<Folder>>,
children: RefCell<Vec<Rc<Folder>>>,
}
fn new_folder(name: &str) -> Rc<Folder> {
Rc::new(Folder {
name: name.to_string(),
parent: RefCell::new(Weak::new()),
children: RefCell::new(Vec::new()),
})
}
fn add_child(parent: &Rc<Folder>, child: Rc<Folder>) {
*child.parent.borrow_mut() = Rc::downgrade(parent);
parent.children.borrow_mut().push(child);
}
fn full_path(folder: &Folder) -> String {
match folder.parent.borrow().upgrade() {
Some(parent) => format!("{}/{}", full_path(&parent), folder.name),
None => folder.name.clone(),
}
}
fn main() {
let home = new_folder("home");
let photos = new_folder("photos");
let trips = new_folder("voyages");
add_child(&home, Rc::clone(&photos));
add_child(&photos, Rc::clone(&trips));
match photos.parent.borrow().upgrade() {
Some(parent) => println!("photos se trouve dans {}", parent.name),
None => println!("photos n'a pas de parent"),
}
println!("chemin complet : {}", full_path(&trips));
println!("home : fort = {}, faible = {}", Rc::strong_count(&home), Rc::weak_count(&home));
println!("photos : fort = {}, faible = {}", Rc::strong_count(&photos), Rc::weak_count(&photos));
}

Sortie :

photos se trouve dans home
chemin complet : home/photos/voyages
home : fort = 1, faible = 1
photos : fort = 2, faible = 1
▶ Essayez par vous-même

Lisez les compteurs lentement. photos a deux propriétaires forts : la variable photos et la liste children à l’intérieur de home. Il a un lien faible : le champ parent de trips (le dossier voyages). home n’a qu’un seul propriétaire fort, sa propre variable, car personne ne possède son parent. C’est exactement la forme que vous voulez : l’ownership (la possession) descend l’arbre, et les liens qui remontent sont faibles.

RefCell est là parce que vous modifiez parent et ajoutez des éléments à children alors que les dossiers sont déjà dans un Rc, et qu’un Rc seul ne donne qu’un accès partagé, en lecture seule.

Weak est aussi parfait pour les listes du genre « garder un œil sur ces choses, sans s’y accrocher » : des écouteurs (listeners), des observateurs, ou un cache d’objets que quelqu’un d’autre possède. Ici, le score d’un jeu envoie des mises à jour à ceux qui écoutent encore. Quand un écouteur est libéré, son lien faible cesse simplement de fonctionner :

use std::rc::{Rc, Weak};
struct Listener {
name: String,
}
fn notify(listeners: &[Weak<Listener>], event: &str) {
for weak in listeners {
match weak.upgrade() {
Some(listener) => println!("{} a reçu : {event}", listener.name),
None => println!("(un écouteur a disparu, on passe)"),
}
}
}
fn main() {
let screen = Rc::new(Listener { name: String::from("écran") });
let logger = Rc::new(Listener { name: String::from("journal") });
let mut listeners: Vec<Weak<Listener>> = Vec::new();
listeners.push(Rc::downgrade(&screen));
listeners.push(Rc::downgrade(&logger));
notify(&listeners, "le score est de 10");
drop(logger); // le journal est fermé par son propriétaire
notify(&listeners, "le score est de 20");
// faire le ménage dans les liens qui ne pointent plus vers rien
listeners.retain(|weak| weak.upgrade().is_some());
println!("écouteurs restants : {}", listeners.len());
}

Sortie :

écran a reçu : le score est de 10
journal a reçu : le score est de 10
écran a reçu : le score est de 20
(un écouteur a disparu, on passe)
écouteurs restants : 1
▶ Essayez par vous-même

La liste n’a jamais empêché le journal d’être libéré. Si elle avait contenu des Rc, le journal aurait continué à vivre dans la liste, en recevant des messages dont personne ne voulait. Avec Weak, c’est le propriétaire qui décide quand quelque chose disparaît, et tous les autres vérifient simplement avec upgrade().

Cow : emprunter quand on peut, copier quand il le faut

Section intitulée « Cow : emprunter quand on peut, copier quand il le faut »

Passons à un autre problème. Imaginons que vous écriviez une fonction qui nettoie des noms d’utilisateur. La plupart des noms sont déjà corrects. Quelques-uns ont des espaces en trop ou des majuscules. Si votre fonction renvoie toujours une nouvelle String, vous copiez chaque nom, même ceux que vous n’avez pas modifiés.

Cow (clone on write, « cloner à l’écriture », dans std::borrow::Cow) règle ce problème. Un Cow<'_, str> est un enum à deux variantes :

  • Cow::Borrowed(&str) : « voici le texte d’origine, intact ». Aucune allocation.
  • Cow::Owned(String) : « j’ai dû le modifier, alors voici une nouvelle String ».
use std::borrow::Cow;
fn normalize(name: &str) -> Cow<'_, str> {
let trimmed = name.trim();
let has_upper = name.chars().any(|c| c.is_uppercase());
if trimmed.len() == name.len() && !has_upper {
Cow::Borrowed(name) // déjà propre : on rend l'original
} else {
Cow::Owned(trimmed.to_lowercase()) // à corriger : on construit une nouvelle String
}
}
fn main() {
for input in ["alice", " Bob ", "CAROL", "dave"] {
match normalize(input) {
Cow::Borrowed(name) => println!("{input:?} -> {name:?} (emprunté, aucune copie)"),
Cow::Owned(name) => println!("{input:?} -> {name:?} (possédé, nouvelle String)"),
}
}
}

Sortie :

"alice" -> "alice" (emprunté, aucune copie)
" Bob " -> "bob" (possédé, nouvelle String)
"CAROL" -> "carol" (possédé, nouvelle String)
"dave" -> "dave" (emprunté, aucune copie)
▶ Essayez par vous-même

Deux des quatre noms n’ont rien coûté du tout. Dans un vrai programme où 99 % des entrées sont déjà propres, cela fait beaucoup de copies évitées. Le '_ dans Cow<'_, str> veut simplement dire « ceci peut emprunter à l’entrée ». Si les lifetimes (durées de vie) vous semblent encore floues, la page Les lifetimes en Rust les explique.

En général, vous n’avez même pas besoin de faire un match sur un Cow. Il se déréférence (deref) en &str : vous pouvez donc appeler des méthodes de chaîne dessus et passer &name à n’importe quelle fonction qui prend un &str. Quand il vous faut vraiment une String à vous, appelez .into_owned(). Elle sort la String s’il y en a une, et ne copie que si le Cow était emprunté.

use std::borrow::Cow;
fn normalize(name: &str) -> Cow<'_, str> {
let trimmed = name.trim();
if trimmed.len() == name.len() && !name.chars().any(|c| c.is_uppercase()) {
Cow::Borrowed(name)
} else {
Cow::Owned(trimmed.to_lowercase())
}
}
fn greet(name: &str) {
println!("Bienvenue, {name} !");
}
fn main() {
let name = normalize(" Eve ");
println!("longueur : {}", name.len()); // les méthodes de str marchent directement
println!("commence par e : {}", name.starts_with('e'));
greet(&name); // &Cow<str> devient &str
let saved: String = name.into_owned(); // c'est maintenant une simple String
println!("{saved:?} enregistré dans la liste des utilisateurs");
}

Sortie :

longueur : 3
commence par e : true
Bienvenue, eve !
"eve" enregistré dans la liste des utilisateurs
▶ Essayez par vous-même

Cow fonctionne aussi avec d’autres types, comme Cow<'_, [i32]> pour une slice qui aura peut-être besoin d’être modifiée. Mais pour du texte, c’est Cow<'_, str> que vous verrez le plus souvent.

Voici un petit aide-mémoire de tous les types « pointeurs » que vous avez rencontrés jusqu’ici :

Type Ce qu’il vous apporte À utiliser quand
Box<T> Un seul propriétaire, valeur sur le tas Types récursifs, ou une grosse valeur que vous voulez déplacer à peu de frais
Rc<T> Plusieurs propriétaires, un seul thread Plusieurs parties du programme partagent les mêmes données
Arc<T> Plusieurs propriétaires, entre threads Comme Rc, mais partagé entre threads (voir L’état partagé)
Weak<T> Un lien qui ne possède pas Liens vers le parent, listes d’observateurs, caches, casser des cycles de Rc
RefCell<T> Modification à travers une référence partagée, vérifiée à l’exécution Vous devez modifier quelque chose qui vit dans un Rc
Cow<'_, T> Emprunté ou possédé, décidé à l’exécution Une fonction qui renvoie le plus souvent son entrée telle quelle

Dans le doute, commencez par l’ownership et le borrowing (l’emprunt) tout simples. N’ajoutez ces types que quand le compilateur, ou une fuite de mémoire, vous dit que vous en avez besoin.

Faire un unwrap sur upgrade alors que la valeur a disparu

Section intitulée « Faire un unwrap sur upgrade alors que la valeur a disparu »

upgrade() renvoie une Option, et ce n’est pas pour rien. Si vous faites .unwrap() par habitude, votre programme fera un panic dès que la valeur aura été libérée :

use std::rc::Rc;
fn main() {
let session = Rc::new(String::from("user-42"));
let link = Rc::downgrade(&session);
drop(session); // la session se termine
let current = link.upgrade().unwrap();
println!("toujours connecté en tant que {current}");
}

À l’exécution, vous obtenez :

thread 'main' (12345) panicked at src/main.rs:9:34:
called `Option::unwrap()` on a `None` value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ Essayez par vous-même

Ce message de Rust signifie : « Option::unwrap() a été appelé sur une valeur None ». La solution est de traiter « elle a disparu » comme un cas normal, avec match ou if let :

match link.upgrade() {
Some(current) => println!("toujours connecté en tant que {current}"),
None => println!("la session est terminée"),
}

Si un enfant tient un Rc<Parent> et que le parent tient un Rc<Child>, vous avez construit le cycle de la première section. Rien ne plante et rien ne vous prévient ; la mémoire ne revient simplement jamais. Une bonne règle : décidez qui possède qui, utilisez Rc dans ce sens-là, et Weak pour chaque lien qui va dans l’autre sens.

struct Folder {
parent: RefCell<Rc<Folder>>, // faux : l'enfant garde le parent en vie
children: RefCell<Vec<Rc<Folder>>>,
}

Remplacez le type de parent par RefCell<Weak<Folder>>, et le cycle disparaît.

Créez un Post avec un title, gardé dans un Rc. Créez un Comment avec un text et un post: Weak<Post>. Écrivez une fonction describe(comment: &Comment) qui affiche "<texte>" sur <titre> si l’article existe encore, ou "<texte>" sur un article supprimé sinon. Appelez-la, libérez l’article, puis appelez-la à nouveau. Indice : utilisez match comment.post.upgrade().

Voir la solution
use std::rc::{Rc, Weak};
struct Post {
title: String,
}
struct Comment {
text: String,
post: Weak<Post>,
}
fn describe(comment: &Comment) {
match comment.post.upgrade() {
Some(post) => println!("\"{}\" sur {}", comment.text, post.title),
None => println!("\"{}\" sur un article supprimé", comment.text),
}
}
fn main() {
let post = Rc::new(Post { title: String::from("Apprendre Rust") });
let comment = Comment { text: String::from("Super article !"), post: Rc::downgrade(&post) };
describe(&comment);
drop(post);
describe(&comment);
}

Sortie :

"Super article !" sur Apprendre Rust
"Super article !" sur un article supprimé
▶ Essayez par vous-même

Le commentaire ne garde jamais l’article en vie. Dès que le seul Rc est libéré, upgrade() renvoie None, et le commentaire gère ce cas calmement au lieu de faire un panic.

Écrivez fn escape(text: &str) -> Cow<'_, str>, qui remplace chaque < par &lt;. Si le texte ne contient pas de <, renvoyez-le emprunté. Testez-la sur "bonjour le monde" et "1 < 2", et affichez la variante obtenue. Indice : vérifiez d’abord avec text.contains('<'), puis utilisez text.replace('<', "&lt;") pour le cas possédé.

Voir la solution
use std::borrow::Cow;
fn escape(text: &str) -> Cow<'_, str> {
if text.contains('<') {
Cow::Owned(text.replace('<', "&lt;"))
} else {
Cow::Borrowed(text)
}
}
fn main() {
for text in ["bonjour le monde", "1 < 2"] {
match escape(text) {
Cow::Borrowed(s) => println!("{s} (emprunté)"),
Cow::Owned(s) => println!("{s} (possédé)"),
}
}
}

Sortie :

bonjour le monde (emprunté)
1 &lt; 2 (possédé)
▶ Essayez par vous-même

Seul le texte qui contient vraiment < paie pour une nouvelle String. Tout le reste passe directement, intact.

  • Deux valeurs Rc qui pointent l’une vers l’autre n’atteignent jamais un compteur fort de 0 : elles fuient, et leur Drop ne s’exécute jamais.
  • Rc::downgrade crée un Weak<T>, qui ne garde pas la valeur en vie. upgrade() rend une Option<Rc<T>>.
  • Dans les arbres, les enfants sont possédés avec Rc et les liens vers le parent sont des Weak. La possession descend, les liens faibles remontent.
  • Les listes d’observateurs et les caches peuvent contenir des Weak, pour ne jamais garder quelque chose en vie par accident.
  • Cow<'_, str> renvoie le texte emprunté quand rien n’a changé, et une String possédée seulement quand il a fallu le modifier. Il se déréférence en &str, et .into_owned() vous donne une String.

Petit quiz

1. Deux valeurs tiennent chacune un Rc vers l'autre. Que se passe-t-il à la fin de main ?

2. Que renvoie weak.upgrade() ?

3. Dans une arborescence de dossiers, quel lien doit être un Weak ?

4. Quand une fonction qui renvoie un Cow<'_, str> évite-t-elle de créer une nouvelle String ?

Score : 0 / 4