Développement Web

Comprendre l’ownership et le borrowing en Rust

14 min de lecture

📍 Guide principal de la série : Apprendre Rust de zéro : le guide complet pour débuter. Ce tutoriel fait partie de la série « Rust de zéro ». Pour la vue d’ensemble, lisez d’abord le guide principal.

Le concept qui rend Rust unique

Si un seul mécanisme distingue Rust de tous les autres langages, c’est l’ownership (la possession). C’est lui qui permet à Rust de garantir la sûreté mémoire sans ramasse-miettes (garbage collector) : pas de pause imprévisible à l’exécution, pas de fuite mémoire, pas de bug de « pointeur fou ». Le prix à payer, c’est que le compilateur impose des règles strictes — et c’est précisément ces règles qui font fuir beaucoup de débutants. Pourtant, une fois le modèle mental en place, tout s’éclaire.

Dans ce tutoriel, on prend le temps de comprendre l’ownership, le déplacement (move), l’emprunt (borrowing) et les slices, en les appliquant à stock-cli : passer la liste des produits à une fonction sans la « perdre », et modifier une quantité en place en toute sécurité. Vous n’avez pas besoin de tout retenir du premier coup ; vous avez besoin de comprendre pourquoi le compilateur réagit comme il le fait.

🎯 Ce que vous allez apprendre

  • Énoncer les trois règles d’ownership et expliquer ce qu’est un move.
  • Distinguer les types Copy (copiés) des types possédés comme String (déplacés).
  • Emprunter une valeur avec une référence & et la modifier avec &mut.
  • Appliquer la règle d’emprunt (une référence mutable ou plusieurs immuables) et utiliser les slices.

🛠️ Ce que vous allez construire

Deux fonctions de stock-cli écrites proprement : afficher_inventaire, qui emprunte la liste des produits sans en prendre possession, et vendre, qui modifie une quantité via une référence mutable. À la fin, vous saurez écrire des fonctions qui manipulent des données partagées sans les copier ni les perdre.

Prérequis

  • Connaître les variables, types et fonctions Rust (voir le tutoriel précédent).
  • Test express : si vous savez écrire une fonction fn f(x: u32) -> u32 et la compiler, vous êtes prêt.
  • ⏱️ Temps estimé : ~45 minutes — c’est le tutoriel le plus dense de la série, prenez votre temps.

Étape 1 — La pile, le tas, et les trois règles

Pour comprendre l’ownership, il faut un modèle mental de la mémoire. Les données de taille connue et fixe (un i32, un bool) vivent sur la pile (stack), rapide et automatique. Les données de taille variable (une String qui peut grandir) vivent sur le tas (heap), plus souple mais qui demande une gestion : il faut allouer la mémoire, puis la libérer. C’est cette libération que les autres langages confient soit au programmeur (C, risque d’erreurs), soit à un ramasse-miettes (Java, Python, coût à l’exécution). Rust, lui, la confie au compilateur via l’ownership.

Le livre officiel énonce trois règles, à connaître par cœur :

1. Chaque valeur en Rust a un propriétaire (owner).
2. Il ne peut y avoir qu’un seul propriétaire à la fois.
3. Quand le propriétaire sort de sa portée (scope), la valeur est libérée.

La règle 3 est la clé : à la fin d’un bloc { ... }, Rust appelle automatiquement la libération (la fonction drop) sur chaque valeur dont c’est la portée. Pas de free() manuel, pas de fuite. La mémoire est rendue exactement au bon moment, déterminé à la compilation.

Étape 2 — Le déplacement (move) : pourquoi s1 devient invalide

Voici le piège qui surprend tout le monde. Avec un entier, l’affectation copie la valeur. Avec une String, elle ne la copie pas : elle la déplace. Regardez :

fn main() {
    let s1 = String::from("Écran iPhone 11");
    let s2 = s1;            // s1 est DÉPLACÉ dans s2, pas copié
    // println!("{s1}");    // ERREUR : value borrowed here after move
    println!("{s2}");        // OK
}

