Chapitre 04
Ownership, emprunts & lifetimes
Objectifs du chapitre
- Comprendre quel problème l'ownership résout par rapport à la gestion mémoire manuelle du C/C++.
- Maîtriser les trois règles de l'ownership et faire le lien avec le RAII du C++.
- Distinguer move, copy et
clone(), et savoir lequel a un coût. - Utiliser les emprunts
&Tet&mut Ten respectant le borrow checker. - Lire une annotation de lifetime et comprendre quand le compilateur l'infère.
- Écrire une boucle de contrôle qui partage l'état sans le copier — zéro coût, zéro danger.
1. Le problème que Rust résout
En C et en C++, la mémoire dynamique est votre responsabilité. Vous appelez
malloc/free, new/delete, et vous devez le faire au bon moment,
exactement une fois, dans tous les chemins d'exécution — y compris ceux qui lèvent une exception ou sortent
d'une boucle par un return anticipé. Toute erreur produit un des classiques du domaine :
- Fuite mémoire : on oublie le
free, la mémoire n'est jamais rendue. - Double-free : on libère deux fois le même bloc, corruption du tas.
- Dangling pointer : on garde un pointeur vers une zone déjà libérée.
- Use-after-free : on lit ou écrit à travers ce pointeur — comportement indéfini.
Ces bugs sont particulièrement redoutables en robotique : ils sont non déterministes, se manifestent parfois après des heures de fonctionnement, et peuvent immobiliser un bras robotisé au pire moment. Le C++ moderne les atténue avec les smart pointers et le RAII, mais rien n'empêche à la compilation d'écrire un dangling reference.
Rust prend le problème à la racine : la sécurité mémoire est garantie à la compilation, sans ramasse-miettes (pas de garbage collector, donc pas de pauses imprévisibles — point crucial en temps réel). Le mécanisme central s'appelle l'ownership (la « possession »).
Piège C/C++ nº 1 — le pointeur nul. En C, une référence peut toujours être
NULL, et le déréférencer plante le programme. En Rust, une référence &T est
garantie valide et non nulle. L'absence de valeur se modélise explicitement avec
Option<T> (chapitre ultérieur), jamais avec un pointeur nul silencieux.
2. Les trois règles de l'ownership
Tout le modèle tient en trois règles, vérifiées par le compilateur :
- Chaque valeur a un propriétaire (une variable).
- Il ne peut y avoir qu'un seul propriétaire à la fois.
- Quand le propriétaire sort du scope, la valeur est libérée automatiquement (
drop).
fn main() {
{
let etat = String::from("robot en marche"); // `etat` devient propriétaire
println!("{etat}");
} // <- fin du scope : `drop` est appelé, la mémoire est rendue ici
// `etat` n'existe plus, impossible de l'utiliser : erreur de compilation
}
Analogie C++ : c'est le RAII généralisé. Le drop automatique en fin de scope,
c'est exactement le destructeur appelé par le C++ quand un objet local disparaît. La différence : en Rust
c'est le comportement par défaut pour toute valeur, et le compilateur vérifie qu'aucune référence
ne survit à l'objet — ce que le C++ ne garantit pas.
3. Le déplacement (move) par défaut
Considérons ce code, qui paraîtrait anodin en C :
let a = String::from("capteur");
let b = a; // move : la propriété de la chaîne passe de `a` à `b`
// println!("{a}"); // ERREUR : `a` a été déplacé, il n'est plus valide
En C++, b = a aurait fait une copie profonde (ou un move explicite avec
std::move). En Rust, l'affectation d'une valeur possédée est un déplacement :
la ressource n'est pas copiée, mais transférée. L'ancien propriétaire a est invalidé
à la compilation. Résultat : impossible d'avoir deux variables qui croient posséder la même mémoire — donc
pas de double-free.
Types Copy : les scalaires font exception
Les types simples stockés entièrement sur la pile (entiers, flottants, bool, char,
et les tuples de ces types) implémentent le trait Copy. Pour eux, l'affectation duplique la valeur
au lieu de la déplacer, car la copie est triviale et sans ressource à libérer.
let x: i32 = 42;
let y = x; // copie : `x` reste utilisable
println!("{x} et {y}"); // OK : 42 et 42
let v = vec![1.0_f64, 2.0, 3.0];
let w = v; // move : `v` n'est plus valide (Vec possède de la mémoire sur le tas)
// println!("{v:?}"); // ERREUR
Règle mnémotechnique. Si le type possède une ressource sur le tas (String,
Vec, Box…), l'affectation déplace. Si tout tient sur la pile et est
trivialement copiable (i32, f64…), l'affectation copie.
4. clone() : la copie explicite (et son coût)
Quand vous voulez réellement une copie indépendante d'une valeur possédée, vous l'écrivez explicitement
avec clone(). Le mot est volontairement visible dans le code : une copie profonde a un coût, et
Rust veut que ce coût soit lisible.
let a = String::from("trajectoire");
let b = a.clone(); // copie profonde : deux chaînes indépendantes existent
println!("{a} / {b}"); // OK, les deux sont valides
⏱ Temps réel. Un clone() d'un gros vecteur d'état, c'est une allocation
tas + une recopie, à chaque appel. Dans une boucle de contrôle qui tourne à 1 kHz, c'est
exactement le genre de coût caché qui fait exploser votre budget temps et fragmente le tas. Règle d'or :
dans la boucle de contrôle, on emprunte, on ne clone pas.
5. Les emprunts (borrowing)
Déplacer la propriété à chaque fois qu'on veut lire une valeur serait ingérable. La solution est l'emprunt : on prête une référence sans céder la propriété. Deux formes :
&T— emprunt partagé (lecture seule). On peut en avoir plusieurs simultanément.&mut T— emprunt mutable exclusif (lecture/écriture). Un seul à la fois.
La règle fondamentale du borrow checker, à tout instant, pour une valeur donnée :
Soit N lecteurs (&T), soit 1 écrivain (&mut T), jamais les deux en même temps.
fn afficher(v: &Vec<f64>) { // emprunt partagé : on lit seulement
println!("longueur = {}", v.len());
}
fn doubler(v: &mut Vec<f64>) { // emprunt exclusif : on modifie
for x in v.iter_mut() {
*x *= 2.0;
}
}
fn main() {
let mut etat = vec![1.0, 2.0, 3.0];
afficher(&etat); // prêt en lecture, `etat` reste propriétaire
doubler(&mut etat); // prêt en écriture (exclusif)
afficher(&etat); // le vecteur vaut maintenant [2.0, 4.0, 6.0]
}
Cette règle « N lecteurs XOR 1 écrivain » est ce qui élimine à la compilation les
data races et les invalidations d'itérateur (le fameux bug C++ où l'on modifie un
std::vector pendant qu'on le parcourt).
Piège C/C++ nº 2 — l'aliasing mutable. En C, rien n'empêche deux pointeurs d'écrire
dans la même zone au même moment. En Rust, tant qu'un &mut existe, aucun autre emprunt
(mutable ou partagé) n'est autorisé. Le compilateur refuse le code plutôt que de produire un
comportement indéfini.
6. Pourquoi c'est décisif pour la robotique
Imaginez une boucle de contrôle qui, à chaque itération, lit un vecteur d'état de plusieurs centaines de
composantes (positions articulaires, vitesses, forces). En le passant par emprunt &Vec<f64>
ou, mieux, par tranche &[f64], vous transmettez juste un pointeur + une longueur :
- Zéro copie — pas d'allocation, pas de recopie, coût constant et prévisible.
- Zéro dangling — le compilateur garantit que la référence ne survit pas à l'état.
- Zéro data race — le partage lecture/écriture est contrôlé statiquement.
/// Norme euclidienne d'un vecteur d'état, sans le copier.
fn norme(v: &[f64]) -> f64 {
v.iter().map(|x| x * x).sum::<f64>().sqrt()
}
fn boucle_controle(etat: &[f64]) {
// À 1 kHz, cet appel ne coûte rien de plus qu'un passage de pointeur.
let e = norme(etat);
println!("erreur = {e:.3}");
}
Préférez &[T] à &Vec<T> en paramètre. La tranche
&[f64] accepte aussi bien un Vec qu'un tableau fixe [f64; N] —
elle est plus générale et tout aussi gratuite. Utile quand une partie de votre code embarqué évite les
allocations dynamiques.
7. Notions de lifetimes
Une lifetime (durée de vie) est la portée pendant laquelle une référence reste valide. Le
compilateur les suit toujours en interne ; la plupart du temps il les infère pour vous
(on parle d'élision), et vous n'écrivez rien. Les annotations explicites, notées 'a,
n'apparaissent que lorsque le compilateur ne peut pas deviner seul la relation entre les durées de vie —
typiquement quand une fonction renvoie une référence.
/// Renvoie une référence vers le plus grand des deux flottants.
/// `'a` dit : « le résultat vit aussi longtemps que le PLUS COURT
/// des deux emprunts d'entrée ». C'est une promesse vérifiée à la compilation.
fn plus_grand<'a>(a: &'a f64, b: &'a f64) -> &'a f64 {
if a > b { a } else { b }
}
fn main() {
let x = 3.0;
let y = 7.0;
let m = plus_grand(&x, &y); // `m` emprunte `x` ou `y`
println!("max = {m}"); // OK tant que x et y sont vivants
}
Les lifetimes ne changent rien à l'exécution : elles ne coûtent rien et ne modifient pas le code généré. Ce sont uniquement des annotations qui permettent au borrow checker de prouver qu'aucune référence ne pointe vers de la mémoire libérée. C'est la garantie « pas de dangling reference » rendue explicite.
// Ce que Rust REFUSE de compiler — et heureusement :
fn dangling() -> &f64 {
let local = 42.0;
&local // ERREUR : `local` est libéré à la fin de la fonction
} // on retournerait un pointeur pendouillant, comme en C
En C++, retourner l'adresse d'une variable locale compile sans broncher et produit un use-after-return silencieux. En Rust, c'est une erreur de compilation nette. Les lifetimes sont le mécanisme qui rend cette vérification possible.
Exercices
Exercice 1 — Corriger une violation du borrow checker
Le code suivant est refusé par le compilateur. Trouvez pourquoi et corrigez-le.
fn main() {
let mut v = vec![10.0, 20.0, 30.0];
let premier = &v[0]; // emprunt partagé
v.push(40.0); // emprunt mutable pendant que `premier` vit
println!("{premier}");
}
Voir la solution
Problème : premier est un emprunt partagé &T, mais v.push()
exige un emprunt mutable &mut T. Les deux ne peuvent coexister. De plus push
pourrait réallouer le vecteur et invalider premier (exactement le bug C++ d'itérateur
invalidé). Il faut soit finir d'utiliser premier avant le push, soit lire une
copie de la valeur (elle est Copy) :
fn main() {
let mut v = vec![10.0, 20.0, 30.0];
let premier = v[0]; // f64 est Copy : on copie la valeur, pas d'emprunt persistant
v.push(40.0); // plus aucun emprunt partagé en vie : OK
println!("{premier}"); // affiche 10
}
Variante : mettre println!("{}", &v[0]); avant le push. L'emprunt se termine
alors avant la modification.
Exercice 2 — Écrire une fonction qui emprunte
Écrivez norme(v: &[f64]) -> f64 qui calcule la norme euclidienne d'un vecteur d'état
sans le déplacer ni le copier. Testez-la dans un main.
Voir la solution
fn norme(v: &[f64]) -> f64 {
v.iter()
.map(|x| x * x) // chaque composante au carré
.sum::<f64>() // somme des carrés
.sqrt() // racine
}
fn main() {
let etat = vec![3.0, 4.0];
println!("{}", norme(&etat)); // 5.0
println!("{}", norme(&etat)); // OK : `etat` n'a jamais été déplacé, on peut ré-emprunter
}
Le paramètre &[f64] est une tranche empruntée : aucune allocation, et
etat reste disponible après l'appel puisqu'il n'a pas cédé sa propriété.
Exercice 3 — Modifier un vecteur d'état via &mut
Écrivez saturer(v: &mut [f64], limite: f64) qui borne chaque composante dans
l'intervalle [-limite, +limite] (utile pour saturer des commandes moteur). La fonction ne
renvoie rien : elle modifie le vecteur en place.
Voir la solution
fn saturer(v: &mut [f64], limite: f64) {
for x in v.iter_mut() { // itération mutable : `x` est un &mut f64
*x = x.clamp(-limite, limite); // clamp borne la valeur
}
}
fn main() {
let mut commandes = vec![0.5, -3.0, 2.2, -0.1];
saturer(&mut commandes, 1.0);
println!("{commandes:?}"); // [0.5, -1.0, 1.0, -0.1]
}
&mut [f64] est un emprunt exclusif : tant que saturer s'exécute, personne
d'autre ne peut lire ni écrire commandes. La modification se fait en place, sans allocation.
Exercice 4 — Pourquoi ça ne compile pas ?
Expliquez précisément l'erreur produite par ce code.
fn main() {
let trajectoire = String::from("A -> B -> C");
let copie = trajectoire; // (1)
println!("origine : {trajectoire}"); // (2)
println!("copie : {copie}");
}
Voir la solution
Ligne (1) : String n'est pas Copy (elle possède de la mémoire sur le tas).
L'affectation est donc un move : la propriété passe de trajectoire à
copie, et trajectoire devient invalide.
Ligne (2) : on tente d'utiliser trajectoire après son déplacement. Le compilateur
répond error[E0382]: borrow of moved value: `trajectoire`. C'est le borrow checker
qui empêche un use-after-move (l'équivalent conceptuel d'un use-after-free).
Deux corrections possibles selon l'intention :
// (a) On veut deux chaînes indépendantes : copie explicite
let copie = trajectoire.clone();
println!("origine : {trajectoire}"); // OK, trajectoire est toujours propriétaire
// (b) On veut juste lire les deux fois la même donnée : emprunt
let copie = &trajectoire; // emprunt partagé, pas de move
println!("origine : {trajectoire}");
println!("copie : {copie}");
Récapitulatif
- 3 règles d'ownership : une valeur a un unique propriétaire ; un seul à la fois ; libération automatique (
drop) en fin de scope — le RAII généralisé et vérifié. - Move par défaut pour les types possédant une ressource (
String,Vec…) : l'ancienne variable devient invalide. Copy pour les scalaires. clone()= copie profonde explicite et coûteuse — visible dans le code, à bannir des boucles de contrôle.- Emprunts :
&Tpartagé (N lecteurs) ou&mut Texclusif (1 écrivain), jamais les deux à la fois. - Robotique : passer l'état par
&[f64]= zéro copie, zéro dangling, zéro data race, coût prévisible. - Lifetimes : annotations
'asouvent inférées (élision) ; garantissent statiquement qu'aucune référence ne survit à sa donnée. Aucun coût à l'exécution. - Zéro pointeur nul, zéro use-after-free : garanti à la compilation, sans garbage collector.