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 et std::forward, des fonctions d’insertion spéciales sur les conteneurs comme emplace()… 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 ?