Introduction
Bienvenue dans RefCell
Dans ce laboratoire, nous explorerons le concept de mutabilité interne en Rust et la manière dont il est implémenté à l'aide du type RefCell<T>.
RefCell<T> et le modèle de mutabilité interne
La mutabilité interne est un modèle de conception en Rust qui vous permet de modifier des données même lorsqu'il existe des références immuables à ces données ; normalement, cette action est interdite par les règles d'emprunt. Pour modifier des données, le modèle utilise du code unsafe à l'intérieur d'une structure de données pour contourner les règles habituelles de Rust qui gouvernent la mutation et l'emprunt. Le code unsafe indique au compilateur que nous vérifions les règles manuellement au lieu de compter sur le compilateur pour les vérifier pour nous ; nous aborderons le code unsafe plus en détail au chapitre 19.
Nous ne pouvons utiliser des types qui utilisent le modèle de mutabilité interne que lorsque nous pouvons nous assurer que les règles d'emprunt seront respectées à l'exécution, même si le compilateur ne peut pas le garantir. Le code unsafe impliqué est ensuite encapsulé dans une API sécurisée, et le type externe reste immuable.
Explorons ce concept en examinant le type RefCell<T> qui suit le modèle de mutabilité interne.
Vérification des règles d'emprunt à l'exécution avec RefCell<T>
Contrairement à Rc<T>, le type RefCell<T> représente une propriété exclusive des données qu'il stocke. Alors, en quoi RefCell<T> diffère-t-il d'un type comme Box<T>? Rappelez-vous les règles d'emprunt que vous avez apprises au chapitre 4 :
- À tout moment donné, vous pouvez avoir soit une référence mutable, soit un nombre quelconque de références immuables (mais pas les deux).
- Les références doivent toujours être valides.
Avec les références et Box<T>, les invariants des règles d'emprunt sont vérifiés à la compilation. Avec RefCell<T>, ces invariants sont vérifiés à l'exécution. Avec les références, si vous violez ces règles, vous obtiendrez une erreur du compilateur. Avec RefCell<T>, si vous violez ces règles, votre programme plantera et sortira.
Les avantages de la vérification des règles d'emprunt à la compilation sont que les erreurs seront détectées plus tôt dans le processus de développement et qu'il n'y a pas d'impact sur les performances à l'exécution car toutes les analyses sont effectuées à l'avance. Pour ces raisons, la vérification des règles d'emprunt à la compilation est le meilleur choix dans la majorité des cas, ce qui est pourquoi c'est la valeur par défaut de Rust.
L'avantage de la vérification des règles d'emprunt à l'exécution est que certaines situations sécuritaires en matière de mémoire sont alors autorisées, tandis que la vérification à la compilation les aurait interdites. L'analyse statique, comme le compilateur Rust, est intrinsèquement conservatrice. Certaines propriétés du code sont impossibles à détecter en analysant le code : l'exemple le plus célèbre est le problème de l'arrêt, qui est en dehors des limites de ce livre mais est un sujet intéressant à étudier.
Parce que certaines analyses sont impossibles, si le compilateur Rust n'est pas sûr que le code respecte les règles de propriété, il peut rejeter un programme correct ; de cette manière, il est conservateur. Si Rust acceptait un programme incorrect, les utilisateurs ne pourraient pas faire confiance aux garanties offertes par Rust. Cependant, si Rust rejette un programme correct, le programmeur sera dérangé, mais rien de catastrophique ne peut se produire. Le type RefCell<T> est utile lorsque vous êtes sûr que votre code suit les règles d'emprunt, mais que le compilateur est incapable de le comprendre et de le garantir.
De manière similaire à Rc<T>, RefCell<T> n'est destiné qu'à être utilisé dans des scénarios mono-threadés et vous donnera une erreur de compilation si vous essayez de l'utiliser dans un contexte multi-threadé. Nous parlerons de la manière d'obtenir la fonctionnalité de RefCell<T> dans un programme multi-threadé au chapitre 16.
Voici un récapitulatif des raisons de choisir Box<T>, Rc<T> ou RefCell<T> :
Rc<T>permet plusieurs propriétaires des mêmes données ;Box<T>etRefCell<T>ont une propriété exclusive.Box<T>permet des emprunts immuables ou mutables vérifiés à la compilation ;Rc<T>permet seulement des emprunts immuables vérifiés à la compilation ;RefCell<T>permet des emprunts immuables ou mutables vérifiés à l'exécution.- Parce que
RefCell<T>permet des emprunts mutables vérifiés à l'exécution, vous pouvez modifier la valeur à l'intérieur deRefCell<T>même lorsqueRefCell<T>est immuable.
Modifier la valeur à l'intérieur d'une valeur immuable est le modèle de mutabilité interne. Examnons une situation dans laquelle la mutabilité interne est utile et voyons comment cela est possible.
Mutabilité interne : Un emprunt mutable à une valeur immuable
Une conséquence des règles d'emprunt est que lorsqu'on a une valeur immuable, on ne peut pas l'emprunter mutuellement. Par exemple, ce code ne compilera pas :
Nom de fichier : src/main.rs
fn main() {
let x = 5;
let y = &mut x;
}
Si vous essayez de compiler ce code, vous obtiendrez l'erreur suivante :
error[E0596]: cannot borrow `x` as mutable, as it is not declared
as mutable
--> src/main.rs:3:13
|
2 | let x = 5;
| - help: consider changing this to be mutable: `mut x`
3 | let y = &mut x;
| ^^^^^^ cannot borrow as mutable
Cependant, il existe des situations dans lesquelles il serait utile qu'une valeur se modifie elle-même dans ses méthodes, mais qu'elle apparaisse immuable pour le reste du code. Le code situé en dehors des méthodes de la valeur ne serait pas en mesure de modifier la valeur. L'utilisation de RefCell<T> est un moyen d'obtenir la capacité de mutabilité interne, mais RefCell<T> ne contourne pas complètement les règles d'emprunt : l'analyseur d'emprunt du compilateur autorise cette mutabilité interne, et les règles d'emprunt sont vérifiées à l'exécution au lieu de la compilation. Si vous violez les règles, vous obtiendrez une panic! au lieu d'une erreur du compilateur.
Examînons un exemple pratique où nous pouvons utiliser RefCell<T> pour modifier une valeur immuable et voyons pourquoi cela est utile.
Un cas d'utilisation de la mutabilité interne : Les objets de mock
Parfois, lors des tests, un programmeur utilisera un type à la place d'un autre type, afin d'observer un comportement particulier et d'affirmer qu'il est correctement implémenté. Ce type de substitut est appelé un doublon de test. Pensez à cela comme un double de tournage dans le cinéma, où une personne prend la place d'un acteur pour réaliser une scène particulièrement difficile. Les doubles de test prennent la place d'autres types lorsque nous exécutons des tests. Les objets de mock sont des types spécifiques de doubles de test qui enregistrent ce qui se passe pendant un test, afin que vous puissiez affirmer que les bonnes actions ont eu lieu.
Rust n'a pas d'objets au sens où d'autres langages les ont, et Rust n'a pas de fonctionnalité d'objet de mock intégrée dans la bibliothèque standard comme certains autres langages le font. Cependant, vous pouvez certainement créer une structure qui servira les mêmes buts qu'un objet de mock.
Voici le scénario que nous allons tester : nous allons créer une bibliothèque qui suit une valeur par rapport à une valeur maximale et envoie des messages en fonction de la proximité de la valeur maximale de la valeur actuelle. Cette bibliothèque pourrait être utilisée pour suivre le quota d'un utilisateur pour le nombre d'appels API qu'il est autorisé à effectuer, par exemple.
Notre bibliothèque ne fournira que la fonctionnalité de suivre à quel point une valeur est proche de la valeur maximale et quels messages devraient être envoyés à quels moments. Les applications qui utilisent notre bibliothèque devront fournir le mécanisme d'envoi des messages : l'application pourrait placer un message dans l'application, envoyer un courrier électronique, envoyer un message texte ou faire autre chose. La bibliothèque n'a pas besoin de connaître ces détails. Tout ce qu'elle a besoin, c'est de quelque chose qui implémente un trait que nous allons fournir appelé Messenger. La liste 15-20 montre le code de la bibliothèque.
Nom de fichier : src/lib.rs
pub trait Messenger {
1 fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(
messenger: &'a T,
max: usize
) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
2 pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max =
self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger
.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent: You're at 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You're at 75% of your quota!");
}
}
}
Liste 15-20 : Une bibliothèque pour suivre à quel point une valeur est proche d'une valeur maximale et avertir lorsqu'elle atteint certains niveaux
Une partie importante de ce code est que le trait Messenger a une méthode appelée send qui prend une référence immuable à self et le texte du message [1]. Ce trait est l'interface que notre objet de mock doit implémenter pour que le mock puisse être utilisé de la même manière qu'un objet réel. L'autre partie importante est que nous voulons tester le comportement de la méthode set_value sur le LimitTracker [2]. Nous pouvons changer ce que nous passons pour le paramètre value, mais set_value ne renvoie rien pour que nous puissions formuler des assertions. Nous voulons être en mesure de dire que si nous créons un LimitTracker avec quelque chose qui implémente le trait Messenger et une valeur particulière pour max, lorsque nous passons différents nombres pour value, le messager est invité à envoyer les messages appropriés.
Nous avons besoin d'un objet de mock qui, au lieu d'envoyer un courrier électronique ou un message texte lorsque nous appelons send, ne fera que suivre les messages qu'il est invité à envoyer. Nous pouvons créer une nouvelle instance de l'objet de mock, créer un LimitTracker qui utilise l'objet de mock, appeler la méthode set_value sur LimitTracker, puis vérifier que l'objet de mock a les messages que nous attendons. La liste 15-21 montre une tentative d'implémenter un objet de mock pour faire exactement cela, mais l'analyseur d'emprunt ne le permettra pas.
Nom de fichier : src/lib.rs
#[cfg(test)]
mod tests {
use super::*;
1 struct MockMessenger {
2 sent_messages: Vec<String>,
}
impl MockMessenger {
3 fn new() -> MockMessenger {
MockMessenger {
sent_messages: vec![],
}
}
}
4 impl Messenger for MockMessenger {
fn send(&self, message: &str) {
5 self.sent_messages.push(String::from(message));
}
}
#[test]
6 fn it_sends_an_over_75_percent_warning_message() {
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(
&mock_messenger,
100
);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.len(), 1);
}
}
Liste 15-21 : Une tentative d'implémenter un MockMessenger qui n'est pas autorisé par l'analyseur d'emprunt
Ce code de test définit une structure MockMessenger [1] qui a un champ sent_messages avec un Vec de valeurs String [2] pour suivre les messages qu'il est invité à envoyer. Nous définissons également une fonction associée new [3] pour faciliter la création de nouvelles valeurs MockMessenger qui commencent avec une liste vide de messages. Nous implémentons ensuite le trait Messenger pour MockMessenger [4] afin que nous puissions fournir un MockMessenger à un LimitTracker. Dans la définition de la méthode send [5], nous prenons le message passé en tant que paramètre et le stockons dans la liste sent_messages de MockMessenger.
Dans le test, nous testons ce qui se passe lorsque le LimitTracker est invité à définir value sur une valeur supérieure à 75 % de la valeur max [6]. Tout d'abord, nous créons un nouveau MockMessenger, qui commencera avec une liste vide de messages. Ensuite, nous créons un nouveau LimitTracker et lui donnons une référence au nouveau MockMessenger et une valeur max de 100. Nous appelons la méthode set_value sur le LimitTracker avec une valeur de 80, qui est supérieure à 75 % de 100. Ensuite, nous affirmons que la liste de messages que le MockMessenger suit devrait maintenant avoir un message.
Cependant, il y a un problème avec ce test, comme le montre ici :
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a
`&` reference
--> src/lib.rs:58:13
|
2 | fn send(&self, msg: &str);
| ----- help: consider changing that to be a mutable reference:
`&mut self`
...
58 | self.sent_messages.push(String::from(message));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `self` is a
`&` reference, so the data it refers to cannot be borrowed as mutable
Nous ne pouvons pas modifier le MockMessenger pour suivre les messages car la méthode send prend une référence immuable à self. Nous ne pouvons pas non plus suivre la suggestion du message d'erreur d'utiliser &mut self à la place car alors, la signature de send ne correspondrait pas à la signature dans la définition du trait Messenger (n'hésitez pas à essayer et à voir quel message d'erreur vous obtenez).
C'est une situation où la mutabilité interne peut aider! Nous stockerons les sent_messages dans un RefCell<T>, puis la méthode send sera capable de modifier sent_messages pour stocker les messages que nous avons reçus. La liste 15-22 montre à quoi cela ressemble.
Nom de fichier : src/lib.rs
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct MockMessenger {
1 sent_messages: RefCell<Vec<String>>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
2 sent_messages: RefCell::new(vec![]),
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages
3.borrow_mut()
.push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
--snip--
assert_eq!(
4 mock_messenger.sent_messages.borrow().len(),
1
);
}
}
Liste 15-22 : Utilisation de RefCell<T> pour modifier une valeur interne tandis que la valeur externe est considérée immuable
Le champ sent_messages est maintenant de type RefCell<Vec<String>> [1] au lieu de Vec<String>. Dans la fonction new, nous créons une nouvelle instance de RefCell<Vec<String>> autour du vecteur vide [2].
Pour l'implémentation de la méthode send, le premier paramètre est toujours un emprunt immuable de self, ce qui correspond à la définition du trait. Nous appelons borrow_mut sur le RefCell<Vec<String>> dans self.sent_messages [3] pour obtenir une référence mutable à la valeur à l'intérieur du RefCell<Vec<String>>, qui est le vecteur. Ensuite, nous pouvons appeler push sur la référence mutable au vecteur pour suivre les messages envoyés pendant le test.
La dernière modification que nous devons apporter est dans l'assertion : pour voir combien d'éléments sont dans le vecteur interne, nous appelons borrow sur le RefCell<Vec<String>> pour obtenir une référence immuable au vecteur [4].
Maintenant que vous avez vu comment utiliser RefCell<T>, approfondissons comment cela fonctionne!
Suivi des emprunts à l'exécution avec RefCell<T>
Lors de la création de références immuables et mutables, nous utilisons respectivement la syntaxe & et &mut. Avec RefCell<T>, nous utilisons les méthodes borrow et borrow_mut, qui font partie de l'API sécurisée qui appartient à RefCell<T>. La méthode borrow renvoie le type de pointeur intelligent Ref<T>, et borrow_mut renvoie le type de pointeur intelligent RefMut<T>. Les deux types implémentent Deref, de sorte que nous pouvons les traiter comme des références normales.
RefCell<T> suit le nombre de pointeurs intelligents Ref<T> et RefMut<T> actuellement actifs. Chaque fois que nous appelons borrow, RefCell<T> augmente son compteur du nombre d'emprunts immuables actifs. Lorsqu'une valeur Ref<T> sort de portée, le compteur d'emprunts immuables diminue de 1. Tout comme les règles d'emprunt à la compilation, RefCell<T> nous permet d'avoir de nombreux emprunts immuables ou un emprunt mutable à n'importe quel moment.
Si nous essayons de violer ces règles, au lieu d'obtenir une erreur du compilateur comme nous le ferions avec les références, l'implémentation de RefCell<T> provoquera une panique à l'exécution. La liste 15-23 montre une modification de l'implémentation de send dans la liste 15-22. Nous essayons délibérément de créer deux emprunts mutables actifs pour la même portée pour illustrer que RefCell<T> nous empêche de le faire à l'exécution.
Nom de fichier : src/lib.rs
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
let mut one_borrow = self.sent_messages.borrow_mut();
let mut two_borrow = self.sent_messages.borrow_mut();
one_borrow.push(String::from(message));
two_borrow.push(String::from(message));
}
}
Liste 15-23 : Création de deux références mutables dans la même portée pour voir que RefCell<T> provoquera une panique
Nous créons une variable one_borrow pour le pointeur intelligent RefMut<T> renvoyé par borrow_mut. Ensuite, nous créons un autre emprunt mutable de la même manière dans la variable two_borrow. Cela crée deux références mutables dans la même portée, ce qui n'est pas autorisé. Lorsque nous exécutons les tests de notre bibliothèque, le code de la liste 15-23 compilera sans erreur, mais le test échouera :
---- tests::it_sends_an_over_75_percent_warning_message stdout ----
thread 'main' panicked at 'already borrowed: BorrowMutError', src/lib.rs:60:53
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Remarquez que le code a été interrompu avec le message already borrowed: BorrowMutError. C'est ainsi que RefCell<T> gère les violations des règles d'emprunt à l'exécution.
Choisir de capturer les erreurs d'emprunt à l'exécution plutôt qu'à la compilation, comme nous l'avons fait ici, signifie que vous risquez de découvrir des erreurs dans votre code plus tard dans le processus de développement : peut-être pas avant que votre code ne soit déployé en production. De plus, votre code subira une légère pénalité de performance à l'exécution en raison du suivi des emprunts à l'exécution plutôt qu'à la compilation. Cependant, l'utilisation de RefCell<T> permet d'écrire un objet de mock qui peut se modifier pour suivre les messages qu'il a reçus tandis que vous l'utilisez dans un contexte où seulement des valeurs immuables sont autorisées. Vous pouvez utiliser RefCell<T> malgré ses inconvénients pour obtenir plus de fonctionnalité que les références normales ne le permettent.
Autoriser plusieurs propriétaires de données mutables avec Rc<T> et RefCell<T>
Une manière courante d'utiliser RefCell<T> est en combinaison avec Rc<T>. Rappelez-vous que Rc<T> vous permet d'avoir plusieurs propriétaires pour certaines données, mais il ne vous donne qu'un accès immuable à ces données. Si vous avez un Rc<T> qui contient un RefCell<T>, vous pouvez obtenir une valeur qui peut avoir plusieurs propriétaires et que vous pouvez modifier!
Par exemple, rappelez-vous l'exemple de liste cons dans la liste 15-18 où nous avons utilisé Rc<T> pour permettre à plusieurs listes de partager la propriété d'une autre liste. Étant donné que Rc<T> ne contient que des valeurs immuables, nous ne pouvons pas modifier aucune des valeurs de la liste une fois qu'elles ont été créées. Ajoutons RefCell<T> pour sa capacité à modifier les valeurs dans les listes. La liste 15-24 montre que, en utilisant un RefCell<T> dans la définition de Cons, nous pouvons modifier la valeur stockée dans toutes les listes.
Nom de fichier : src/main.rs
#[derive(Debug)]
enum List {
Cons(Rc<RefCell<i32>>, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
1 let value = Rc::new(RefCell::new(5));
2 let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));
let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));
3 *value.borrow_mut() += 10;
println!("a after = {:?}", a);
println!("b after = {:?}", b);
println!("c after = {:?}", c);
}
Liste 15-24 : Utilisation de Rc<RefCell<i32>> pour créer une List que nous pouvons modifier
Nous créons une valeur qui est une instance de Rc<RefCell<i32>> et la stockons dans une variable nommée value [1] pour pouvoir y accéder directement plus tard. Ensuite, nous créons une List dans a avec une variante Cons qui contient value [2]. Nous devons cloner value pour que a et value aient tous les deux la propriété de la valeur interne 5 plutôt que de transférer la propriété de value à a ou d'avoir a emprunter value.
Nous enveloppons la liste a dans un Rc<T> pour que lorsque nous créons les listes b et c, elles puissent toutes deux faire référence à a, comme nous l'avons fait dans la liste 15-18.
Après avoir créé les listes dans a, b et c, nous voulons ajouter 10 à la valeur dans value [3]. Nous le faisons en appelant borrow_mut sur value, qui utilise la fonctionnalité d'indirection automatique dont nous avons parlé dans "Où est l'opérateur ->?" pour indirectionner le Rc<T> vers la valeur interne RefCell<T>. La méthode borrow_mut renvoie un pointeur intelligent RefMut<T>, et nous utilisons l'opérateur d'indirection dessus et changeons la valeur interne.
Lorsque nous imprimons a, b et c, nous pouvons voir qu'elles ont toutes la valeur modifiée de 15 plutôt que de 5 :
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))
Cette technique est assez pratique! En utilisant RefCell<T>, nous avons une valeur List qui est extérieurement immuable. Mais nous pouvons utiliser les méthodes sur RefCell<T> qui donnent accès à sa mutabilité interne pour modifier nos données lorsque nécessaire. Les vérifications à l'exécution des règles d'emprunt nous protègent contre les courses de données, et il est parfois intéressant de sacrifier un peu de vitesse pour cette flexibilité dans nos structures de données. Notez que RefCell<T> ne fonctionne pas pour le code multithreadé! Mutex<T> est la version thread-safe de RefCell<T>, et nous en parlerons au chapitre 16.
Sommaire
Félicitations! Vous avez terminé le laboratoire sur RefCell