Ownership dans Rust: sécurité mémoire sans GC

1. Vue d’ensemble

L’ownership est le mécanisme fondamental qui permet à Rust d’assurer la sécurité mémoire sans garbage collector (GC) comme Java ou Go, et sans que vous ayez besoin de malloc/free comme en C. Cet article explique les trois règles d’ownership, les concepts de borrowing, de lifetime, et les erreurs compile-time courantes. Après la lecture, vous comprendrez pourquoi Rust est « exigeant » lors de la compilation, mais en échange, vous n’aurez pas de pointeur dangling, double free ou data race à l’exécution.

2. Le problème: gérer la mémoire sans GC

Les langages comme Java utilisent un GC pour nettoyer — pratique mais coûteux en CPU et provoque des pauses imprévisibles. C/C++ vous donne le contrôle total mais c’est facile de faire un double free, use-after-free, memory leak. Rust choisit une troisième voie: le compilateur suit qui « possède » quelles données, et libère automatiquement quand le propriétaire sort du scope. Pas de coût runtime, pas besoin d’un thread GC.

fn main() {
    let s = String::from("Non La Dev");
    println!("{s}");
} // s quitte le scope ici, Rust appelle automatiquement drop(s) pour libérer la heap memory

La ligne String::from alloue une région en heap. Quand la variable s sort de la portée }, Rust insère automatiquement un appel à drop — similaire à finally { free(s) } mais généré par le compilateur, vous n’avez pas à l’écrire.

3. Les trois règles d’Ownership

Rust a exactement trois règles, les apprendre par cœur c’est maîtriser 80% du mécanisme:

  1. Chaque valeur dans Rust a une variable appelée son owner.
  2. À un moment donné, il n’y a qu’un seul owner.
  3. Quand l’owner sort du scope, la valeur est droppée (libérée).

3.1. Move semantics

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // l'ownership « move » de s1 vers s2

    println!("{s2}");
    // println!("{s1}"); // erreur compile: s1 a été déplacé
}

Contrairement à Java (assigner une variable copie juste la référence, les deux pointent le même objet, le GC s’occupe du reste), Rust considère let s2 = s1 comme un transfert d’ownership. Après cette ligne, s1 n’est plus valide. Si vous décommentez la dernière ligne, vous aurez une erreur:

error[E0382]: borrow of moved value: `s1`
  --> src/main.rs:5:20
   |
2  |     let s1 = String::from("hello");
   |         -- move occurs because `s1` has type `String`
3  |     let s2 = s1;
   |              -- value moved here
4  |
5  |     println!("{s1}");
   |                ^^^^ value borrowed here after move

Cela prévient complètement l’erreur de double free: si s1 et s2 pointaient tous les deux vers la même heap, quand les deux sortent du scope, Rust essayerait de drop deux fois — ça crasherait. Le compilateur bloque cela dès la compilation.

3.2. Clone quand vous voulez garder les deux

Si vous avez vraiment besoin de deux copies indépendantes, appelez .clone() explicitement:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // deep copy de la région heap

    println!("{s1} - {s2}"); // les deux sont valides
}

.clone() alloue une nouvelle région heap et copie le contenu. Le coût runtime est explicite, et Rust force l’appel explicite pour que personne ne clone par accident une énorme structure sans le savoir — contrairement à de nombreux langages qui copient silencieusement.

3.3. Copy trait pour les types de données simples

fn main() {
    let x = 5;
    let y = x; // copie, pas un move

    println!("x = {x}, y = {y}"); // les deux peuvent encore être utilisés
}

Les types primitifs comme i32, bool, char, ou les tuples contenant uniquement ces types implémentent le trait Copy. Comme ils ont une taille fixe et résident complètement sur la stack, copier est moins cher que move donc Rust copie automatiquement au lieu d’invalider la variable originale.

Note: Un type ne peut pas implémenter à la fois Copy et Drop. Si un type a des ressources à nettoyer (heap, descripteur de fichier…), il ne peut pas être Copy.

4. Ownership lors du passage en paramètre

Passer une variable à une fonction suit aussi la move semantics exactement comme assigner une variable:

fn print_order(order: String) {
    println!("Order: {order}");
} // order est droppée ici, pas dans l'appelant

fn main() {
    let order = String::from("ORDER-001");
    print_order(order);
    // println!("{order}"); // erreur: order a été déplacée dans la fonction
}

