Contactez-nous

Introduction pratique à l'ownership

Découvrez le concept fondamental de l'ownership en Rust : les trois règles, la différence entre move et copy, et l'emprunt avec les références &T et &mut T.

L'ownership : la pierre angulaire de la sécurité et de la performance en Rust

L'un des concepts les plus distinctifs et les plus importants de Rust est son système d'ownership (ou possession). C'est ce système qui permet à Rust de garantir la sécurité de la mémoire (absence de null pointers dereferencing, de data races, etc.) sans avoir besoin d'un garbage collector (ramasse-miettes). Comprendre l'ownership est crucial pour tout développeur Rust, car il influence profondément la manière dont vous écrivez et structurez votre code.

Ce chapitre a pour but de vous fournir une introduction pratique à l'ownership. Nous commencerons par énoncer les trois règles fondamentales qui régissent ce système. Ensuite, nous explorerons les implications de ces règles, notamment la distinction entre le déplacement (move) et la copie (copy) des données. Enfin, nous introduirons le concept d'emprunt (borrowing) à travers les références, qui permet d'accéder à des données sans en prendre possession.

Bien que l'ownership puisse sembler intimidant au premier abord, une fois maîtrisé, il devient un allié puissant qui vous guide vers l'écriture de code sûr et performant. Le compilateur Rust, avec ses messages d'erreur souvent très pédagogiques, joue un rôle clé pour vous aider à respecter les règles de l'ownership.

Les trois règles de l'ownership (possession)

Le système d'ownership de Rust est basé sur un ensemble de règles simples mais strictes que le compilateur vérifie au moment de la compilation. Si l'une de ces règles est enfreinte, le programme ne compilera pas. Voici ces trois règles fondamentales :

  1. Chaque valeur en Rust a une variable qui est son propriétaire (owner).
  2. Il ne peut y avoir qu'un seul propriétaire à la fois.
  3. Lorsque le propriétaire sort de la portée (scope), la valeur est automatiquement détruite (dropped).

Ces règles s'appliquent principalement aux données stockées sur le tas (heap), comme le contenu d'une `String` ou d'un `Vec<T>`. Pour les données stockées sur la pile (stack), comme les types scalaires (entiers, flottants, booléens, caractères) ou les tuples/tableaux de types scalaires, la gestion est souvent plus simple car ces types implémentent généralement le trait `Copy` (que nous verrons bientôt).

Illustrons avec un exemple simple impliquant la portée :

fn main() { // Début de la portée de `main`
    {
        // `s` n'est pas valide ici, elle n'a pas encore été déclarée
        let s = String::from("hello"); // `s` est valide à partir de ce point
                                      // `s` est le propriétaire de la chaîne "hello" allouée sur le tas
        println!("Dans la portée interne : {}", s);
        // faire des choses avec `s`
    } // Cette portée est maintenant terminée, et `s` n'est plus valide.
      // Rust appelle automatiquement la fonction `drop` pour `s` ici,
      // libérant la mémoire de la String sur le tas.

    // println!("Après la portée interne : {}", s); // ERREUR! `s` n'est plus valide ici.

    let x = 5; // `x` est un i32, stocké sur la pile.
               // Les règles d'ownership s'appliquent, mais la gestion est plus simple pour les types `Copy`.
    println!("x = {}", x);
} // Fin de la portée de `main`, `x` est détruit (retiré de la pile).

La troisième règle, la destruction automatique lorsque le propriétaire sort de la portée, est ce qui permet à Rust de garantir la libération de la mémoire sans garbage collector. La fonction `drop` est une méthode spéciale que les types peuvent implémenter pour spécifier le code à exécuter lorsque leur valeur est détruite.

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

La manière dont Rust gère l'assignation de valeurs d'une variable à une autre, ou le passage de valeurs à des fonctions, dépend du type de données.

Déplacement (Move) : Pour les types qui gèrent des ressources sur le tas (comme `String`, `Vec<T>`, ou des structures contenant de tels types) et qui n'implémentent pas le trait `Copy`, l'assignation ou le passage à une fonction transfère la propriété. La variable originale n'est alors plus valide. C'est ce qu'on appelle un "déplacement" (move). Cela évite les problèmes de double libération de mémoire (deux variables pensant posséder et devoir libérer la même ressource).

Exemple avec `String` (type qui se déplace) :

fn main() {
    let s1 = String::from("valeur");
    let s2 = s1; // La propriété de la chaîne est déplacée de s1 vers s2.
                 // s1 n'est plus valide ici.

    // println!("s1 = {}", s1); // ERREUR! `s1` a été déplacée (value borrowed here after move).
    println!("s2 = {}", s2); // s2 est maintenant le propriétaire et est valide.

    prend_possession(s2);
    // println!("s2 après appel de fonction = {}", s2); // ERREUR! `s2` a été déplacée dans la fonction.
}