Une String est en réalité trois informations sur la pile (un pointeur vers le tas, une longueur, une capacité) qui désignent les octets réels stockés sur le tas. Quand on écrit let s2 = s1, Rust copie ces trois informations de pile — mais pas les octets du tas. On aurait alors deux variables pointant vers la même zone mémoire. À la fin du bloc, toutes deux essaieraient de la libérer : c’est le bug du double free, classique en C. Pour l’empêcher, Rust invalide s1 : après le déplacement, seul s2 est propriétaire. Utiliser s1 ensuite est une erreur de compilation, pas un crash à l’exécution.

Si vous voulez réellement deux copies indépendantes des octets du tas, demandez-le explicitement avec .clone() :

    let s1 = String::from("Écran iPhone 11");
    let s2 = s1.clone();    // copie profonde : deux String distinctes
    println!("{s1} / {s2}"); // les deux sont valides

Le clone() est visible et volontaire : Rust vous montre où vous payez le coût d’une copie mémoire, là où d’autres langages le cachent.

Point d’étape — Décommentez le println!("{s1}") du premier exemple : le compilateur doit refuser avec borrow of moved value: s1. Ce message confirme que vous avez bien provoqué un déplacement. Recommentez-le pour continuer.

Étape 3 — Les types Copy : pourquoi les entiers ne se déplacent pas

Si tout se déplaçait, manipuler des entiers serait pénible. Heureusement, les types simples stockés entièrement sur la pile implémentent le trait Copy : ils sont copiés trivialement à l’affectation, et l’original reste valide.

    let x = 5;
    let y = x;              // x est COPIÉ
    println!("x = {x}, y = {y}"); // les deux sont valides

Sont Copy : tous les entiers (u32, i64…), les flottants (f64), le booléen bool, le caractère char, et les tuples composés uniquement de types Copy (comme (i32, i32)). En revanche, dès qu’un type gère une allocation sur le tas (comme String ou Vec), il n’est pas Copy : il se déplace. La règle mentale est simple : petit et sur la pile → copié ; possède des données sur le tas → déplacé.

Étape 4 — Ownership et fonctions : le problème concret

L’ownership ne joue pas qu’entre variables : il joue aussi quand on passe une valeur à une fonction. Passer une String à une fonction la déplace dedans — après l’appel, la variable d’origine n’est plus utilisable. C’est exactement le piège que rencontre tout débutant sur stock-cli :

fn afficher(nom: String) {
    println!("Produit : {nom}");
}   // nom sort de portée et est libéré ici

fn main() {
    let produit = String::from("Batterie Samsung A10");
    afficher(produit);       // produit est DÉPLACÉ dans la fonction
    // afficher(produit);    // ERREUR : produit a déjà été déplacé
}

Le deuxième appel échoue : produit a été consommé par le premier. Une solution naïve serait de renvoyer la String pour la « récupérer », mais le code devient vite illisible. La vraie solution, idiomatique, c’est l’emprunt : prêter la valeur à la fonction sans lui en donner la propriété.

Étape 5 — L’emprunt (borrowing) avec les références

Une référence, notée &, permet à une fonction d’accéder à une valeur sans en prendre possession. On dit qu’elle emprunte la valeur. Le propriétaire d’origine reste valide après l’appel :

fn afficher(nom: &String) {   // emprunte, ne possède pas
    println!("Produit : {nom}");
}   // la référence sort de portée, mais la String n'est PAS libérée

fn main() {
    let produit = String::from("Batterie Samsung A10");
    afficher(&produit);      // on prête une référence
    afficher(&produit);      // toujours valide : on peut re-prêter
    println!("Encore là : {produit}");
}

Le &produit crée une référence ; la fonction reçoit &String. Comme elle n’est pas propriétaire, rien n’est libéré à la fin de la fonction, et produit reste utilisable autant qu’on veut. C’est le mode de passage par défaut en Rust : on emprunte presque toujours, on ne déplace que lorsqu’on veut vraiment transférer la propriété.

