Introduction
On m'a souvent demandé :
"Parmi toutes les fonctionnalités de Rust, si tu devais en retenir une seule qui fait sa force, ce serait quoi ?"
Eh ben ce serait ça.
Aujourd'hui, je vais vous parler de ce concept clé du langage, et pas des moindres : ☄ L'Ownership ☄. Je vais ainsi vous montrer pourquoi Rust est un langage sûr pour la mémoire.
À celles et ceux qui veulent un résumé rapide pour découvrir le langage : Rust : ce langage natif safe et puissant
Principes fondamentaux de l'ownership
L'allocation mémoire dans un programme, c'est le nerf de la guerre. Une mauvaise gestion des allocations peut entraîner une surcharge de la RAM, des pointeurs zombies 🧟♂️,... bref, des trucs vraiment pas cool et qu'on a malheureusement trop rencontré avec des langages comme le C++.
Le but de l'Ownership, c'est justement de maintenir la mémoire propre : minimiser les duplications de données, éviter les comportements imprévisibles avec les opérations de lectures/écritures, et nettoyer rapidement les données inutilisées.
Alors, comment ça marche ?
Concrètement, toutes les allocations suivent les règles fondamentales suivantes :
- Chaque allocation mémoire a un unique propriétaire
- Seul le propriétaire (ou l'emprunteur dans certains cas) a les droits de modification
- S'il n'y a plus de propriétaire, la mémoire est libérée
Ces règles sont appliquées sur l'ensemble de notre code, à chaque compilation ! Bien évidemment si une seule règle est enfreinte..., ça ne compile pas !
Notez que le concept de lifetime participe aussi activement à la protection mémoire, mais c'est un sujet à part entière que je ne traiterai pas dans cet article.
Sémantique de mouvement
Etant donné que les allocations n'ont pas toutes le même coût, le comportement du symbol =
va être différent selon le type de variable.
Par exemple, sur l'affectation d'un entier, que l'on peut considérer comme une variable "simple" :
let x = 5; // allocation à taille fixe
let y = x; // l'affectation d'un entier déclenche une copie par défaut
println!("x = {}", x); //OK
Jusque là, rien de bien nouveau. Mais si l'on essaye de faire la même chose avec un String, que l'on peut considérer comme une variable "complexe" :
let s1 = String::from("hello"); // allocation à taille dynamique, s1 est propriétaire
let s2 = s1; // s2 prend désormais l'ownership
println!("{}, world!", s1); // erreur de compilation : s1 has moved!!
"Quoi ? quoi ? 😲"
Féliciations, vous venez de rencontrer la sémantique de mouvement en Rust ! 😀
Par défaut en Rust, les variables complexes (allocation dynamique, taille variable, structures,...) ne sont pas copiées. Il y a simplement un transfert de propriété !
Attention, cela n'a rien à voir avec les références implicites d'objets comme en Java ! Ici il n'y a pas de référence.
Autre exemple avec une fonction :
fn main() {
let s1 = String::from("hello"); // allocation à taille variable, s1 est propriétaire
my_func(s1); // s1 est transféré dans le scope my_func !
println!("{}, world!", s1); // erreur de compilation : s1 has moved!!
}
fn my_func(s: String){
println!("{}, world!", s); // s est le propriétaire de "hello"
}
La bonne pratique ici est donc de passer la valeur par référence immutable :
fn main() {
let s1 = String::from("hello");
my_func(&s1); // s1 est passé par référence immutable dans le scope my_func
println!("{}, world!", s1); // OK : s1 est toujours propriétaire
}
fn my_func(s: &String){
println!("{}, world!", s); // s est une référence immutable
}
Remarque : pour les structures légères, on peut aussi implémenter automatiquement les traits Clone et Copy. Mais cela risque de dupliquer inutilement les données, ce qu'on veut justement éviter ! À utiliser consciencieusement donc.
Avec cette référence immutable, on vient d'aborder une autre notion : l'emprunt (borrowing).
Les emprunts (borrowing)
En toute franchise, il est difficile de prévoir la réaction du compilateur et de son redoutable borrow checker sans une bonne expérience, mais on pourrait résumer les règles de borrowing comme suit :
- S'il n'y a pas de référence mutable, il peut y avoir autant de références immutables que l'on souhaite sur une même variable.
- Il ne peut y avoir qu'une seule référence mutable à la fois sur une même variable => et c'est ce concept qui nous protège de bien des erreurs en écriture concurrentielle ! Et directement à la compilation ! ✌
C'est là que les choses peuvent se compliquer un peu... mais c'est pour notre bien !
Observons quelques exemples :
let mut s = String::from("hello");
let r1 = &s; // référence immutable 1 : OK
let r2 = &s; // référence immutable 2 : OK
println!("{} and {}", r1, r2); // affiche "hello and hello"
// variables r1 et r2 ne sont plus utilisées après ce point
let r3 = &mut s; // 1 emprunt par référence mutable : no problemo
*r3 += " world"; // modification de la String d'origine par déréférencement
println!("{}", r3); // OK : affiche "hello world"
Mais si on modifiait juste la fin de cet exemple :
let mut s = String::from("hello");
// [...]
let r3 = &mut s; // 1 emprunt par référence mutable : no problemo
*r3 += " world";
println!("{}", s); // erreur de compilation : "cannot borrow `s` as immutable because it is also borrowed as mutable"
println!("{}", r3); // OK : affiche "hello world"
Impossible d'emprunter et printer s
car il est potentiellement en cours de modification par r3
! Et cela est vrai seulement car r3
est utilisé juste après dans le print
.
Si on change alors simplement l'ordre des print
s :
let mut s = String::from("hello");
// [...]
let r3 = &mut s;
*r3 += " world";
println!("{}", r3); // OK : affiche "hello world"
println!("{}", s); // OK : r3 ne peut plus être utilisé ici
Bingo ! le compilateur est rassuré 😎
Conclusion
On a vu comment étaient transmises les variables d'un contexte à un autre, et des exemples simples pour comprendre la puissance des vérifications d'ownership.
Au final, Rust révèle les erreurs potentielles à la compilation plutôt que nous laisser subir les conséquences à l'exécution, ce qui constitue une de ses plus grandes forces. Dites vous bien que si votre programme compile enfin, c'est que votre code est sûr !
On pourrait passer des journées entières à tester d'autres cas, mais c'est justement l'expérience pratique qui permet de découvrir les différentes problématiques au fil du temps. Je vous invite donc à expérimenter par vous même le borrow checker sur le Rust Playground 😉
"...et sinon, qu'en est-il des accès concurrents dans du multithreading ?"
C'est un autre sujet très intéressant que nous aborderons sans doute une prochaine fois, et vous verrez qu'avec des outils comme les Mutex<T>
et Arc<T>
, on peut aussi venir efficacement à bout de ces problématiques ! Affaire à suivre...