Ownership et Borrowing en Rust : Guide complet

Maîtrisez le système d'ownership et borrowing de Rust. Comprendre les règles de propriété, références, lifetimes et les patterns avancés de gestion mémoire.

Ownership et Borrowing en Rust - Guide complet

Le système d'ownership est ce qui différencie Rust de tous les autres langages de programmation. Cette approche unique permet de garantir la sécurité mémoire sans garbage collector, détectant les bugs à la compilation plutôt qu'à l'exécution. Ce guide approfondi explore les mécanismes d'ownership et de borrowing, des fondamentaux jusqu'aux patterns avancés utilisés en production.

Philosophie Rust

Le compilateur Rust agit comme un assistant de programmation exigeant : chaque erreur d'ownership bloquée à la compilation représente un bug potentiel évité en production.

Les trois règles fondamentales de l'ownership

Le système d'ownership repose sur trois règles simples mais strictes. Une fois ces règles intégrées, le modèle mental de Rust devient naturel et prévisible.

ownership_rules.rsrust
// Démonstration des trois règles fondamentales

fn main() {
    // Règle 1 : Chaque valeur a exactement UN propriétaire
    let s1 = String::from("hello");  // s1 est le propriétaire unique

    // Règle 2 : Il ne peut y avoir qu'un seul propriétaire à la fois
    let s2 = s1;  // Ownership transféré (move) de s1 vers s2
    // println!("{}", s1);  // ERREUR de compilation : s1 n'existe plus
    println!("s2 = {}", s2);  // Seul s2 est valide maintenant

    // Règle 3 : Quand le propriétaire quitte le scope, la valeur est drop
    {
        let s3 = String::from("temporary");
        println!("s3 dans le bloc = {}", s3);
    }  // s3 est automatiquement libéré ici (drop appelé)
    // println!("{}", s3);  // ERREUR : s3 n'existe plus
}

Ces trois règles éliminent trois catégories entières de bugs : les use-after-free, les double-free et les fuites mémoire. Le compilateur vérifie statiquement que ces règles sont respectées.

Move vs Copy : comprendre la sémantique de transfert

Le comportement lors de l'assignation dépend du type de données. Les types qui implémentent le trait Copy sont dupliqués, tandis que les autres sont déplacés (moved).

move_vs_copy.rsrust
// Distinction entre types Copy et types Move

fn main() {
    // Types Copy : valeurs stockées sur la stack, taille connue
    let x: i32 = 42;
    let y = x;  // x est COPIÉ, pas déplacé
    println!("x = {}, y = {}", x, y);  // Les deux sont valides

    // Autres types Copy : f64, bool, char, tuples de types Copy
    let point = (3.0, 4.0);
    let point_copy = point;  // Copie du tuple
    println!("Original: {:?}, Copie: {:?}", point, point_copy);

    // Types Move : valeurs sur la heap, taille dynamique
    let s1 = String::from("owned");
    let s2 = s1;  // s1 est DÉPLACÉ vers s2
    // println!("{}", s1);  // ERREUR : valeur déplacée
    println!("s2 = {}", s2);

    // Vec, HashMap, Box sont aussi des types Move
    let vec1 = vec![1, 2, 3];
    let vec2 = vec1;  // Move, pas copie
    // println!("{:?}", vec1);  // ERREUR
    println!("vec2 = {:?}", vec2);
}

// Clone explicite pour dupliquer les types Move
fn explicit_clone() {
    let original = String::from("données importantes");
    let clone = original.clone();  // Duplication explicite (coût mémoire)

    println!("Original: {}", original);  // Toujours valide
    println!("Clone: {}", clone);  // Copie indépendante
}

La distinction Move/Copy est fondamentale : elle détermine si l'assignation transfère la propriété ou crée une copie indépendante.

Quand utiliser Clone

L'appel à .clone() doit être intentionnel. Un code rempli de clones peut indiquer un problème de design. Le borrowing est souvent une meilleure solution.

Borrowing : références immutables et mutables

Le borrowing permet d'accéder à une valeur sans en prendre la propriété. C'est le mécanisme qui rend le code Rust à la fois sûr et performant.

