Rc<T>, le pointeur intelligent qui compte les références

Dans la majorité des cas, la possession est claire : vous savez exactement quelle variable possède une valeur donnée. Cependant, il existe des cas où une valeur peut être possédée par plusieurs propriétaires. Par exemple, dans des structures de données de graphes, plusieurs extrémités peuvent pointer vers le même noeud, et ce noeud est par conception possédé par toutes les extrémités qui pointent vers lui. Un noeud ne devrait pas être nettoyé, à moins qu'il n'ait plus d'extrémités qui pointent vers lui.

Pour permettre la possession multiple, Rust dispose du type Rc<T>, qui est une abréviation pour Reference Counting (compteur de références). Le type Rc<T> assure le suivi du nombre de références vers une valeur, afin de déterminer si la valeur est toujours utilisée ou non. S'il y a zéro références vers une valeur, la valeur peut être nettoyée sans qu'aucune référence ne devienne invalide.

Imaginez que Rc<T> est comme une télévision dans une salle commune. Lorsqu'une personne entre pour regarder la télévision, elle l'allume. Une autre entre dans la salle et regarde la télévision. Lorsque la dernière personne quitte la salle, elle éteint la télévision car elle n'est plus utilisée. Si quelqu'un éteint la télévision alors que d'autres continuent à la regarder, cela va provoquer du chahut !

Nous utilisons le type Rc<T> lorsque nous souhaitons allouer une donnée sur le tas pour que plusieurs éléments de notre programme puissent la lire et que nous ne pouvons pas déterminer au moment de la compilation quel élément cessera de l'utiliser en dernier. Si nous savions quel élément finirait en dernier, nous pourrions simplement faire en sorte que cet élément prenne possession de la donnée, et les règles de possession classiques qui s'appliquent au moment de la compilation prendraient effet.

Notez que Rc<T> fonctionne uniquement dans des scénarios à un seul processus. Lorsque nous verrons la concurrence au chapitre 16, nous verrons comment procéder au comptage de références dans des programmes multi-processus.

Utiliser Rc<T> pour partager une donnée

Retournons à notre exemple de liste de construction de l'encart 15-5. Souvenez-vous que nous l'avons défini en utilisant Box<T>. Cette fois-ci, nous allons créer deux listes qui partagent toutes les deux la propriété d'une troisième liste. Théoriquement, cela ressemblera à l'illustration 15-3 :

Deux listes qui se partagent la propriété d'une troisième liste

Illustration 15-3 : deux listes, b et c, qui se partagent la possession d'une troisième liste, a

Nous allons créer une liste a qui contient 5 et ensuite 10. Ensuite, nous allons créer deux autres listes : b qui démarre avec 3 et c qui démarre avec 4. Les deux listes b et c vont ensuite continuer sur la première liste a qui contient déjà 5 et 10. Autrement dit, les deux listes vont se partager la première liste contenant 5 et 10.

Si nous essayons d'implémenter ce scénario en utilisant les définitions de List avec Box<T>, comme dans l'encart 15-17, cela ne va pas fonctionner :

Fichier : src/main.rs

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}

Encart 15-17 : démonstration que nous ne sommes pas autorisés à avoir deux listes qui utilisent Box<T> pour partager la propriété d'une troisième liste