Par défaut, une référence est en lecture seule : on ne peut pas modifier la valeur empruntée. Pour la modifier, il faut une référence mutable, notée &mut. Écrivons la fonction vendre de stock-cli, qui décrémente une quantité en place :

fn vendre(quantite: &mut u32) {
    *quantite -= 1;          // * déréférence : on modifie la valeur pointée
}

fn main() {
    let mut stock = 8u32;
    vendre(&mut stock);      // on prête une référence MUTABLE
    vendre(&mut stock);
    println!("Stock restant : {stock}"); // 6
}

Trois conditions pour modifier via une référence : la variable d’origine doit être mut (let mut stock), on passe &mut stock, et dans la fonction on utilise l’opérateur de déréférencement * pour atteindre la valeur. Oubliez l’un des trois et le compilateur vous le rappellera précisément.

Point d’étapevendre appelée deux fois sur un stock de 8 doit afficher 6. Si vous obtenez cannot borrow as mutable, c’est que stock n’a pas été déclaré mut. Si c’est cannot assign to ... behind a & reference, vous avez oublié le mut sur la référence ou le *.

Étape 6 — La règle d’or de l’emprunt

Voici la règle qui empêche les accès concurrents dangereux et les corruptions de données, énoncée par le livre officiel :

À un instant donné, vous pouvez avoir soit une seule référence mutable, soit n’importe quel nombre de références immuables — jamais les deux en même temps. Et toute référence doit toujours rester valide.

Autrement dit : tant que quelqu’un lit la donnée (références &), personne ne peut la modifier ; et si quelqu’un la modifie (&mut), il est le seul à y toucher. Cette règle, vérifiée à la compilation, élimine toute une classe de bugs (les data races) avant même l’exécution.

    let mut stock = 8u32;
    let r1 = &stock;          // emprunt immuable
    let r2 = &stock;          // un autre emprunt immuable : OK
    println!("{r1} et {r2}");  // r1 et r2 ne servent plus après ici

    let r3 = &mut stock;      // emprunt mutable : OK car r1/r2 finis
    *r3 += 1;
    println!("{r3}");

Tant que r1 et r2 (immuables) sont utilisés, créer r3 (mutable) serait refusé. Mais comme leur dernière utilisation est avant r3, le compilateur considère leur emprunt terminé : c’est le mécanisme des non-lexical lifetimes. Enfin, Rust interdit les références pendantes : on ne peut pas renvoyer une référence vers une valeur libérée à la fin de la fonction — le compilateur le détecte et exige que la donnée vive assez longtemps.

Étape 7 — Les slices : emprunter une portion

Un slice est une référence vers une portion contiguë d’une collection, sans en prendre possession. Le slice le plus courant est &str, une vue sur une chaîne. Pour stock-cli, on l’utilise pour passer un nom de produit sans imposer une String :

// &str accepte aussi bien un littéral qu'une String empruntée
fn longueur_nom(nom: &str) -> usize {
    nom.len()
}

fn main() {
    let litteral = "Chargeur USB-C";
    let possede = String::from("Vitre arrière");
    println!("{}", longueur_nom(litteral));   // littéral &str
    println!("{}", longueur_nom(&possede));   // String -> &str automatiquement
}

Prendre &str en paramètre plutôt que &String rend la fonction plus générale : elle accepte les deux. C’est une convention forte en Rust. De même, un slice de tableau &[u32] emprunte une tranche de valeurs (par exemple &quantites[1..3]) sans copier. Les slices sont partout dans le code Rust idiomatique, justement parce qu’ils empruntent au lieu de posséder.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
borrow of moved value Valeur utilisée après un déplacement Emprunter avec & ou cloner avec .clone()
cannot borrow `x` as mutable Variable non déclarée mut Déclarer let mut x
cannot borrow as mutable more than once Deux &mut simultanés Limiter à une seule référence mutable à la fois
missing lifetime specifier Référence renvoyée sans garantie de validité Renvoyer la valeur possédée, ou annoter le lifetime
Oubli du * sur &mut Modification sans déréférencer Utiliser *quantite -= 1