fn prend_possession(une_string: String) { // `une_string` prend possession de la valeur.
    println!("Dans la fonction prend_possession : {}", une_string);
} // `une_string` sort de la portée, `drop` est appelée, la mémoire est libérée.
Copie (Copy) : Pour les types simples stockés entièrement sur la pile, comme les types scalaires (entiers, flottants, booléens, `char`) et les tuples ou tableaux de ces types, Rust effectue une "copie" (copy) bit à bit des données. La variable originale reste valide et utilisable après l'assignation ou le passage à une fonction. Ces types implémentent le trait `Copy`. Un type ne peut implémenter `Copy` que si tous ses composants implémentent `Copy`. Un type qui a besoin d'une logique de désallocation spéciale (implémentant `Drop`) ne peut pas implémenter `Copy`.

Exemple avec `i32` (type qui est copié) :

fn main() {
    let x = 5;    // i32, implémente Copy
    let y = x;    // `y` reçoit une copie de la valeur de `x`.
                  // `x` est toujours valide.

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

    fait_une_copie(x);
    println!("x après appel de fonction = {}", x); // `x` est toujours valide, sa valeur a été copiée.
}

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

Si un type implémente `Copy`, une variable de ce type reste valide après son assignation à une autre variable. Si un type n'implémente pas `Copy` (parce qu'il gère des ressources comme de la mémoire sur le tas, des fichiers, des sockets, etc.), Rust utilisera la sémantique de déplacement pour éviter des erreurs de gestion de ces ressources. On peut explicitement cloner une valeur pour obtenir une copie profonde des données (par exemple, `let s2 = s1.clone();` pour une `String`), mais cela a un coût en performance car cela implique une nouvelle allocation et copie des données sur le tas.

Emprunts (borrowing) : références immuables (`&T`) et mutables (`&mut T`)

Prendre possession d'une valeur à chaque fois qu'on veut l'utiliser dans une fonction peut être lourd, surtout si la fonction a aussi besoin de retourner la possession. Pour permettre d'accéder à des données sans en prendre possession, Rust introduit le concept d'emprunt (borrowing) via les références.

Une référence est comme un pointeur qui permet d'accéder à une valeur stockée ailleurs, mais avec des garanties de sécurité supplémentaires fournies par le compilateur Rust. Il existe deux types de références :

  • Références immuables (`&T`) : Permettent de lire les données mais pas de les modifier. Vous pouvez avoir plusieurs références immuables à une même donnée simultanément.
  • Références mutables (`&mut T`) : Permettent de modifier les données. Vous ne pouvez avoir qu'une seule référence mutable à une donnée particulière dans une portée donnée. De plus, si vous avez une référence mutable, vous ne pouvez pas avoir d'autres références (mutables ou immuables) à cette même donnée en même temps.

Ces règles concernant les références mutables sont cruciales pour prévenir les "data races" (conditions de concurrence sur les données) au moment de la compilation, en particulier dans du code concurrent.

Exemple avec des références :

fn main() {
    let s = String::from("monde");

    // Passer une référence immuable
    let longueur = calculer_longueur(&s); // &s crée une référence à `s`
                                          // `s` n'est pas déplacée, elle reste le propriétaire.
    println!("La longueur de '{}' est {}.", s, longueur);

    let mut s_mutable = String::from("Bonjour");
    // Passer une référence mutable
    changer_string(&mut s_mutable); // &mut s_mutable crée une référence mutable
    println!("String modifiée : {}", s_mutable);

    // Règles des références :
    let r1 = &s_mutable; // OK: référence immuable
    let r2 = &s_mutable; // OK: autre référence immuable
    // let r3 = &mut s_mutable; // ERREUR: cannot borrow `s_mutable` as mutable 
                               // because it is also borrowed as immutable (par r1 et r2)
    println!("r1={}, r2={}", r1, r2); // Les références immuables r1 et r2 sont utilisées ici.
                                      // Leur portée se termine ici si elles ne sont plus utilisées.

    // Maintenant, on peut créer une référence mutable car r1 et r2 ne sont plus en service.
    let r3 = &mut s_mutable;
    println!("r3={}", r3);
    // let r4 = &s_mutable; // ERREUR: cannot borrow `s_mutable` as immutable 
                           // because it is also borrowed as mutable (par r3)
    // let r5 = &mut s_mutable; // ERREUR: cannot borrow `s_mutable` as mutable more than once
} 

fn calculer_longueur(chaine: &String) -> usize { // `chaine` est une référence à une String
    chaine.len()
} // `chaine` sort de la portée, mais comme elle ne possède pas la valeur,
  // rien n'est détruit ici. La String originale (`s`) est toujours valide.

fn changer_string(texte: &mut String) { // `texte` est une référence mutable
    texte.push_str(", monde !");
}

Le processus de création de références est appelé "emprunt" (borrowing). Tout comme dans la vie réelle, si vous empruntez quelque chose, vous devez le rendre. Rust s'assure que les références ne "vivent" jamais plus longtemps que les données auxquelles elles pointent (c'est le rôle des durées de vie ou lifetimes, un concept que nous aborderons plus tard). Le compilateur vérifie ces règles pour prévenir les références pendantes (dangling references).

En résumé, l'ownership, avec ses concepts de déplacement, de copie, et d'emprunt via les références, est un système puissant qui permet à Rust d'atteindre la sécurité mémoire sans les coûts d'exécution d'un garbage collector. S'habituer à ces règles demande un peu de pratique, mais elles deviennent rapidement une seconde nature et conduisent à un code plus robuste et prévisible.