borrowing_basics.rsrust
// Références immutables et mutables

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

    // Référence immutable : lecture seule, plusieurs autorisées
    let len = calculate_length(&s);  // Emprunt immutable
    println!("'{}' a {} caractères", s, len);  // s toujours valide

    // Plusieurs références immutables simultanées : OK
    let r1 = &s;
    let r2 = &s;
    let r3 = &s;
    println!("r1={}, r2={}, r3={}", r1, r2, r3);
}

fn calculate_length(s: &String) -> usize {
    // s est une référence, pas le propriétaire
    s.len()
}  // s sort du scope mais ne drop rien (pas propriétaire)

// Références mutables : modification autorisée
fn mutable_borrowing() {
    let mut s = String::from("hello");

    change(&mut s);  // Emprunt mutable
    println!("Après modification: {}", s);
}

fn change(s: &mut String) {
    s.push_str(", world!");  // Modification via référence mutable
}

La règle d'or du borrowing : soit plusieurs références immutables, soit une seule référence mutable, jamais les deux simultanément.

Les règles du borrow checker

Le borrow checker est le composant du compilateur qui vérifie les règles de borrowing. Comprendre ses erreurs permet de résoudre rapidement les problèmes.

borrow_checker_rules.rsrust
// Règles strictes du borrow checker

fn main() {
    // RÈGLE 1 : Pas de référence mutable avec des références immutables
    let mut s = String::from("hello");

    let r1 = &s;      // Référence immutable : OK
    let r2 = &s;      // Autre référence immutable : OK
    // let r3 = &mut s;  // ERREUR : ne peut pas emprunter mutable
    println!("{} et {}", r1, r2);

    // APRÈS utilisation de r1 et r2, elles sont "mortes"
    let r3 = &mut s;  // Maintenant OK : r1 et r2 plus utilisées
    r3.push_str(" world");
    println!("{}", r3);

    // RÈGLE 2 : Une seule référence mutable à la fois
    let mut data = String::from("exclusive");
    let ref1 = &mut data;
    // let ref2 = &mut data;  // ERREUR : déjà emprunté mutable
    ref1.push_str("!");
    println!("{}", ref1);
}

// RÈGLE 3 : Les références ne peuvent pas vivre plus longtemps que la donnée
fn dangling_reference_prevented() {
    let reference;
    {
        let s = String::from("short-lived");
        // reference = &s;  // ERREUR : s ne vit pas assez longtemps
    }
    // s est drop ici, reference serait invalide

    // Solution : déplacer la valeur hors du scope
    let owned_outside;
    {
        let s = String::from("moved out");
        owned_outside = s;  // Move, pas référence
    }
    println!("{}", owned_outside);  // OK : owned_outside est propriétaire
}

Le borrow checker utilise le concept de Non-Lexical Lifetimes (NLL) : une référence est considérée active uniquement jusqu'à sa dernière utilisation, pas jusqu'à la fin du scope.

Prêt à réussir tes entretiens Rust ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Lifetimes : annoter la durée de vie des références

Les lifetimes sont des annotations qui aident le compilateur à vérifier que les références restent valides. La plupart du temps, elles sont inférées automatiquement.

lifetimes_explained.rsrust
// Annotations de durée de vie explicites

// Sans annotation : le compilateur infère les lifetimes
fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(i) => &s[..i],
        None => s,
    }
}

// Avec annotation explicite : même fonction
fn first_word_explicit<'a>(s: &'a str) -> &'a str {
    // 'a signifie : la référence retournée vit aussi longtemps que l'input
    match s.find(' ') {
        Some(i) => &s[..i],
        None => s,
    }
}

// Quand les annotations sont nécessaires : plusieurs références
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    // Le compilateur ne peut pas deviner quelle référence est retournée
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("long string");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(&string1, &string2);
        println!("Le plus long: {}", result);  // OK ici
    }
    // println!("{}", result);  // ERREUR si décommenté : string2 drop
}

Les lifetimes ne changent pas la durée de vie des données, elles décrivent les relations entre les durées de vie de différentes références.

Lifetimes dans les structures

Quand une structure contient des références, les lifetimes doivent être annotées pour garantir que la structure ne survit pas aux données référencées.

struct_lifetimes.rsrust
// Structures contenant des références