Quand vous appelez print_order(order), l’ownership de order est transféré au paramètre de la fonction. Quand la fonction finit, la valeur est droppée à l’intérieur — main n’a plus le droit d’utiliser order. C’est pourquoi le borrowing (section suivante) est presque toujours nécessaire quand vous voulez juste « emprunter en lecture » les données sans transférer l’ownership.

5. Borrowing: emprunter sans prendre l’ownership

5.1. Immutable reference (&T)

fn print_order(order: &String) {
    println!("Order: {order}");
}

fn main() {
    let order = String::from("ORDER-001");
    print_order(&order); // emprunter, pas de move
    println!("Peut encore être utilisée: {order}");
}

Le symbole & crée une référence — emprunter les données sans prendre l’ownership. La fonction print_order lira seulement, elle ne possède pas order. Après l’exécution de la fonction, main a toujours le droit complet d’utiliser la variable originale.

5.2. Mutable reference (&mut T)

fn add_discount(total: &mut f64) {
    *total -= 10.0;
}

fn main() {
    let mut total = 100.0;
    add_discount(&mut total);
    println!("Total: {total}"); // Total: 90
}

&mut permet d’emprunter pour modifier. Pour pouvoir l’utiliser, la variable originale doit être déclarée mut. Le symbole * dereference et assigne une nouvelle valeur à la région mémoire pointée par la référence.

5.3. Les règles du borrowing

Rust applique deux lois à la compilation, collectivement appelées le borrow checker:

  • À un moment donné, soit il y a une seule mutable reference, soit plusieurs immutable reference — jamais mélangées.
  • Une référence ne vit jamais plus longtemps que la donnée originale (évite les références dangling).
fn main() {
    let mut total = 100.0;

    let r1 = &total;
    let r2 = &total; // OK: plusieurs immutable reference en même temps
    println!("{r1} {r2}");

    let r3 = &mut total; // erreur si r1, r2 sont encore utilisés après ce point
    println!("{r3}");
}

Si r1 et r2 ne sont plus utilisés après le premier println!, le compilateur (grâce aux Non-Lexical Lifetimes) permet à r3 d’emprunter en mutable en dessous. Mais si vous essayez d’utiliser r1 après que r3 existe, vous aurez:

error[E0502]: cannot borrow `total` as mutable because it is also borrowed as immutable

Cette règle élimine complètement les data race à la compilation: impossible d’avoir quelqu’un qui lit ET quelqu’un qui écrit sur la même région mémoire en même temps.

6. Lifetime: assurer que les références ne pointent nulle part

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let order1 = String::from("ORDER-001");
    let order2 = String::from("ORDER-002-LONG");

    let result = longest(order1.as_str(), order2.as_str());
    println!("Le plus long: {result}");
}

La notation 'a est une annotation de lifetime, elle dit au compilateur: « la référence retournée vit pas plus longtemps que x ET y ». Rust ne peut pas deviner la relation vie-mort entre plusieurs références dans ce cas, donc vous devez le dire explicitement. La plupart du temps Rust déduit les lifetimes (lifetime elision) donc vous écrivez rarement 'a manuellement — c’est seulement quand une fonction prend plusieurs références et retourne l’une d’elles.

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:33
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |                                 ^ expected named lifetime parameter

Sans 'a, le compilateur pleure parce qu’il ne sait pas si la référence retournée va avec x ou y.

7. Quand vous avez besoin de plusieurs owners: Rc et RefCell

L’ownership simple à un seul propriétaire est parfois trop rigide, par exemple quand vous construisez des structures de données en arbre ou graphe où plusieurs nœuds veulent référencer la même valeur.

<!-- Pas de dépendance, Rc/RefCell sont dans std -->
use std::cell::RefCell;
use std::rc::Rc;

struct Customer {
    name: String,
    balance: RefCell<f64>,
}

fn main() {
    let customer = Rc::new(Customer {
        name: String::from("Nón Lá"),
        balance: RefCell::new(0.0),
    });

    let c1 = Rc::clone(&customer);
    let c2 = Rc::clone(&customer);

    *c1.balance.borrow_mut() += 50.0;
    *c2.balance.borrow_mut() += 30.0;

    println!("{} balance = {}", customer.name, customer.balance.borrow());
    println!("Nombre de références: {}", Rc::strong_count(&customer));
}

