Contactez-nous

Déplacement (move) vs. copie (copy) des données

Explorez la sémantique de déplacement (move) pour les types propriétaires (String, Vec) et la sémantique de copie (copy) pour les types Copy (i32, bool) en Rust.

Move vs. Copy : comment Rust gère le transfert de valeurs

Dans le cadre du système d'ownership de Rust, la manière dont les valeurs sont transférées entre variables, ou passées à des fonctions, est cruciale. Rust distingue deux comportements principaux : le déplacement (move) et la copie (copy). Le comportement adopté dépend du type de la donnée concernée et de sa manière de gérer les ressources, notamment la mémoire.

Ce sous-chapitre explore en détail ces deux sémantiques. Nous verrons que le déplacement est le comportement par défaut pour les types qui gèrent des ressources sur le tas (comme `String` ou `Vec<T>`), transférant la propriété et invalidant la variable source. A l'inverse, les types simples stockés entièrement sur la pile (comme les entiers ou les booléens) peuvent implémenter le trait `Copy`, ce qui signifie que leurs valeurs sont dupliquées bit à bit, laissant la variable source valide.

Comprendre cette distinction est fondamental pour éviter les erreurs de compilation liées à l'utilisation de valeurs déplacées et pour optimiser les performances en sachant quand une copie profonde des données (via le trait `Clone`) pourrait être nécessaire par rapport à une simple copie sur la pile ou un déplacement.

La sémantique de déplacement (move) : transférer la propriété

Par défaut, lorsque vous assignez une variable à une autre, ou lorsque vous passez une variable à une fonction, si le type de cette variable gère des ressources sur le tas (par exemple, une `String` qui alloue de la mémoire pour son contenu textuel), Rust effectue un déplacement (move). Cela signifie que la propriété de la ressource (les données sur le tas) est transférée à la nouvelle variable ou au paramètre de la fonction. La variable d'origine n'est alors plus considérée comme propriétaire et devient invalide pour accéder à ces données.

Ce mécanisme est essentiel pour garantir la règle "un seul propriétaire à la fois" et pour prévenir les erreurs de double libération de mémoire (deux variables essayant de libérer la même ressource).

Illustrons avec le type `String` :

fn main() {
    let s1 = String::from("hello"); // s1 possède les données "hello" sur le tas.

    let s2 = s1; // Déplacement ! La propriété des données de "hello" est transférée de s1 à s2.
                 // s1 n'est plus valide. Tenter d'utiliser s1 après cela provoquera une erreur de compilation.

    // println!("s1 = {}", s1); // ERREUR: value used here after move
    println!("s2 = {}", s2); // OK, s2 est le nouveau propriétaire.

    let s3 = String::from("world");
    prend_possession(s3);
    // println!("s3 = {}", s3); // ERREUR: s3 a été déplacée dans la fonction `prend_possession`.
}

fn prend_possession(une_chaine: String) { // `une_chaine` prend possession de la valeur passée.
    println!("Dans la fonction, une_chaine = {}", une_chaine);
} // Ici, `une_chaine` sort de la portée, et les données qu'elle possédait sont libérées (drop).

Techniquement, lors d'un déplacement, Rust copie les données de la variable `s1` qui sont sur la pile (le pointeur vers les données sur le tas, la longueur et la capacité de la `String`) vers la variable `s2`. Cependant, il ne copie pas les données sur le tas elles-mêmes. Puis, il invalide `s1` pour s'assurer qu'elle ne tentera pas de libérer les données sur le tas lorsque `s1` sortira de portée. C'est un transfert de responsabilité peu coûteux.

Le déplacement est le comportement par défaut pour tous les types qui n'implémentent pas le trait `Copy`. Cela inclut la plupart des types qui gèrent des allocations dynamiques ou d'autres ressources système (fichiers, sockets, etc.).

La sémantique de copie (copy) : dupliquer les données sur la pile

Pour certains types, principalement ceux dont les données sont entièrement stockées sur la pile et dont la copie est peu coûteuse (une simple copie bit à bit), Rust permet une sémantique de copie (copy) au lieu d'un déplacement. Lorsqu'une variable d'un type `Copy` est assignée à une autre, ou passée à une fonction, ses données sont dupliquées. La variable d'origine reste valide et utilisable.

Les types qui peuvent être `Copy` sont ceux qui implémentent le trait `Copy`. Ce trait est un "trait marqueur" (marker trait), ce qui signifie qu'il n'ajoute aucun comportement nouveau mais signale au compilateur que les valeurs de ce type peuvent être dupliquées trivialement.