🌍 Adaptation au contexte ouest-africain

L’absence de ramasse-miettes a une conséquence très concrète sur du matériel modeste, fréquent dans les ateliers et PME de la sous-région : un programme Rust ne « gèle » jamais pour une pause de garbage collection, et sa consommation mémoire reste prévisible. Sur un petit serveur à 3 000 FCFA/mois ou un ordinateur d’entrée de gamme, c’est un avantage réel : pas de pic mémoire surprise, pas de ralentissement aléatoire. L’ownership, qui paraît contraignant au début, est précisément ce qui permet de viser ce matériel léger sans sacrifier la fiabilité.

Côté apprentissage, ne luttez pas seul contre le borrow checker : ses messages sont des leçons. Quand il refuse votre code, lisez la cause et la suggestion ; en quelques jours, vous anticiperez ses objections et écrirez « juste » du premier coup. C’est un investissement qui se rentabilise vite.

✅ Récapitulatif

Vous tenez le concept central de Rust. Vous savez que chaque valeur a un propriétaire unique, qu’une String se déplace tandis qu’un entier se copie, et que pour utiliser une valeur sans la perdre, on l’emprunte avec & (lecture) ou &mut (écriture). Vous connaissez la règle d’or — une référence mutable ou plusieurs immuables — et l’usage des slices pour emprunter une portion. Pour stock-cli, vous pouvez désormais écrire des fonctions qui lisent et modifient l’inventaire sans copie inutile. La suite logique : structurer proprement un produit avec les struct et les enum.

🧾 Aide-mémoire

Élément Signification
let s2 = s1; (String) Déplacement : s1 devient invalide
s1.clone() Copie profonde explicite
&valeur Emprunt immuable (lecture)
&mut valeur Emprunt mutable (écriture)
*r Déréférencement d’une référence
&str / &[T] Slice : vue empruntée sur une portion
Règle d’or 1 &mut XOR N &

💪 À vous de jouer

Écrivez une fonction reapprovisionner(quantite: &mut u32, ajout: u32) qui ajoute ajout au stock pointé. Testez-la sur un stock initial de 2 avec un ajout de 10, et affichez le résultat (12).

Voir une solution
fn reapprovisionner(quantite: &mut u32, ajout: u32) {
    *quantite += ajout;      // déréférence puis additionne
}

fn main() {
    let mut stock = 2u32;
    reapprovisionner(&mut stock, 10);
    println!("Nouveau stock : {stock}"); // 12
}

On emprunte stock en mutable, on déréférence avec * pour additionner. La variable stock reste propriétaire et conserve la nouvelle valeur après l’appel.

Tutoriels frères

Pour aller plus loin

FAQ

Q : Pourquoi mon code qui marchait en Python est refusé en Rust ?
R : Python utilise un ramasse-miettes et n’a pas d’ownership. Rust déplace les valeurs possédées et vérifie les emprunts à la compilation. Ce qui était implicite ailleurs devient explicite ici — c’est le prix de la sûreté sans GC.

Q : Quand dois-je utiliser .clone() ?
R : Seulement quand vous avez réellement besoin de deux copies indépendantes. Cloner « pour faire taire le compilateur » est un anti-pattern : préférez l’emprunt &, plus efficace, et ne clonez que si la logique l’exige vraiment.

Q : Quelle différence entre &String et &str ?
R : &str est plus général : il accepte un littéral comme une String empruntée. Prenez &str en paramètre par convention ; n’utilisez &String que si vous avez une raison précise.

Q : Qu’est-ce qu’un lifetime ?
R : C’est la durée pendant laquelle une référence est valide. Le compilateur l’infère presque toujours seul. Vous n’aurez à l’annoter (&'a T) que dans des cas avancés, hors du périmètre débutant.

Partager