Lorsque nous compilons ce code, nous obtenons cette erreur :

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
9  |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` due to previous error

Les variantes Cons prennent possession des données qu'elles obtiennent, donc lorsque nous avons créé la liste b, a a été déplacée dans b et b possède désormais a. Ensuite, lorsque nous essayons d'utiliser a à nouveau lorsque nous créons c, nous ne sommes pas autorisés à le faire car a a été déplacé.

Nous pourrions changer la définition de Cons pour stocker des références à la place, mais ensuite nous aurions besoin de renseigner des paramètres de durée de vie. En renseignant les paramètres de durée de vie, nous devrions préciser que chaque élément dans la liste vivra au moins aussi longtemps que la liste entière. C'est le cas pour les éléments et les listes dans l'encart 15-17, mais pas dans tous les cas.

A la place, nous allons changer la définition de List pour utiliser Rc<T> à la place de Box<T>, comme dans l'encart 15-18. Chaque variante Cons va maintenant posséder une valeur et un Rc<T> pointant sur une List. Lorsque nous créons b, au lieu de prendre possession de a, nous allons cloner le Rc<List> que a possède, augmentant ainsi le nombre de références de un à deux et permettant à a et b de partager la propriété des données dans Rc<List>. Nous allons aussi cloner a lorsque nous créons c, augmentant le nombre de références de deux à trois. Chaque fois que nous appelons Rc::clone, le compteur de références des données présentes dans le Rc<List> va augmenter, et les données ne seront pas nettoyées tant qu'il n'y aura pas zéro référence vers elles.

Filename : src/main.rs

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}

Encart 15-18 : une définition de List qui utilise Rc<T>

Nous devons ajouter une instruction use pour importer Rc<T> dans la portée car il n'est pas présent dans l'étape préliminaire. Dans le main, nous créons la liste qui stocke 5 et 10 et la stocke dans une nouvelle Rc<List> dans a. Ensuite, lorsque nous créons b et c, nous appelons la fonction Rc::clone et nous passons une référence vers le Rc<List> de a en argument.

Nous aurions pu appeler a.clone() plutôt que Rc::clone(&a), mais la convention en Rust est d'utiliser Rc::clone dans cette situation. L'implémentation de Rc::clone ne fait pas une copie profonde de toutes les données comme le fait la plupart des implémentations de clone. L'appel à Rc:clone augmente uniquement le compteur de références, ce qui ne prend pas beaucoup de temps. Les copies profondes des données peuvent prendre beaucoup de temps. En utilisant Rc::clone pour les compteurs de références, nous pouvons distinguer visuellement un clonage qui fait une copie profonde d'un clonage qui augmente uniquement le compteur de références. Lorsque vous enquêtez sur des problèmes de performances dans le code, vous pouvez ainsi écarter les appels à Rc::clone pour ne vous intéresser qu'aux clonages à copie profonde que vous recherchez probablement.

Cloner une Rc<T> augmente le compteur de référence

Changeons notre exemple de l'encart 15-18 pour que nous puissions voir le compteur de références changer au fur et à mesure que nous créons et libérons des références dans le Rc<List> présent dans a.

Dans l'encart 15-19, nous allons changer le main afin qu'il ait une portée en son sein autour de c ; ainsi nous pourrons voir comment le compteur de références change lorsque c sort de la portée.

Fichier : src/main.rs

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("compteur après la création de a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("compteur après la création de b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("compteur après la création de c = {}", Rc::strong_count(&a));
    }
    println!("compteur après que c est sorti de la portée = {}", Rc::strong_count(&a));
}

Encart 15-19 : affichage du compteur de références

A chaque étape du programme où le compteur de références change, nous affichons le compteur de références, que nous pouvons obtenir en faisant appel à la fonction Rc::strong_count. Cette fonction s'appelle strong_count plutôt que count car le type Rc<T> a aussi un weak_count ; nous verrons à quoi sert ce weak_count dans la dernière section de ce chapitre.

Ce code affiche ceci :

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished dev [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
compteur après la création de a = 1
compteur après la création de b = 2
compteur après la création de c = 3
compteur après que c est sorti de la portée = 2

Nous pouvons voir clairement que le Rc<List> dans a a un compteur de références initial à 1 ; puis à chaque fois que nous appelons clone, le compteur augmente de 1. Nous n'avons pas à appeler une fonction pour réduire le compteur de références, comme nous avons dû le faire avec Rc::clone pour augmenter compteur : l'implémentation du trait Drop réduit le compteur de références automatiquement lorsqu'une valeur de Rc<T> sort de la portée.

Ce que nous ne voyons pas dans cet exemple, c'est que lorsque b et a sortent de la portée à la fin du main, le compteur vaut alors 0 et que le Rc<List> est nettoyé complètement à ce moment. L'utilisation de Rc<T> permet à une valeur d'avoir plusieurs propriétaires, et le compteur garantit que la valeur reste en vigueur tant qu'au moins un propriétaire existe encore.

Grâce aux références immuables, Rc<T> vous permet de partager des données entre plusieurs éléments de votre programme pour uniquement les lire. Si Rc<T> vous avait aussi permis d'avoir des références mutables, vous auriez alors violé une des règles d'emprunt vues au chapitre 4 : les emprunts mutables multiples à une même donnée peuvent causer des accès concurrents et des incohérences. Cependant, pouvoir modifier des données reste très utile ! Dans la section suivante, nous allons voir le motif de mutabilité interne et le type RefCell<T> que vous pouvez utiliser conjointement avec un Rc<T> pour pouvoir travailler avec cette contrainte d'immuabilité.