Propriété
En une phrase, la notion de propriété (ownership) de Rust reprend les bonnes pratiques du C++ moderne, et les intègrent pleinement au langage pour qu’elles aient une meilleure ergonomie et que les programmeurs les utilisent vraiment.
Théorie
L’édition 2011 de la norme C++ a introduit la sémantique de déplacement (move semantics), qui vise à permettre de transférer la propriété d’une ressource (par exemple une allocation mémoire) d’une région de code à une autre sans faire de copies inutiles. Mais en C++, c’est resté une fonctionnalité obscure et seulement utilisée par les experts parce que…
- Pour effectuer ce transfert, il faut utiliser des syntaxes verbeuses comme
std::move
etstd::forward
, des fonctions d’insertion spéciales sur les conteneurs commeemplace()
… et tout ceci est très déplaisant et exotique pour le programmeur habitué au C++98. - Pour qu’il y ait un bénéfice en termes de performance, le type qui est déplacé
doit supporter le déplacement explicitement. La plupart des types C++ ne le
font pas, et donc la plupart du temps utiliser
std::move
ne sert qu’à clarifier l’intention du programmeur. - Qui plus est, les compilateurs C++ vivent dans un monde de copies inutiles
depuis si longtemps que leurs optimiseurs ont été construits pour éliminer
certaines de ces copies. Cela réduit encore le nombre de situations où
std::move
a un intérêt pour les performances.
En définitif, la sémantique de déplacement C++ est donc généralement présentée
comme une optimisation de performances, mais elle n’améliore généralement pas
les performances et est trop complexe à utiliser par rapport à la copie
traditionnelle. Il est donc normal que les programmeurs C++ s’en servent peu,
hors des types qui imposent son utilisation comme std::unique_ptr
.
Face à ce triste état de fait, Rust a adopté un point de vue un peu différent :
- Si on ne veut pas que les programmeurs fassent des copies inutiles, il faut optimiser l’ergonomie pour ça : le déplacement doit être facile, la copie doit être verbeuse.
- Si on veut que le déplacement soit universellement supporté, il faut que ce
soit si simple que les types n’ont pas besoin de le supporter explicitement.
Ca pourrait être un simple
memcpy()
des octets de la valeur source vers la destination, que le compilateur sait souvent éliminer. - Pour qu’un simple
memcpy()
fonctionne comme opération de déplacement générale, y compris sur des types qui gèrent des ressources, il faut que la valeur source ne soit plus accessible après déplacement, et que son destructeur ne soit pas exécuté. Intuitivement, cela a parfaitement du sens : si un objet du monde réel a été déplacé, on ne peut plus y accéder à sa position d’origine. Et côté technique, le compilateur peut facilement imposer ces contraintes. - Il y a des types pour lesquels
memcpy()
est aussi un opérateur de copie valide, comme les entiers et les flottants par exemple. Dans ce cas, ça n’a pas tellement de sens de rendre l’original inaccessible, on peut le laisser accessible.
Pratique
A chaque fois qu’on utilise l’opérateur =
en Rust, on fait donc un
déplacement, qui est implémenté par une copie mémoire bit à bit
(parfois éliminable par le compilateur).
#![allow(unused)] fn main() { #[derive(Debug)] struct Wrapper(u32); let a = Wrapper(42); let b = a; // Déplacement de a println!("{b:?}"); }
Une donnée qui a été déplacée n’est plus accessible. Donc cette variante du code ci-dessus, où on ré-utilise la variable “a” après déplacement, ne compilera pas.
#![allow(unused)] fn main() { #[derive(Debug)] struct Wrapper(u32); let a = Wrapper(42); let b = a; println!("{a:?}"); // Erreur: Accès à une variable déplacée }
Quand une variable a été déplacée, tout se passe comme si elle n’existait plus, et son destructeur n’est notamment pas exécuté. C’est le destructeur de la donnée déplacée qui s’exécutera plus tard, quand son heure sera venue.
#![allow(unused)] fn main() { #[derive(Debug)] struct Wrapper(u32); impl Drop for Wrapper { fn drop(&mut self) { println!("Destruction d'un Wrapper"); } } let b; { let a = Wrapper(42); b = a; println!("Sortie du scope de a"); }; println!("Sortie du scope de b"); }
Les types peuvent implémenter un opérateur de copie explicite via le trait
Clone
, qui a une syntaxe plus verbeuse que le déplacement pour en décourager
l’utilisation abusive :
#![allow(unused)] fn main() { #[derive(Clone, Debug)] struct Wrapper(u32); let a = Wrapper(42); let b = a.clone(); // Copie explicite de a println!("{a:?} {b:?}"); }
Pour les types primitifs du langage et les types structurés qui ne contiennent
que des valeurs de ce type, la copie bit à bit effectuée par le déplacement est
équivalente à la copie explicite effectuée par Clone
au niveau machine. Ca
n’a donc pas de sens de différencier ces deux types de copie, et on peut
implémenter le trait Copy
qui supprime la sémantique de déplacement pour ce
type et garde l’original accessible après affectation :
fn main() { // Notez que l'implémentation de Copy nécessite celle de Clone #[derive(Clone, Copy, Debug)] struct Wrapper(u32); let a = Wrapper(42); let b = a; // Copie implicite de a println!("{a:?} {b:?}"); }
Copy
n’est pas implémenté automatiquement parce que sa présence contraint
l’implémentation : un type public d’une bibliothèque qui implémente Copy
ne
peut plus être modifié en ajoutant des membres qui n’implémentent pas Copy
,
sans supprimer l’implémentation Copy
et donc briser la compatibilité avec
tous les utilisateurs de la bibliothèque qui font des copies implicites.
N’hésitez pas à jouer un peu avec l’exemple de code éditable ci-dessus pour vous
assurer que vous avez bien compris le concept de propriété en Rust. Pouvez-vous
prédire ce qui va se passer si on ajoute un destructeur qui affiche du texte à
la version de Wrapper
qui implémente Copy
?