Rc<T> (Reference Counted) permet à plusieurs owners de pointer vers la même donnée immuable, compte les références à la runtime, et drop automatiquement quand le compte atteint 0. RefCell<T> permet l’emprunt mutable à la runtime au lieu de compile-time (interior mutability) — en échange, si vous violez les règles de borrowing (par exemple deux borrow_mut() en même temps), le programme va panic au lieu d’une erreur compile.

Nón Lá balance = 80
Nombre de références: 3

Note: Rc/RefCell sont pour single-thread uniquement. Si vous devez partager des données entre plusieurs threads, utilisez Arc (atomic reference count) avec Mutex ou RwLock. N’utilisez pas Rc<RefCell<T>> partout — ça élimine l’avantage du borrow checker qui attrape les erreurs à compile-time, qui est le plus grand atout de Rust.

8. Écrire des tests pour vérifier le comportement d’ownership

#[derive(Clone)]
struct Order {
    id: String,
    amount: f64,
}

fn apply_discount(order: &mut Order, percent: f64) {
    order.amount -= order.amount * percent / 100.0;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn should_keep_original_order_untouched_when_cloned() {
        let original = Order { id: "ORD-1".into(), amount: 100.0 };
        let mut copy = original.clone();

        apply_discount(&mut copy, 10.0);

        assert_eq!(original.amount, 100.0); // l'original reste inchangé
        assert_eq!(copy.amount, 90.0);
    }

    #[test]
    fn should_mutate_through_mutable_borrow() {
        let mut order = Order { id: "ORD-2".into(), amount: 200.0 };
        apply_discount(&mut order, 25.0);

        assert_eq!(order.amount, 150.0);
    }
}

Lancez cargo test, le résultat:

running 2 tests
test tests::should_keep_original_order_untouched_when_cloned ... ok
test tests::should_mutate_through_mutable_borrow ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured

Le premier test prouve que .clone() crée une copie indépendante — modifier copy n’affecte pas original. Le second test confirme que &mut change effectivement la donnée originale via la référence.

9. Erreurs courantes

  • Use after move: essayer d’utiliser une variable après l’avoir déplacée ailleurs (fonction, autre variable, struct). Correction: utilisez .clone() si vous avez besoin de deux copies, ou passez une référence (&T) à la place.
  • Cannot borrow as mutable more than once: vous tenez une &mut mais essayez de créer une autre & ou &mut en même temps. Réduisez le scope de l’ancienne référence (utiliser un bloc {} ou laisser-la cesser d’exister plus tôt) pour résoudre.
  • Dangling reference: retourner une référence vers une variable locale qui sera droppée quand la fonction termine. Rust bloque complètement cette erreur à compile-time — vous verrez un missing lifetime specifier ou borrowed value does not live long enough.
  • Abuser de .clone() pour contourner le borrow checker: valide à la compilation mais coûteux en performance inutile. Traitez chaque .clone() comme une question: « ai-je vraiment besoin d’une copie indépendante, ou juste en train d’éviter de comprendre l’ownership? »

10. Quand l’ownership strict n’est pas nécessaire

Si vous écrivez un script court, un prototype rapide, ou des données toujours petites sans exigence de performance/sécurité stricte, le coût d’apprentissage et de conformité au borrow checker peut ne pas en valoir la peine — un langage comme Python ou Go vous donnera une vélocité de développement plus rapide. L’ownership brille au mieux quand votre système demande une haute performance ET une sécurité mémoire stricte: systèmes d’exploitation, embedded, moteurs de jeux, ou services backend exigeant une faible latence sans accepter de GC pause.

11. Conclusion

L’ownership est la fondation qui permet à Rust d’atteindre ce qui semblait contradictoire: la sécurité mémoire comme un langage avec GC, mais la performance du C/C++. Les trois règles owner-move-drop, le mécanisme de borrowing avec la règle une-mutable-ou-plusieurs-immutable, et les annotations de lifetime travaillent ensemble pour que le compilateur attrape les erreurs mémoire-unsafe à la compilation au lieu de les laisser crasher à l’exécution. Au début, le borrow checker peut vous causer quelques « disputes » avec le compilateur, mais une fois qu’on y est habitué, vous écrirez du code avec bien moins de bugs sans aucune surveillance runtime.

rustownershipsécurité-mémoireprogrammation-systèmesborrow-checker