Aller au contenu

Les pointeurs intelligents en Rust

Les règles d’ownership (possession) et de borrowing (emprunt) couvrent la plupart des situations. Mais quelques cas bien réels ne rentrent pas dans le moule « exactement un propriétaire » : une valeur dont la taille n’est connue qu’à l’exécution, des données qui ont vraiment besoin de plusieurs propriétaires, ou une valeur que vous devez modifier à travers une référence partagée. Les smart pointers (pointeurs intelligents) sont la réponse de Rust à chacun de ces cas.

Par défaut, les valeurs vivent sur la pile (stack), et le compilateur doit connaître leur taille à l’avance. Box<T> déplace une valeur sur le tas (heap) et vous donne un pointeur de taille fixe vers elle. C’est utile quand la taille d’un type ne peut pas être connue à la compilation, le plus souvent avec les types récursifs :

fn main() {
let boxed = Box::new(5);
println!("{}", *boxed); // * pour récupérer la valeur
}

Sortie :

5
▶ Essayez par vous-même

Un exemple classique où vous avez besoin de Box : un type qui se contient lui-même, comme un nœud de liste chaînée :

enum List {
Cons(i32, Box<List>),
Nil,
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
match list {
List::Cons(value, _) => println!("Première valeur : {value}"),
List::Nil => println!("Vide"),
}
}

Sortie :

Première valeur : 1
▶ Essayez par vous-même

Sans Box, Cons(i32, List) demanderait une place infinie : une List qui contient une List qui contient une List, et ainsi de suite à l’infini. Box<List>, lui, est un pointeur de taille fixe, donc le compilateur peut calculer la place nécessaire.

L’ownership dit qu’une valeur a exactement un propriétaire. La plupart du temps, c’est exactement ce qu’il faut. Mais parfois, plusieurs parties de votre programme ont vraiment besoin de partager les mêmes données. Rc<T> (reference counted, « à comptage de références ») le permet, en tenant le compte du nombre de propriétaires qui existent à un instant donné :

use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("données partagées"));
let b = Rc::clone(&a); // pas une copie profonde : juste un propriétaire de plus
let c = Rc::clone(&a);
println!("{a} {b} {c}");
println!("Propriétaires : {}", Rc::strong_count(&a));
}

Sortie :

données partagées données partagées données partagées
Propriétaires : 3
▶ Essayez par vous-même

Rc::clone ne copie pas les données elles-mêmes : il augmente simplement un compteur et vous rend une autre référence vers la même valeur. Les données ne sont libérées qu’une fois que tous les propriétaires sont sortis de leur portée.

RefCell<T> : modifier à travers une référence partagée

Section intitulée « RefCell<T> : modifier à travers une référence partagée »

Normalement, les règles du borrowing sont vérifiées à la compilation : soit plusieurs références en lecture seule, soit une seule référence mutable, jamais les deux. RefCell<T> déplace cette même vérification à l’exécution. Cela vous permet de modifier une valeur même à travers ce qui ressemble à une référence partagée, sans mut :

use std::cell::RefCell;
fn main() {
let value = RefCell::new(5);
*value.borrow_mut() += 1;
println!("{}", value.borrow());
}

Sortie :

6
▶ Essayez par vous-même

RefCell est le plus souvent associé à Rc. Rc<RefCell<T>> vous donne une valeur qui a plusieurs propriétaires et qui peut quand même être modifiée, ce que Rc<T> seul ne permet pas.

Ne commencez pas par les smart pointers. Commencez avec l’ownership et le borrowing classiques, et ne sortez ces outils que lorsque le compilateur vous dit clairement que ça ne rentre pas : un type récursif (Box), une valeur qui a vraiment besoin de plusieurs propriétaires (Rc), ou une modification à travers une référence partagée que les règles d’emprunt n’autorisent pas (RefCell).

Reprenez l’enum List de la partie sur Box. Construisez la liste 1, 2, 3 et écrivez une fonction fn sum(list: &List) -> i32 qui additionne toutes les valeurs. Indice : faites un match sur la liste. Pour Cons(value, rest), la réponse est value plus la somme de rest. Pour Nil, c’est 0.

Voir la solution
enum List {
Cons(i32, Box<List>),
Nil,
}
fn sum(list: &List) -> i32 {
match list {
List::Cons(value, rest) => value + sum(rest),
List::Nil => 0,
}
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Cons(3, Box::new(List::Nil))))));
println!("Somme : {}", sum(&list));
}

Sortie :

Somme : 6
▶ Essayez par vous-même

La fonction s’appelle elle-même sur le reste de la liste, jusqu’à atteindre Nil. Remarquez que vous pouvez passer rest (un &Box<List>) là où un &List est attendu : Rust regarde à travers la Box pour vous.

Créez une seule liste de courses (un Vec<String>) que deux personnes, alice et bob, possèdent toutes les deux et peuvent toutes les deux compléter. Chacune ajoute un article. Affichez ensuite la liste et le nombre de propriétaires. Indice : enveloppez le Vec dans un Rc<RefCell<...>>, donnez à chaque personne un Rc::clone, et appelez .borrow_mut().push(...) pour ajouter un article.

Voir la solution
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let list = Rc::new(RefCell::new(Vec::new()));
let alice = Rc::clone(&list);
let bob = Rc::clone(&list);
alice.borrow_mut().push(String::from("lait"));
bob.borrow_mut().push(String::from("pain"));
println!("{:?}", list.borrow());
println!("Propriétaires : {}", Rc::strong_count(&list));
}

Sortie :

["lait", "pain"]
Propriétaires : 3
▶ Essayez par vous-même

Il y a trois propriétaires : list, alice et bob. Tous les trois pointent vers le même Vec, donc un article ajouté par alice est aussi visible depuis bob et list. Aucune des variables n’a besoin de mut, car c’est RefCell qui gère la modification.

  • Box<T> range une valeur sur le tas, derrière un pointeur de taille fixe : indispensable pour les types récursifs.
  • Rc<T> permet d’avoir plusieurs propriétaires pour une même valeur dans du code à un seul thread, grâce au comptage de références ; entre plusieurs threads, utilisez Arc<T>.
  • RefCell<T> vérifie les règles d’emprunt à l’exécution plutôt qu’à la compilation, ce qui permet de modifier une valeur à travers une référence partagée, mais provoque une panic si les règles sont enfreintes.
  • Ne sortez ces outils que lorsque l’ownership et le borrowing classiques ne conviennent pas à votre situation.

Petit quiz

1. Pourquoi un type récursif, comme un nœud de liste chaînée, a-t-il besoin de Box<T> ?

2. Que fait réellement Rc::clone(&a) ?

3. Quand RefCell<T> vérifie-t-il les règles d'emprunt ?

4. Qu'apporte Rc<RefCell<T>> que Rc<T> seul n'offre pas ?

Score : 0 / 4