Borrowing et Lifetime en Rust par l'exemple
1. Aperçu
Le borrowing et les lifetime sont deux concepts qui posent le plus de difficultés aux débutants en Rust face au compilateur. Cet article explique le borrowing (&T, &mut T) et les lifetime annotations ('a) à travers des exemples de domaine familiers comme Order et Customer. Après avoir lu cet article, vous serez capable de comprendre les messages d’erreur du borrow checker, de savoir quand écrire des lifetime annotations et quand le compilateur peut les déduire automatiquement.
2. Rappel rapide sur l’Ownership
Avant de parler du borrowing, il faut se souvenir que chaque valeur en Rust n’a qu’un seul propriétaire. Lorsque vous passez une valeur par valeur, la propriété est transférée.
struct Customer {
name: String,
}
fn print_customer(customer: Customer) {
println!("Khách hàng: {}", customer.name);
}
fn main() {
let customer = Customer { name: String::from("Nguyễn Văn A") };
print_customer(customer);
// println!("{}", customer.name); // erreur : customer a été déplacé
}
print_customer reçoit Customer par valeur, donc customer est transféré dans la fonction. La ligne en commentaire, si on retire le commentaire, signalera une erreur :
error[E0382]: borrow of moved value: `customer`
Le borrowing a été créé pour résoudre exactement ce problème : permettre à une autre fonction d’« emprunter » des données sans avoir besoin de prendre la propriété.
3. Borrowing immuable : &T
Utilisez &T quand vous avez simplement besoin de lire des données, sans les modifier.
struct Order {
items: Vec<f64>,
}
fn calculate_total(order: &Order) -> f64 {
order.items.iter().sum()
}
fn main() {
let order = Order { items: vec![100.0, 250.0, 50.0] };
let total = calculate_total(&order);
println!("Tổng: {}", total);
println!("Số món: {}", order.items.len()); // order vẫn dùng được
}
Tổng: 400
Số món: 3
calculate_total reçoit &Order — une référence immuable. order appartient toujours à main, la fonction « emprunte » simplement pour lire. Ainsi, après l’appel de la fonction, main peut toujours utiliser order normalement. Vous pouvez créer plusieurs &Order en même temps, pourvu qu’il n’y ait pas de &mut Order interférant.
4. Borrowing mutable : &mut T
Quand vous avez besoin de modifier des données via une référence, utilisez &mut T.
struct Order {
items: Vec<f64>,
discount_percent: f64,
}
fn apply_discount(order: &mut Order, percent: f64) {
order.discount_percent = percent;
}
fn main() {
let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };
apply_discount(&mut order, 10.0);
println!("Giảm giá: {}%", order.discount_percent);
}
Giảm giá: 10%
Notez deux choses : la variable order doit être déclarée mut, et l’appel de la fonction doit passer &mut order. Contrairement à &T, à un moment donné, seul un seul &mut T peut exister, et aucune autre &T ne peut vivre en même temps.
5. Borrow checker : la règle « un maître, plusieurs lecteurs ou un seul écrivain »
La règle centrale : pour une donnée, à un moment donné, vous ne pouvez avoir que plusieurs &T ou un seul &mut T, pas les deux mélangés.
fn main() {
let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };
for item in &order.items {
order.items.push(*item); // erreur : order.items est emprunté de manière immuable pour boucler
}
}
for item in &order.items crée une référence immuable qui vit pendant toute la boucle. À l’intérieur de la boucle, order.items.push(...) a besoin d’emprunter la même donnée de manière mutable → conflit. Le compilateur signale :
error[E0502]: cannot borrow `order.items` as mutable because it is
also borrowed as immutable
Le correctif courant : collecter les valeurs à ajouter dans un Vec temporaire, puis utiliser extend après que la boucle soit terminée — c’est-à-dire séparer les deux emprunts sur deux moments différents.
Note : le borrow checker analyse selon le « scope de vie réelle » (non-lexical lifetimes), pas selon le bloc
{}. Deux emprunts qui ne chevauchent pas en termes de durée de vie restent valides, même s’ils sont dans la même fonction.
6. Qu’est-ce que les lifetime ?
Une lifetime est la façon dont Rust décrit « combien de temps cette référence vit », pour que le compilateur s’assure que vous n’utilisez jamais une référence après que les données d’origine aient été supprimées. La plupart du temps, vous n’avez pas besoin d’écrire les lifetime — le compilateur les déduit. Mais quand une fonction reçoit plusieurs références et en retourne une, le compilateur a besoin que vous précisiez de quel paramètre la référence renvoyée « emprunte ».
6.1. Lifetime annotations sur les fonctions
fn longest_name<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
let customer1 = String::from("Nguyễn Văn A");
let customer2 = String::from("Trần Thị B dài hơn");
let longest = longest_name(&customer1, &customer2);
println!("Tên dài nhất: {}", longest);
}
Tên dài nhất: Trần Thị B dài hơn
'a ne crée pas une nouvelle durée de vie, c’est juste un nom pour lier : a, b et la valeur retournée doivent tous vivre au moins aussi longtemps que 'a. Le compilateur ne sait pas si la fonction retourne a ou b à l’exécution, donc il force les deux entrées et la sortie à partager une lifetime pour assurer la sécurité dans tous les cas.
Si vous écrivez mal — en retournant une référence non liée aux lifetime déclarées :
fn longest_name_broken<'a>(a: &'a str, _b: &str) -> &'a str {
let local = String::from("tạm");
&local // erreur : local est supprimé à la fin de la fonction
}
error[E0515]: cannot return value referencing local variable `local`
6.2. Lifetime dans les structs
Les structs contenant des références ont aussi besoin de lifetime annotations, car Rust doit s’assurer que la struct ne « vit » pas plus longtemps que les données qu’elle emprunte.
struct Customer {
name: String,
}
struct OrderSummary<'a> {
customer: &'a Customer,
total: f64,
}
fn build_summary<'a>(customer: &'a Customer, total: f64) -> OrderSummary<'a> {
OrderSummary { customer, total }
}
fn main() {
let customer = Customer { name: String::from("Lê Văn C") };
let summary = build_summary(&customer, 500.0);
println!("{} - tổng: {}", summary.customer.name, summary.total);
}
OrderSummary<'a> ne possède pas Customer, elle garde simplement une référence. La lifetime 'a force summary à ne pas vivre plus longtemps que customer. Si vous laissez customer être supprimé avant d’utiliser summary, le compilateur le bloque au moment de la compilation — aucun dangling pointer ne peut s’échapper vers l’exécution.
6.3. Quand n’avez-vous pas besoin d’écrire les lifetime (élision)
Rust a 3 règles de déduction automatique des lifetime (lifetime elision) qui s’appliquent à la plupart des fonctions :
- Chaque paramètre qui est une référence se voit attribuer une lifetime distinct.
- Si la fonction a exactement une lifetime d’entrée, elle est attribuée à la sortie.
- S’il y a
&selfou&mut self, la lifetime deselfest attribuée à la sortie.
fn first_word(text: &str) -> &str {
// règle 2 : une seule lifetime d'entrée -> s'attribue automatiquement à la sortie
match text.find(' ') {
Some(index) => &text[..index],
None => text,
}
}
Cette fonction n’a pas besoin d’écrire 'a car elle n’a qu’un seul paramètre qui est une référence — le compilateur déduit que la sortie emprunte à text. Vous avez besoin d’écrire les lifetime à la main seulement quand il y a plus d’une référence d’entrée et la sortie ne rentre pas dans les cas 2, 3.
7. Exemple réel : un parser qui retourne une slice
fn first_word(text: &str) -> &str {
match text.find(' ') {
Some(index) => &text[..index],
None => text,
}
}
fn main() {
let order_note = String::from("giao hàng nhanh trong ngày");
let word = first_word(&order_note);
println!("Từ đầu: {}", word);
}
Từ đầu: giao
first_word ne crée pas de nouveau String, elle retourne simplement une slice (&str) qui se trouve dans la mémoire d’order_note. C’est un motif très courant dans les parsers Rust : éviter d’allouer de la mémoire supplémentaire, simplement « pointer » vers les données d’origine. La lifetime garantit que word ne peut pas vivre plus longtemps qu’order_note.
Essayez d’écrire incorrectement pour voir comment le compilateur vous protège :
fn first_word_broken() -> &str {
let text = String::from("giao hàng nhanh");
first_word(&text) // text est supprimé quand la fonction se termine
}
error[E0106]: missing lifetime specifier
Le compilateur refuse immédiatement la compilation car il n’y a aucun moyen d’assigner une lifetime valide à la sortie — text va certainement disparaître avant que la valeur retournée soit utilisée à l’appel.
Test pour first_word
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tra_ve_dung_tu_dau_tien() {
let note = "giao hang nhanh trong ngay";
assert_eq!(first_word(note), "giao");
}
#[test]
fn tra_ve_ca_cau_khi_khong_co_khoang_trang() {
let note = "ship";
assert_eq!(first_word(note), "ship");
}
}
Exécutez cargo test :
running 2 tests
test tests::tra_ve_ca_cau_khi_khong_co_khoang_trang ... ok
test tests::tra_ve_dung_tu_dau_tien ... ok
8. Erreurs courantes
- Retourner une référence à une variable local : la variable est supprimée à la fin de la fonction, mais la référence continue de « vivre » à l’appel → le compilateur la bloque avec une erreur lifetime, jamais laissé passer à l’exécution.
- Garder
&Ttrop longtemps puis emprunter&mut T: courant quand on sauvegarde le résultat d’.iter()dans une variable puis on mute la struct d’origine dans le même scope. Correctif : réduire la portée de l’emprunt immuable avec un bloc{}ou séparer la logique de lecture/écriture en deux étapes. - Abuser de
clone()pour éviter le borrow checker :clone()compile toujours mais coûte le prix de copier les données. N’l’utilisez que quand vous avez vraiment besoin d’une copie indépendante, pas pour « éteindre » une erreur de borrowing. - Écrire des lifetime annotations en excès : si une fonction n’a qu’une seule référence d’entrée, ne l’écrivez pas
'aà la main — la règle d’élision l’a gérée, l’écrire en plus rend simplement le code confus.
Note : si vous trouvez que vous devez ajouter
'staticpour que le code compile, arrêtez-vous et reconsidérez la conception.'staticforce les données à vivre pendant tout le programme, c’est généralement un signe que vous contournez le vrai problème d’ownership au lieu de le résoudre.
9. Conclusion
Le borrowing vous permet d’emprunter des données sans avoir besoin de prendre la propriété, avec la règle « plusieurs lecteurs ou un seul écrivain » vérifiée par le borrow checker au moment de la compilation. Les lifetime sont le langage pour que le compilateur et vous vous mettez d’accord sur la durée de vie d’une référence, particulièrement important quand une fonction retourne une référence ou qu’une struct contient des références. Dans la plupart des cas, le compilateur déduit les lifetime par la règle d’élision ; vous n’avez besoin de les écrire à la main que quand il y a plus d’une référence d’entrée ambiguë quant à la source de la sortie.