Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 329 minIntermédiaire

Ownership, emprunts et durées de vie : la mémoire en Rust

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • expliquer les trois règles de l'ownership et ce qu'est un déplacement ;
  • prêter une valeur avec & ou &mut en respectant les règles d'emprunt ;
  • choisir entre String et &str pour un paramètre ;
  • lire une annotation de durée de vie comme 'a et corriger l'erreur E0597 ;
  • dire quelles failles mémoire le compilateur empêche.

4 exercices corrigés4 schémas

L'ownership (la propriété) est le système qui permet à Rust de libérer la mémoire automatiquement, sans ramasse-miettes : chaque valeur a un unique propriétaire, et elle est libérée quand ce propriétaire disparaît. Pour utiliser une valeur sans en devenir propriétaire, on l'emprunte (borrowing) avec une référence, sous des règles que le compilateur vérifie. Ce chapitre explique ces règles simplement, avec des schémas, puis montre les durées de vie (lifetimes) et les failles que tout cela empêche.

C'est le chapitre le plus important du cours, et le plus nouveau si tu viens de JavaScript ou de C#. Prends ton temps et tape chaque exemple.

La pile et le tas

Un programme range ses données à deux endroits :

  • La pile (stack) : rapide, organisée en couches. Chaque appel de fonction y pose ses variables locales, retirées dès que la fonction se termine. Tout ce qui est sur la pile a une taille connue à la compilation : un u32 (4 octets), un bool, un tableau [u32; 4].
  • Le tas (heap) : une grande réserve où le programme demande de la place pendant l'exécution, pour des données dont la taille varie, comme un texte saisi par l'utilisateur ou une liste qui grandit. Cette place doit être rendue quand on n'en a plus besoin.

