II. Spécificités de Rust
5. Propriété (ou ownership)
Jusqu'à présent, de temps à autre, on utilisait le caractère '&' devant des paramètres de fonctions sans que je vous explique à quoi ça servait. Exemple :
Runfn ajouter_valeur(v: &mut Vec<i32>, valeur: i32) {
v.push(valeur);
}
struct X {
v: i32,
}
impl X {
fn addition(&self, a: i32) -> i32 {
self.v + a
}
}
Il s'agit de variables passées par référence. En Rust, cela a une grande importance. Il faut savoir que chaque variable ne peut avoir qu'un seul "propriétaire" à la fois, ce qui est l'une des grandes forces de ce langage. Par exemple :
Runfn une_fonction(v: Vec<i32>) {
// le contenu n'a pas d'importance
}
let v = vec![5, 12];
une_fonction(v);
println!("{}", v[0]); // error ! "use of moved value"
Un autre exemple encore plus simple :
Runlet original = vec![1, 2, 3];
let non_original = original;
println!("original[0] is: {}", original[0]); // même erreur
"Mais c'est complètement idiot ! Comment on fait pour modifier la variable depuis plusieurs endroits ?!"
C'est justement pour éviter ça que ce système d'ownership (propriété donc) existe. C'est ce qui vous posera sans aucun doute le plus de problème quand vous développerez vos premiers programmes.
Dans un chapitre précédent, je vous ai parlé des traits. Hé bien sachez que l'un d'entre eux s'appelle Copy et permet de copier (sans rire !) un type sans perdre la propriété de l'original. Tous les types de "base" (aussi appelés primitifs) (i8, i16, i32, isize, f32, etc...) l'implémentent. Ce code est donc tout à fait valide :
Runlet original: i32 = 8;
let copy = original;
println!("{}", original);
Cependant Copy ne peut être implémenté que sur des types primitifs ou des structures ne contenant que des types primitifs, ce qui nous limite beaucoup. Un autre trait appelé Clone permet lui de dupliquer des types "plus lourds". Ce n'est cependant pas toujours une bonne idée de dupliquer un type. Revenons donc à notre situation initiale.
Il est possible de "contourner" ce problème de copie de la manière suivante :
Runfn fonction(v: Vec<i32>) -> Vec<i32> {
v // on "rend" la propriété de l'objet en le renvoyant
}
fn main() {
let v = vec![5, 12];
let v = fonction(v); // et on la re-récupère ici
println!("{}", v[0]);
}
Bof, n'est-ce pas ? Et encore c'est un code simple. Imaginez quelque chose comme ça :
Runfn fonction(
v1: Vec<i32>,
v2: Vec<i32>,
v3: Vec<i32>,
v4: Vec<i32>,
) -> (Vec<i32>, Vec<i32>, Vec<i32>, Vec<i32>) {
(v1, v2, v3, v4)
}
let v1 = vec![5, 12, 3];
let v2 = vec![5, 12, 3];
let v3 = vec![5, 12, 3];
let v4 = vec![5, 12, 3];
let (v1, v2, v3, v4) = fonction(v1, v2, v3, v4);
Ça devient difficile de suivre, hein ? Vous l'aurez donc compris, ce n'est pas du tout une bonne idée.
"Mais alors comment on fait ? On implémente le trait Clone sur tous les types ?"
Non, et heureusement ! La copie de certains types pourrait avoir un lourd impact sur les performances de votre programme, tandis que d'autres ne peuvent tout simplement pas être copiés ! C'est ici que les références rentrent en jeu.
Jusqu'à présent, vous vous en êtes servies sans que je vous explique à quoi elles servaient. Je pense que maintenant vous vous en doutez. Ajoutons une référence à notre premier exemple :
Runfn une_fonction(v: &Vec<i32>) {
// le contenu n'a pas d'importance
}
let v = vec![5, 12];
une_fonction(&v);
println!("{}", v[0]); // Pas de souci !
On peut donc dire que les références permettent d'emprunter une variable sans en prendre la propriété, et c'est très important de s'en souvenir !
Prenons un exemple : quand vous indiquez à quelqu'un où vous vivez, vous n'allez pas copier votre maison/appartement mais juste donner son adresse. Hé bien ici, c'est la même chose !
Tout comme les variables, les références aussi peuvent être mutables. "&" signifie référence constante et "&mut" signifie référence mutable. Il y a cependant plusieurs choses à savoir :
- Une référence ne peut pas "vivre" plus longtemps que la variable qu'elle référence.
- On peut avoir autant de référence constante que l'on veut sur une variable.
- On ne peut avoir qu'une seule référence mutable sur une variable.
- On ne peut avoir une référence mutable que sur une variable mutable.
- On ne peut avoir une référence constante et une référence mutable en même temps sur une variable.
Pour bien comprendre cela, il faut bien avoir en tête comment la durée de vie d'une variable fonctionne :
Runfn func() {
// On crée une variable.
let mut var = 10i32;
// On fait des opérations dessus.
var += 12;
var *= 2;
// ...
// Quand on sort de la fonction, var n'existe plus.
}
fn main() {
// Cette variable n'a rien à voir avec celle dans la fonction func.
let var: i32 = 12;
let var2: f32 = 0;
func();
// On quitte la fonction, var et var2 n'existent plus.
}
Ainsi, ce code devient invalide :
Runfn main() {
let reference: &i32;
{
let x = 5;
reference = &x;
} // `x` n'existe plus ici, rendant `reference` invalide
println!("{}", reference); // On ne peut donc pas s'en servir ici.
}
Ici, le compilateur vous dira que la variable x ne vit pas assez longtemps, elle est donc détruite en premier, rendant reference invalide ! Pour pallier à ce problème, rien de bien compliqué :
Runfn main() {
let x = 5;
let reference: &i32 = &x;
println!("{}", reference);
}
Maintenant vous savez ce qui se cache derrière les références et vous avez des notions concernant la durée de vie des variables. Il est temps de voir ce deuxième point un peu plus en détail.
Pour les plus curieux : toutes ses règles sont appliquées par ce que l'on appelle le "borrow checker" (le "vérifieur d'emprunt" en français) dans le compilateur de Rust.