// Structure avec référence : lifetime obligatoire
struct ImportantExcerpt<'a> {
    part: &'a str,  // Cette référence doit vivre au moins aussi longtemps que la struct
}

impl<'a> ImportantExcerpt<'a> {
    // Méthode retournant une référence avec le même lifetime
    fn level(&self) -> i32 {
        3
    }

    // Règle d'élision : &self implique le lifetime de sortie
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention: {}", announcement);
        self.part  // Retourne avec le lifetime 'a de self
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();

    let excerpt = ImportantExcerpt {
        part: first_sentence,  // OK : novel vit plus longtemps que excerpt
    };

    println!("Extrait: {}", excerpt.part);
    println!("Niveau: {}", excerpt.level());
}

// Static lifetime : référence valide pour toute la durée du programme
fn static_lifetime_example() {
    let s: &'static str = "Cette string est dans le binaire";
    // Les littéraux string ont toujours un lifetime 'static
    println!("{}", s);
}

Les règles d'élision de lifetime permettent souvent d'omettre les annotations dans les cas courants, rendant le code plus lisible.

Patterns avancés : interior mutability

Parfois, la mutabilité doit être vérifiée à l'exécution plutôt qu'à la compilation. Rust fournit des types pour ce pattern : RefCell et Cell.

interior_mutability.rsrust
// Mutabilité intérieure avec RefCell et Cell

use std::cell::{Cell, RefCell};

// Cell : pour types Copy, remplace la valeur entière
struct Counter {
    count: Cell<u32>,  // Mutable malgré &self
}

impl Counter {
    fn new() -> Counter {
        Counter { count: Cell::new(0) }
    }

    fn increment(&self) {
        // Modification via référence immutable !
        self.count.set(self.count.get() + 1);
    }

    fn get(&self) -> u32 {
        self.count.get()
    }
}

// RefCell : pour types non-Copy, vérifie à l'exécution
struct CachedValue {
    value: RefCell<Option<String>>,
}

impl CachedValue {
    fn new() -> CachedValue {
        CachedValue { value: RefCell::new(None) }
    }

    fn get_or_compute(&self, compute: impl FnOnce() -> String) -> String {
        // borrow() pour lecture, borrow_mut() pour écriture
        if self.value.borrow().is_none() {
            *self.value.borrow_mut() = Some(compute());
        }
        self.value.borrow().as_ref().unwrap().clone()
    }
}

fn main() {
    let counter = Counter::new();
    counter.increment();
    counter.increment();
    println!("Compteur: {}", counter.get());  // 2

    let cache = CachedValue::new();
    let result = cache.get_or_compute(|| {
        println!("Calcul coûteux...");
        String::from("résultat")
    });
    println!("Valeur: {}", result);

    // Deuxième appel : pas de recalcul
    let result2 = cache.get_or_compute(|| String::from("jamais exécuté"));
    println!("Cache hit: {}", result2);
}

RefCell et Cell déplacent la vérification du borrow checking à l'exécution. Une violation des règles provoque un panic plutôt qu'une erreur de compilation.

Attention aux panics

RefCell::borrow_mut() provoque un panic si la valeur est déjà empruntée. Utiliser try_borrow_mut() pour une gestion d'erreur explicite.

Smart pointers et ownership

Les smart pointers comme Box, Rc et Arc offrent différentes stratégies de propriété pour des cas d'usage spécifiques.

smart_pointers.rsrust
// Box, Rc et Arc pour différents patterns d'ownership

use std::rc::Rc;
use std::sync::Arc;
use std::thread;

// Box : propriétaire unique, donnée sur la heap
fn box_example() {
    let boxed = Box::new(vec![1, 2, 3, 4, 5]);
    println!("Boxed vec: {:?}", boxed);
    // Utile pour : types récursifs, grands objets, trait objects
}

// Rc : compteur de références, plusieurs propriétaires (single-thread)
fn rc_example() {
    let data = Rc::new(String::from("shared data"));

    let clone1 = Rc::clone(&data);  // Incrémente le compteur
    let clone2 = Rc::clone(&data);

    println!("Compteur: {}", Rc::strong_count(&data));  // 3
    println!("Tous partagent: {}, {}, {}", data, clone1, clone2);
}  // Libéré quand le compteur atteint 0

