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&muten respectant les règles d'emprunt ; - choisir entre
Stringet&strpour un paramètre ; - lire une annotation de durée de vie comme
'aet 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), unbool, 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 ?
| Approche | Langages | Qui libère le tas ? | Risque |
|---|---|---|---|
| À la main | C, C++ | Le développeur, avec free ou delete | Oubli (fuite), libération trop tôt ou deux fois (failles) |
| Ramasse-miettes | C#, Java, JavaScript, Python | Un 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 |
| Ownership | Rust | Le compilateur insère la libération au bon endroit | Aucun à 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.
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 ?
fn main() {
let numero = String::from("PC-0001");
let copie = numero;
println!("{numero} {copie}");
}
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.
Copy et clone
Pourquoi alors let b = a; marche-t-il avec des nombres ?
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}");
}
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), sontCopy: 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 :
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);
}
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 :
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);
}
Poste PC-0001
Poste PC-0001
Poste PC-0001-B
&numerocrée une référence : la fonction lit le texte, puis le rend en se terminant. Le propriétaire restenumero, dansmain.&mut numeroprête la valeur en écriture. Il faut que la variable soit elle-même déclaréemut, 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 :
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}");
}
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.
Deux références mutables en même temps sont refusées aussi, avec l'erreur E0499 :
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 :
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());
}
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 :
fn nouveau_numero() -> &String {
let numero = String::from("PC-0008");
&numero
}
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'est | Un texte possédé, modifiable, rangé sur le tas | Une vue empruntée sur un texte qui appartient à quelqu'un d'autre (une « tranche », string slice) |
| Création | String::from("PC-0001"), "PC-0001".to_string(), format!(…) | Un littéral "PC-0001", ou &une_string, ou &une_string[0..2] |
| Modifiable | Oui (push_str, push, clear…) si la variable est mut | Non |
| Usage typique | Champ d'une struct, valeur que l'on construit ou que l'on garde | Paramètre d'une fonction qui se contente de lire |
/// « 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
}
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 :
fn main() {
let reference;
{
let numero = String::from("PC-0001");
reference = №
} // numero est libéré ici
println!("{reference}");
}
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
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 ?
fn le_plus_long(a: &str, b: &str) -> &str {
if a.len() >= b.len() { a } else { b }
}
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 :
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é
}
}
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ération | Le compilateur refuse d'utiliser une valeur déplacée (E0382) ou une référence qui survit à sa valeur (E0597) |
| Double libération | Un seul propriétaire : une seule libération |
Fuite mémoire par oubli de free | Libération automatique à la fin de la portée |
| Dépassement de tampon | Indices 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 nul | Pas 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.
// A
let a = String::from("PC-0001");
let b = a;
println!("{b}");
// B
let a: u32 = 89900;
let b = a;
println!("{a} {b}");
// C
let mut liste = vec![1, 2, 3];
let r = &liste;
liste.push(4);
println!("{r:?}");
// 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:aest déplacé dansb, mais on n'utilise plusaensuite. - B compile et affiche
89900 89900:u32estCopy, l'affectation copie la valeur. - C ne compile pas (E0502) :
rempruntelisteen lecture,pushl'emprunte en écriture, etrest utilisé après. Déplace leprintln!avant lepush. - D compile et affiche
PC PCpuisPC-0001: les deux lecturesr1etr2se terminent à leur dernière utilisation, avant la création der3. Etr3n'est plus utilisé quand on litsà 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.
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 :
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
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.
- Que garantit-elle à l'appelant ?
- Le compilateur aurait-il pu la déduire sans l'annotation
'a? - Pourquoi
fn choisir(a: &str, b: &str) -> &strexige-t-elle une annotation, alors que la première non ?
Voir la réponseMasquer la réponse de l'exercice 4
- La tranche renvoyée vient de
texte: elle reste valable tant que le texte d'origine existe, et pas plus longtemps. - 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) -> &strcompile tel quel : ce sont les règles d'élision des durées de vie. - 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é ?