Les types scalaires comme les entiers (`i32`, `u8`, etc.), les flottants (`f32`, `f64`), les booléens (`bool`), et les caractères (`char`) implémentent tous `Copy`. Un tuple ou un tableau peut également être `Copy` si et seulement si tous les types qu'il contient sont eux-mêmes `Copy`.

Illustrons avec `i32` :

fn main() {
    let x = 5; // x est un i32, qui est Copy.
    let y = x; // Copie ! Une copie de la valeur de x (5) est assignée à y.
               // x reste valide et utilisable.

    println!("x = {}, y = {}", x, y); // Affiche "x = 5, y = 5"

    let z = 10;
    fait_une_copie(z);
    println!("z après l'appel de fonction = {}", z); // z est toujours 10, sa valeur a été copiée dans la fonction.
}

fn fait_une_copie(un_nombre: i32) { // `un_nombre` reçoit une copie de la valeur passée.
    println!("Dans la fonction, un_nombre = {}", un_nombre);
} // `un_nombre` (la copie) sort de la portée et est retiré de la pile.

Un type ne peut pas implémenter `Copy` s'il implémente le trait `Drop`. Le trait `Drop` est utilisé par les types qui ont besoin d'une logique de nettoyage spéciale lorsque leur valeur sort de la portée (par exemple, pour libérer de la mémoire sur le tas ou fermer un fichier). Si un type implémentait à la fois `Copy` et `Drop`, cela pourrait conduire à des situations complexes où les copies et l'original essaieraient tous de libérer la même ressource, ou à une sémantique de libération peu claire. Donc, Rust interdit cette combinaison : un type est soit `Copy` (copie triviale, pas de `Drop`), soit il a une sémantique de déplacement (et peut implémenter `Drop`).

Le trait `Clone` : créer des copies profondes explicites

Que faire si vous avez un type qui gère des données sur le tas (comme `String`) et que vous voulez réellement créer une copie complète et indépendante de ces données, au lieu de simplement déplacer la propriété ?

Pour cela, Rust fournit le trait `Clone`. La plupart des types qui gèrent des ressources (et donc ne sont pas `Copy`) implémentent `Clone` pour permettre la création de duplicatas profonds de leurs données. L'appel à la méthode `.clone()` est explicite et peut potentiellement être une opération coûteuse car elle implique souvent une nouvelle allocation mémoire et la copie des données.

Exemple avec `String::clone()` :

fn main() {
    let s1 = String::from("original");

    // Si nous voulons une copie indépendante de s1, et non un déplacement :
    let s2 = s1.clone(); // Appel explicite à .clone()
                         // s2 est maintenant une nouvelle String avec ses propres données sur le tas,
                         // qui sont une copie des données de s1.
                         // s1 reste valide car nous avons cloné, pas déplacé.

    println!("s1 = '{}', s2 = '{}'", s1, s2);

    let mut s3 = s1.clone(); // Une autre copie
    s3.push_str(" modifié");
    println!("s1 = '{}' (inchangé), s3 = '{}' (modifié)", s1, s3);
}

Il est important de distinguer `Copy` et `Clone` :

  • `Copy` : Implicite, pour les types où la copie est triviale (copie bit à bit sur la pile). La variable source reste valide. Aucun code personnalisé n'est exécuté.
  • `Clone` : Explicite (via l'appel à `.clone()`). Permet de dupliquer des données, y compris celles sur le tas. Peut être coûteux. La variable source reste valide. Le code de la méthode `clone` est exécuté.

Tous les types qui sont `Copy` sont également `Clone` (la méthode `clone` pour un type `Copy` effectue simplement la copie bit à bit). Cependant, l'inverse n'est pas vrai : de nombreux types sont `Clone` mais pas `Copy` (par exemple, `String`, `Vec<T>`).

En résumé, la sémantique de déplacement est la stratégie par défaut de Rust pour assurer la sécurité de la gestion des ressources sans garbage collector. La sémantique de copie est une optimisation pour les types simples. Le clonage explicite permet de dupliquer des données lorsque c'est nécessaire. Comprendre ces mécanismes vous aide à écrire du code Rust qui est non seulement sûr mais aussi performant, en évitant des allocations et des copies inutiles lorsque des déplacements ou des emprunts (que nous verrons ensuite) suffisent.