Salut tout le monde,
J’ai un problème avec le borrow checker de Rust. Voici un bout de code qui représente ce que j’essaye de faire :
struct Foo {
bars: Vec<Bar>,
}
impl Foo {
pub fn create_bar(&mut self) -> &mut Bar {
bars.push(Bar::new());
match bars.last_mut() {
Some(v) => v,
None => panic!("Can't go here"),
}
}
}
fn main() {
let mut foo = Foo {
bars: vec![],
};
let bar_1 = foo.create_bar();
let bar_2 = foo.create_bar();
// some manipulation with bar_1 and bar_2 together
}
- Les
Bar
sont contenus dansFoo
car ils appartiennent àFoo
: quandFoo
est détruit, ils doivent l’être aussi - Malgré tout, il faut bien que les
Bar
soit manipulables, d’où le retour de référence - Pour une raison qui je pense ne doit pas nous intéresser ici, j’ai besoin après ce code de manipuler les deux
Bar
ensemble
Ce code ne compile pas, j’obtiens une erreur de ce type :
error[E0499]: cannot borrow `foo` as mutable more than once at a time
--> main.rs:22
|
21 | let bar_1 = foo.create_bar();
| -- first mutable borrow occurs here
22 | let bar_2 = foo.create_bar();
| ^^ second mutable borrow occurs here
...
24 | // some manipulation with bar_1 and bar_2 together
| --------------- first borrow later used here
Visiblement pour le compilateur, manipuler simultanément deux références mutables sur deux éléments différents et indépendants, contenus d’une manière ou d’une autre dans un même agrégat, revient à manipuler simultanément deux références mutables sur ce même agrégat.
Pourquoi ? En quoi cela a-t-il un intérêt contre les data races ? Je ne comprends pas.
De plus, pour retourner une référence, je n’ai pas d’autre moyen que cet horrible match
. Là encore, le compilateur est "trop bête" pour savoir qu’il y a forcément un dernier élément puisque je viens d’en insérer un. Je sens bien que je ne dois pas m’y prendre correctement, mais je ne vois pas d’autre moyen. Si je crée d’abord la valeur, puis que je la déplace dans le Vec
via push
, puis que je renvoie une référence sur cette valeur, j’ai une erreur car je référence une valeur qui a été déplacée.
Une autre piste que j’ai déjà essayée est d’utiliser des Rc<RefCell<Bar>>
. Ça marche, mais ça a plusieurs gros défauts à mes yeux :
- Je trouve qu’on perd un peu l’intérêt de Rust, et de ses vérifications compile-time. Si c’est à la charge du développeur d’écrire minutieusement son code pour éviter que le programme crashe par un
panic!
deRefCell
, ça n’a pas beaucoup de différence avec écrire minutieusement son code pour éviter les data races. - Utiliser
RefCell
ajoute du code inutile à l’exécution. En effet, du code est exécuté afin d’apporter les garanties qui sont déjà apportées par le développeur s’il ne veut pas que son programme crashe. Dans le coeur d’une librairie, dans des chemins de code qui seront potentiellement utilisés des centaines de fois par seconde, ces instructions inutiles sont inacceptables pour les performances. - Le défaut majeur à mes yeux : ça rend surtout le code diablement illisible. Il faut ajouter des
Rc::clone
,.borrow()
et.borrow_mut()
partout, y compris dans le code de l’utilisateur de la librairie. La rigidité des borrowing rules empêche de stocker une référence pour effectuer plusieurs opérations d’affilée sur une structure via ses méthodes, il faut ajouter des.borrow()
éphémères à chaque ligne. Ce n’est pas maintenable.
Les autres pistes auxquelles j’ai pensées :
- remplacer le
Vec
par unHashMap
avec des IDs uniques à la place des références. On perd tout l’intérêt des références et des garanties qui vont avec, et on ajoute du code inutile à l’exécution - faire du code unsafe et manipuler des raw pointers, là encore on perd tout l’intérêt de Rust
- renvoyer un slice du
Vec
au lieu d’une référence sur élément, mais je crois que ça ne change rien au problème, c’est toujours une référence sur une partie deFoo
Une autre piste que j’ai également explorée : inverser l’appartenance. Au lieu d’avoir l’appartenance à l’intérieur et des références à l’extérieur, laisser l’appartenance à l’extérieur et avoir des références à l’intérieur garantissant que Bar
ne survit pas à Foo
. Cela implique d’utiliser des lifetimes. Mais cela me pose deux problèmes :
- Ça a sémantiquement moins de sens : conceptuellement
Bar
appartient àFoo
et l’utilisateur l’emprunte pour effectuer des opérations, mais dans l’implémentation c’est l’inverse - C’est peut-être moi, mais je trouve l’utilisation des lifetimes encore moins maintenable que des
Rc<RefCell>
… Là encore, cela implique de mettre des lifetimes absolument partout, les lifetimes c’est compliqué (pour l’ensemble de ma librairie, qui va un peu au-delà du problème que j’ai décrit ici, je n’ai pas encore réussi à aboutir à une version qui compile), et cela se propage dans toutes les structures parentes, y compris les éventuelles structures de l’utilisateur de la librairie dans lesquelles il voudrait stocker des objets de la librairie.
Tout cela semble indiquer que de manière générale, ma modélisation n’est pas adaptée. Stocker des enfants dans un Vec
et y donner accès par références pour permettre de les manipuler en-dehors de la structure ne semble pas être un pattern permis en Rust, à moins de produire, pour un problème vraiment basique, du code illisible et/ou extraordinairement compliqué à base de Rc<RefCell>
ou de lifetimes. Mais dans ce cas, comment exprimer l’appartenance et garantir la libération des ressources ?
Merci d’avance et bonne soirée !