// Arc : Rc thread-safe (Atomic Reference Counting)
fn arc_example() {
    let data = Arc::new(vec![1, 2, 3]);

    let handles: Vec<_> = (0..3).map(|i| {
        let data_clone = Arc::clone(&data);
        thread::spawn(move || {
            println!("Thread {}: {:?}", i, data_clone);
        })
    }).collect();

    for handle in handles {
        handle.join().unwrap();
    }
}

fn main() {
    box_example();
    rc_example();
    arc_example();
}

Le choix du smart pointer dépend du pattern de propriété : unique (Box), partagé single-thread (Rc), ou partagé multi-thread (Arc).

Patterns d'ownership en pratique

Voici des patterns courants pour structurer le code autour du système d'ownership.

ownership_patterns.rsrust
// Patterns pratiques pour la gestion de l'ownership

// Pattern 1 : Builder pattern avec ownership chaîné
struct RequestBuilder {
    url: String,
    headers: Vec<(String, String)>,
    timeout: Option<u64>,
}

impl RequestBuilder {
    fn new(url: &str) -> Self {
        RequestBuilder {
            url: url.to_string(),
            headers: Vec::new(),
            timeout: None,
        }
    }

    // Consomme self et retourne le nouveau self
    fn header(mut self, key: &str, value: &str) -> Self {
        self.headers.push((key.to_string(), value.to_string()));
        self  // Retourne l'ownership
    }

    fn timeout(mut self, seconds: u64) -> Self {
        self.timeout = Some(seconds);
        self
    }

    fn build(self) -> Request {
        Request {
            url: self.url,
            headers: self.headers,
            timeout: self.timeout.unwrap_or(30),
        }
    }
}

struct Request {
    url: String,
    headers: Vec<(String, String)>,
    timeout: u64,
}

// Pattern 2 : Cow (Copy-on-Write) pour éviter les allocations
use std::borrow::Cow;

fn process_text(input: &str) -> Cow<str> {
    if input.contains("REPLACE") {
        // Allocation seulement si modification nécessaire
        Cow::Owned(input.replace("REPLACE", "NOUVEAU"))
    } else {
        // Pas d'allocation, retourne une référence
        Cow::Borrowed(input)
    }
}

// Pattern 3 : Take pour extraire d'une Option
fn extract_value(data: &mut Option<String>) -> String {
    data.take().unwrap_or_else(|| String::from("default"))
    // take() remplace par None et retourne l'ownership de la valeur
}

fn main() {
    // Builder pattern
    let request = RequestBuilder::new("https://api.example.com")
        .header("Authorization", "Bearer token")
        .header("Content-Type", "application/json")
        .timeout(60)
        .build();

    println!("URL: {}, Timeout: {}s", request.url, request.timeout);

    // Cow pattern
    let text1 = process_text("hello world");  // Pas d'allocation
    let text2 = process_text("hello REPLACE");  // Allocation
    println!("{} | {}", text1, text2);

    // Take pattern
    let mut optional = Some(String::from("extracted"));
    let value = extract_value(&mut optional);
    println!("Valeur: {}, Option: {:?}", value, optional);  // None
}

Ces patterns exploitent le système d'ownership pour créer des APIs ergonomiques et performantes.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Conclusion

Le système d'ownership et borrowing de Rust représente un changement de paradigme dans la gestion mémoire. Une fois maîtrisé, il devient un allié puissant pour écrire du code à la fois performant et sûr.

Points clés à retenir :

✅ Trois règles d'ownership : un propriétaire unique, transfert de propriété, drop automatique

✅ Borrowing : références immutables multiples OU une référence mutable exclusive

✅ Lifetimes : annoter les relations entre durées de vie des références

✅ Interior mutability : RefCell et Cell pour la mutabilité vérifiée à l'exécution

✅ Smart pointers : Box (unique), Rc (partagé), Arc (thread-safe)

✅ Patterns pratiques : Builder, Cow, Take pour des APIs idiomatiques

Le borrow checker peut sembler strict au début, mais chaque erreur qu'il signale représente un bug potentiel évité. Avec la pratique, penser en termes d'ownership devient naturel et améliore la qualité du code dans tous les langages.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#rust
#ownership
#borrowing
#memory management
#systems programming

Partager

Articles similaires