🦀 Rust pour la robotique · temps réel

Chapitre 04
Ownership, emprunts & lifetimes

Objectifs du chapitre

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 :

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 :

  1. Chaque valeur a un propriétaire (une variable).
  2. Il ne peut y avoir qu'un seul propriétaire à la fois.
  3. 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 :

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 :

/// 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