Une String de Rust (un texte modifiable) est faite de deux parties : sur la pile, trois nombres (l'adresse du texte, sa longueur, sa capacité) ; sur le tas, les caractères eux-mêmes. La question de la gestion mémoire est donc : qui rend la zone du tas, et quand ?

ApprocheLangagesQui libère le tas ?Risque
À la mainC, C++Le développeur, avec free ou deleteOubli (fuite), libération trop tôt ou deux fois (failles)
Ramasse-miettesC#, Java, JavaScript, PythonUn programme qui tourne en parallèle et cherche ce qui n'est plus utiliséPauses et consommation de mémoire, mais pas de faille mémoire
OwnershipRustLe compilateur insère la libération au bon endroitAucun à l'exécution : les erreurs sont refusées à la compilation

Les trois règles de l'ownership

La troisième règle est la clé : à l'accolade fermante, Rust appelle automatiquement le code de libération (la fonction drop). Pas besoin de free, pas besoin de ramasse-miettes.

rust
fn main() {
    {
        let numero = String::from("PC-0001"); // numero est propriétaire du texte
        println!("{numero}");
    } // fin de portée : le texte est libéré ici, automatiquement
}

Le déplacement (move)

Que se passe-t-il si l'on affecte une String à une autre variable ?

rust
fn main() {
    let numero = String::from("PC-0001");
    let copie = numero;
    println!("{numero} {copie}");
}
text
error[E0382]: borrow of moved value: `numero`
 --> src/main.rs:4:16
  |
2 |     let numero = String::from("PC-0001");
  |         ------ move occurs because `numero` has type `String`, which does not implement the `Copy` trait
3 |     let copie = numero;
  |                 ------ value moved here
4 |     println!("{numero} {copie}");
  |                ^^^^^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let copie = numero.clone();
  |                       ++++++++

let copie = numero; ne copie pas le texte : il copie les trois nombres de la pile, et transfère la propriété à copie. C'est un déplacement (move). Si les deux variables restaient valables, chacune croirait devoir libérer le même texte à la fin du bloc : ce serait une double libération. Rust règle le problème en rendant numero inutilisable après le déplacement.

AvantAprès let copie = numero;piletasnumeroptrlen 7cap 7PC-0001numero possède le textenumeroptrlen 7cap 7invalidecopieptrlen 7cap 7PC-0001copie est le seul propriétaire
Un déplacement copie l'en-tête de la String sur la pile et transfère la propriété du texte ; l'ancienne variable devient invalide

Copy et clone

Pourquoi alors let b = a; marche-t-il avec des nombres ?

rust
fn main() {
    // String : clone() copie aussi le texte sur le tas
    let numero = String::from("PC-0001");
    let copie = numero.clone();
    println!("{numero} {copie}");

    // u32 est Copy : l'affectation copie la valeur, rien n'est déplacé
    let prix_ht: u32 = 89900;
    let autre_prix = prix_ht;
    println!("{prix_ht} {autre_prix}");
}
text
PC-0001 PC-0001
89900 89900
  • Les types simples, rangés entièrement sur la pile (entiers, flottants, bool, char, et les tuples ou tableaux qui ne contiennent que de tels types), sont Copy : les copier ne coûte presque rien, donc l'affectation copie au lieu de déplacer.
  • Pour les autres (String, Vec, tes propres structs), .clone() fait une copie complète, tas compris. C'est explicite, parce que ça a un coût : en lisant .clone(), tu sais qu'une copie a lieu.

Ownership et fonctions

Passer une String à une fonction la déplace dans le paramètre, exactement comme une affectation :

rust
fn afficher(numero: String) {
    println!("Poste {numero}");
} // numero sort de portée : le texte est libéré ici

fn main() {
    let numero = String::from("PC-0001");
    afficher(numero);
    afficher(numero);
}
text
error[E0382]: use of moved value: `numero`
 --> src/main.rs:8:14
  |
6 |     let numero = String::from("PC-0001");
  |         ------ move occurs because `numero` has type `String`, which does not implement the `Copy` trait
7 |     afficher(numero);
  |              ------ value moved here
8 |     afficher(numero);
  |              ^^^^^^ value used here after move
  |
note: consider changing this parameter type in function `afficher` to borrow instead if owning the value isn't necessary

Le premier appel donne le texte à afficher, qui le libère en se terminant. Le second appel utiliserait une zone libérée : c'est une utilisation après libération, refusée à la compilation. La note du compilateur donne la bonne solution : faire emprunter la valeur par la fonction.

Les références : emprunter sans prendre

Une référence, notée &, permet d'utiliser une valeur sans en devenir propriétaire. On dit que la fonction emprunte la valeur. Pour la modifier, il faut une référence mutable, &mut :

rust
fn afficher(numero: &String) {
    println!("Poste {numero}");
}

fn ajouter_suffixe(numero: &mut String, suffixe: &str) {
    numero.push_str(suffixe);
}

fn main() {
    let mut numero = String::from("PC-0001");
    afficher(&numero);
    afficher(&numero); // numero appartient toujours à main
    ajouter_suffixe(&mut numero, "-B");
    afficher(&numero);
}
text
Poste PC-0001
Poste PC-0001
Poste PC-0001-B
  • &numero crée une référence : la fonction lit le texte, puis le rend en se terminant. Le propriétaire reste numero, dans main.
  • &mut numero prête la valeur en écriture. Il faut que la variable soit elle-même déclarée mut, et l'appelant écrit &mut : on voit dans l'appel que la fonction va modifier la donnée.

Les règles d'emprunt

C'est la règle « plusieurs lecteurs ou un seul écrivain ». Elle empêche deux morceaux de code de modifier la même donnée en même temps, et elle empêche de lire une donnée pendant qu'on la modifie. Voici l'erreur classique :

rust
fn main() {
    let mut numeros = vec![String::from("PC-0001"), String::from("PC-0002")];
    let premier = &numeros[0];
    numeros.push(String::from("PC-0003"));
    println!("Premier poste : {premier}");
}
text
error[E0502]: cannot borrow `numeros` as mutable because it is also borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let premier = &numeros[0];
  |                    ------- immutable borrow occurs here
4 |     numeros.push(String::from("PC-0003"));
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 |     println!("Premier poste : {premier}");
  |                                ------- immutable borrow later used here

Ce refus n'est pas du zèle. push peut manquer de place dans la zone réservée pour la liste : il réserve alors une zone plus grande, y déplace les éléments et libère l'ancienne. premier pointerait encore sur l'ancienne zone, libérée. En C++, ce code compile et produit précisément une utilisation après libération.

Lecture partagée&parc&parc&parcparcautant de lecteursque tu veuxÉcriture exclusive&mut parcparcun seul écrivain,et personne d'autreRefusé&parc&mut parcparclire et écrire : E0502deux &mut : E0499
Plusieurs lecteurs ou un seul écrivain : le compilateur refuse de mélanger les deux

Deux références mutables en même temps sont refusées aussi, avec l'erreur E0499 :

text
error[E0499]: cannot borrow `numero` as mutable more than once at a time
 --> src/main.rs:4:13
  |
3 |     let a = &mut numero;
  |             ----------- first mutable borrow occurs here
4 |     let b = &mut numero;
  |             ^^^^^^^^^^^ second mutable borrow occurs here
5 |     a.push_str("-A");
  |     - first borrow later used here

Bonne nouvelle : un emprunt se termine à sa dernière utilisation, pas forcément à la fin du bloc. Il suffit souvent de réordonner les lignes :

rust
fn main() {
    let mut numeros = vec![String::from("PC-0001"), String::from("PC-0002")];
    let premier = &numeros[0];
    println!("Premier poste : {premier}"); // dernière utilisation de premier
    numeros.push(String::from("PC-0003")); // l'emprunt est fini : accepté
    println!("{} postes", numeros.len());
}
text
Premier poste : PC-0001
3 postes

Pas de référence pendante

Une référence pendante (dangling reference) désigne une zone déjà libérée. En Rust, elle ne compile pas :

rust
fn nouveau_numero() -> &String {
    let numero = String::from("PC-0008");
    &numero
}
text
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:24
  |
1 | fn nouveau_numero() -> &String {
  |                        ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: instead, you are more likely to want to return an owned value
  |
1 - fn nouveau_numero() -> &String {
1 + fn nouveau_numero() -> String {

numero est libéré à la fin de la fonction : une référence vers lui ne désignerait plus rien. La solution, que propose le compilateur, est de renvoyer la String elle-même, ce qui déplace la propriété vers l'appelant : fn nouveau_numero() -> String { String::from("PC-0008") }.

String et &str

Rust a deux types de texte, et c'est la question numéro un des débutants :

String&str
Ce que c'estUn texte possédé, modifiable, rangé sur le tasUne vue empruntée sur un texte qui appartient à quelqu'un d'autre (une « tranche », string slice)
CréationString::from("PC-0001"), "PC-0001".to_string(), format!(…)Un littéral "PC-0001", ou &une_string, ou &une_string[0..2]
ModifiableOui (push_str, push, clear…) si la variable est mutNon
Usage typiqueChamp d'une struct, valeur que l'on construit ou que l'on gardeParamètre d'une fonction qui se contente de lire
numero : Stringptrlen 7cap 7possède le texteprefixe : &strptrlen 2emprunte 2 octetsPC-0001sur le tas"SRV-12" : &'static strptrlen 6SRV-12dans l'exécutable,en lecture seule
Une String possède son texte sur le tas ; un &str n'est qu'une adresse et une longueur, vers le texte de quelqu'un d'autre
rust
/// « PC-0001 » → « 0001 ».
fn partie_numerique(numero: &str) -> &str {
    match numero.find('-') {
        Some(position) => &numero[position + 1..],
        None => numero,
    }
}

fn main() {
    let numero = String::from("PC-0001");
    let prefixe: &str = &numero[0..2];
    println!("{prefixe} {}", partie_numerique(&numero)); // &String devient &str
    println!("{}", partie_numerique("SRV-12")); // un littéral est déjà un &str
}
text
PC 0001
12

Un paramètre &str accepte à la fois un littéral et une &String : le compilateur convertit automatiquement &String en &str. C'est pourquoi on écrit les paramètres en &str plutôt qu'en &String. find renvoie la position du tiret, ou rien ; le match qui traite les deux cas est expliqué au chapitre 4.

Les durées de vie (lifetimes) expliquées simplement

La durée de vie (lifetime) d'une référence est la portion du programme pendant laquelle elle est utilisée. La règle est simple : une référence ne doit pas vivre plus longtemps que la valeur qu'elle désigne. Le compilateur la vérifie partout :

rust
fn main() {
    let reference;
    {
        let numero = String::from("PC-0001");
        reference = №
    } // numero est libéré ici
    println!("{reference}");
}
text
error[E0597]: `numero` does not live long enough
 --> src/main.rs:5:21
  |
4 |         let numero = String::from("PC-0001");
  |             ------ binding `numero` declared here
5 |         reference = №
  |                     ^^^^^^^ borrowed value does not live long enough
6 |     } // numero est libéré ici
  |     - `numero` dropped here while still borrowed
7 |     println!("{reference}");
  |                --------- borrow later used here
lignevaleur numeroemprunt reference2let reference;3{4let numero = …;5reference = №6}7println!("{reference}");libéréevalidependantel'emprunt survit : E0597
L'emprunt dépasse la vie de la valeur empruntée : le compilateur refuse avec l'erreur E0597

Dans la plupart des fonctions, le compilateur déduit seul les durées de vie. Il a besoin d'aide quand une fonction reçoit plusieurs références et en renvoie une : laquelle des deux renvoie-t-elle ?

rust
fn le_plus_long(a: &str, b: &str) -> &str {
    if a.len() >= b.len() { a } else { b }
}
text
error[E0106]: missing lifetime specifier
 --> src/main.rs:1:38
  |
1 | fn le_plus_long(a: &str, b: &str) -> &str {
  |                    ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `a` or `b`
help: consider introducing a named lifetime parameter
  |
1 | fn le_plus_long<'a>(a: &'a str, b: &'a str) -> &'a str {
  |                ++++     ++          ++          ++

On ajoute une annotation de durée de vie, un nom qui commence par une apostrophe, par convention 'a :

rust
fn le_plus_long<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    let marque = String::from("Lenovo");
    {
        let autre = String::from("HP");
        let resultat = le_plus_long(&marque, &autre);
        println!("{resultat}"); // utilisé tant que les deux vivent : accepté
    }
}
text
Lenovo

Lis la signature ainsi : « la référence renvoyée est valable aussi longtemps que les deux paramètres le sont ». L'annotation ne change pas la durée de vie des valeurs : elle décrit une relation, que le compilateur vérifie ensuite chez l'appelant. Si tu utilises resultat après la libération de autre, tu obtiens l'erreur E0597, même si resultat contient en fait « Lenovo » : le compilateur raisonne sur la signature, pas sur les valeurs.

Ce que ces règles empêchent

Faille en C ou C++Comment Rust l'empêche
Utilisation après libérationLe compilateur refuse d'utiliser une valeur déplacée (E0382) ou une référence qui survit à sa valeur (E0597)
Double libérationUn seul propriétaire : une seule libération
Fuite mémoire par oubli de freeLibération automatique à la fin de la portée
Dépassement de tamponIndices vérifiés : panique au lieu de lire la mémoire voisine
Accès concurrents (data race)Un seul &mut à la fois ; les mêmes règles s'appliquent entre fils d'exécution
Pointeur nulPas de null : une valeur absente est un Option (chapitre 5)

Ces garanties tiennent tant que le code n'utilise pas le mot-clé unsafe, qui permet de désactiver certaines vérifications pour parler au matériel ou à du code C. Un bloc unsafe n'est pas forcément faux, mais c'est là qu'il faut concentrer la relecture. Le guide de l'ANSSI encadre strictement son usage, et le cours Sécurité applicative montre comment intégrer ce genre de règle dans une revue de code.

Exercices

Exercice 1 sur 4 Compile ou pas ? Réussi À revoir

Pour chaque fragment (placé dans main), dis s'il compile. Sinon, nomme le problème.

rust
// A
let a = String::from("PC-0001");
let b = a;
println!("{b}");
rust
// B
let a: u32 = 89900;
let b = a;
println!("{a} {b}");
rust
// C
let mut liste = vec![1, 2, 3];
let r = &liste;
liste.push(4);
println!("{r:?}");
rust
// D
let mut s = String::from("PC");
let r1 = &s;
let r2 = &s;
println!("{r1} {r2}");
let r3 = &mut s;
r3.push_str("-0001");
println!("{s}");
Voir la réponseMasquer la réponse de l'exercice 1
  • A compile et affiche PC-0001 : a est déplacé dans b, mais on n'utilise plus a ensuite.
  • B compile et affiche 89900 89900 : u32 est Copy, l'affectation copie la valeur.
  • C ne compile pas (E0502) : r emprunte liste en lecture, push l'emprunte en écriture, et r est utilisé après. Déplace le println! avant le push.
  • D compile et affiche PC PC puis PC-0001 : les deux lectures r1 et r2 se terminent à leur dernière utilisation, avant la création de r3. Et r3 n'est plus utilisé quand on lit s à la dernière ligne.

Tu avais trouvé ?

Exercice 2 sur 4 Réparer sans cloner Réussi À revoir

Ce programme ne compile pas. Corrige-le sans utiliser .clone(), en changeant le moins de choses possible.

rust
fn longueur(numero: String) -> usize {
    numero.len()
}

fn main() {
    let numero = String::from("PC-0001");
    let n = longueur(numero);
    println!("{numero} a {n} caractères");
}
Voir la réponseMasquer la réponse de l'exercice 2

longueur prend possession de numero, qui n'est plus utilisable ensuite (E0382). La fonction n'a besoin que de lire : elle doit emprunter. Le meilleur type de paramètre est &str :

rust
fn longueur(numero: &str) -> usize {
    numero.len()
}

fn main() {
    let numero = String::from("PC-0001");
    let n = longueur(&numero);
    println!("{numero} a {n} caractères");
}

Le programme affiche PC-0001 a 7 caractères. Ici, nombre d'octets et de caractères coïncident, car le texte ne contient aucun accent.

Tu avais trouvé ?

Exercice 3 sur 4 Ajouter un préfixe Réussi À revoir

Écris une fonction prefixer(numero: &mut String) qui ajoute « SL- » (pour Sorbier Logistique) au début du numéro. Appelle-la sur PC-0001 et affiche le résultat. Indice : insert_str(position, texte).

Voir la réponseMasquer la réponse de l'exercice 3
rust
fn prefixer(numero: &mut String) {
    numero.insert_str(0, "SL-");
}

fn main() {
    let mut numero = String::from("PC-0001");
    prefixer(&mut numero);
    println!("{numero}");
}

Le programme affiche SL-PC-0001. Trois conditions pour modifier par emprunt : la variable est déclarée let mut, le paramètre est &mut String, et l'appel écrit &mut numero.

Tu avais trouvé ?

Exercice 4 sur 4 Expliquer une durée de vie Réussi À revoir

Voici une signature : fn premier_mot<'a>(texte: &'a str) -> &'a str.

  1. Que garantit-elle à l'appelant ?
  2. Le compilateur aurait-il pu la déduire sans l'annotation 'a ?
  3. Pourquoi fn choisir(a: &str, b: &str) -> &str exige-t-elle une annotation, alors que la première non ?
Voir la réponseMasquer la réponse de l'exercice 4
  1. La tranche renvoyée vient de texte : elle reste valable tant que le texte d'origine existe, et pas plus longtemps.
  2. Oui. Quand une fonction a une seule référence en paramètre, le compilateur suppose que la référence renvoyée en vient. fn premier_mot(texte: &str) -> &str compile tel quel : ce sont les règles d'élision des durées de vie.
  3. Avec deux références en paramètre, le compilateur ne peut pas deviner de laquelle vient le résultat. Il faut le dire, par exemple fn choisir<'a>(a: &'a str, b: &'a str) -> &'a str.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Structs, enums et matchRevoir le sommaire du cours