🩀 Rust pour la robotique · temps rĂ©el

Chapitre 06
Collections : Vec, tableaux, slices

Objectifs du chapitre

1. Tableaux Ă  taille fixe [T; N]

Un tableau Rust [T; N] est l'Ă©quivalent direct d'un tableau C T tab[N]; : sa taille N fait partie du type, elle est connue Ă  la compilation, et le tableau vit entiĂšrement sur la pile (aucune allocation dynamique). C'est le choix par dĂ©faut en contexte temps rĂ©el dĂšs que la dimension est fixe — un Ă©tat de robot Ă  3 degrĂ©s de libertĂ©, un buffer de mesures IMU Ă  taille constante, etc.

fn main() {
    // Un état [x, y, theta] : position + orientation d'un robot mobile.
    let etat: [f64; 3] = [0.0, 0.0, 0.0];

    // Initialisation répétée d'une valeur : [valeur; N]
    let zeros: [f64; 5] = [0.0; 5];

    println!("etat = {:?}", etat);
    println!("taille = {} éléments, {} octets",
        etat.len(), std::mem::size_of_val(&etat));

    // AccÚs par index : identique au C, mais vérifié à l'exécution.
    let x = etat[0];
    println!("x = {x}");
}

Comparaison avec C : en C, int tab[3]; ne connaĂźt pas sa propre taille une fois passĂ© Ă  une fonction (il « decay » en pointeur) — il faut la transmettre Ă  part. En Rust, le type [f64; 3] garde sa taille dans la signature de la fonction, et le compilateur vĂ©rifie tout Ă  la compilation. Il n'y a ni allocation, ni pointeur cachĂ© : la disposition mĂ©moire est identique Ă  celle du tableau C, avec en plus les vĂ©rifications de bornes.

Un tableau fixe implĂ©mente Copy si son type d'Ă©lĂ©ments l'implĂ©mente (c'est le cas de f64). L'affecter Ă  une autre variable en fait donc une copie complĂšte, comme en C — pas de dĂ©placement (move) ni d'emprunt implicite.

2. Vec<T> : le tableau dynamique

Vec<T> est l'Ă©quivalent de std::vector<T> en C++ : une structure de trois champs (pointeur vers le tas, longueur, capacitĂ©) qui gĂšre elle-mĂȘme son allocation. Utile quand la taille n'est pas connue Ă  la compilation — une trajectoire chargĂ©e depuis un fichier, une liste d'obstacles dĂ©tectĂ©s, etc.

fn main() {
    let mut mesures: Vec<f64> = Vec::new();

    mesures.push(1.2);
    mesures.push(3.4);
    mesures.push(-0.8);

    println!("len = {}, capacity = {}", mesures.len(), mesures.capacity());

    let dernier = mesures.pop(); // Option<f64> : Some(-0.8) ici
    println!("dernier retiré : {:?}", dernier);

    // Indexation comme un tableau classique.
    println!("mesures[0] = {}", mesures[0]);
}

Un Vec commence avec une capacitĂ© de 0. Chaque push qui dĂ©passe la capacitĂ© actuelle dĂ©clenche une rĂ©allocation : le runtime alloue un nouveau bloc mĂ©moire (typiquement le double de la taille prĂ©cĂ©dente), copie tous les Ă©lĂ©ments existants, puis libĂšre l'ancien bloc. C'est coĂ»teux et surtout imprĂ©visible en durĂ©e — problĂ©matique dans une boucle de contrĂŽle temps rĂ©el. Si la taille finale est connue Ă  l'avance, on la rĂ©serve d'un coup :

fn main() {
    // Réserve la capacité pour 100 éléments en une seule allocation.
    let mut trajectoire: Vec<f64> = Vec::with_capacity(100);

    for i in 0..100 {
        trajectoire.push(i as f64 * 0.01);
    }

    // Aucune réallocation n'a eu lieu pendant la boucle : capacity >= 100 dÚs le départ.
    println!("len = {}, capacity = {}", trajectoire.len(), trajectoire.capacity());
}

Dans une boucle de contrÎle qui tourne à fréquence fixe (ex. 1 kHz), éviter toute allocation : préférer un tableau fixe [f64; N] quand la taille est connue, ou un Vec créé une seule fois avant la boucle avec Vec::with_capacity puis réutilisé (clear() + push, ou écriture directe par index) plutÎt que recréé à chaque itération. Une allocation/désallocation dans le chemin chaud introduit une latence non déterministe qui peut casser une échéance temps réel.

3. Slices &[T] et &mut [T]

Une slice est une vue empruntĂ©e sur une sĂ©quence contiguĂ« d'Ă©lĂ©ments — un pointeur vers le dĂ©but plus une longueur, sans possĂ©der les donnĂ©es ni les copier. On peut obtenir une slice aussi bien Ă  partir d'un tableau fixe que d'un Vec, ce qui en fait le type idĂ©al pour Ă©crire des fonctions gĂ©nĂ©riques indĂ©pendantes du conteneur d'origine.

// Fonction générique : accepte un tableau fixe, un Vec, ou une autre slice.
fn norme(v: &[f64]) -> f64 {
    v.iter().map(|x| x * x).sum::<f64>().sqrt()
}

fn mettre_a_zero(v: &mut [f64]) {
    for x in v.iter_mut() {
        *x = 0.0;
    }
}

fn main() {
    let etat_fixe: [f64; 3] = [3.0, 4.0, 0.0];
    let mut etat_vec: Vec<f64> = vec![3.0, 4.0, 0.0];

    // &etat_fixe et &etat_vec[..] se convertissent tous deux en &[f64]
    println!("norme (tableau) = {}", norme(&etat_fixe));
    println!("norme (Vec)     = {}", norme(&etat_vec));

    mettre_a_zero(&mut etat_vec);
    println!("aprÚs remise à zéro : {:?}", etat_vec);

    // Sous-slice : les 2 premiers éléments seulement.
    let debut = &etat_fixe[0..2];
    println!("debut = {:?}", debut);
}

La rÚgle de conception à retenir : quand une fonction n'a besoin que de lire ou modifier des éléments sans changer la taille de la collection, on la fait accepter une slice (&[T] ou &mut [T]) plutÎt qu'un &Vec<T> ou un &[T; N] précis. C'est plus flexible pour l'appelant, sans coût : aucune copie n'a lieu, la slice est juste un pointeur + une longueur passés par valeur.

4. AccÚs sûr : indexation vs .get()

En C/C++, lire tab[i] avec un i hors bornes est un comportement indéfini : le programme peut lire n'importe quelle mémoire adjacente, silencieusement, et planter bien plus tard sans lien apparent avec la cause réelle. En Rust, l'indexation v[i] est vérifiée à l'exécution : si i dépasse la longueur, le programme panique immédiatement avec un message clair, plutÎt que de corrompre la mémoire en silence.

fn main() {
    let mesures = vec![1.0, 2.0, 3.0];

    // Panic contrÎlé si l'index est hors bornes (message explicite, pas de UB).
    // let x = mesures[10]; // décommenter -> "index out of bounds: the len is 3 but the index is 10"

    // Alternative sûre : .get() renvoie une Option, jamais de panic.
    match mesures.get(10) {
        Some(x) => println!("valeur = {x}"),
        None => println!("index hors bornes, valeur ignorée"),
    }

    // Idiomatique avec unwrap_or : valeur par défaut si absent.
    let x = mesures.get(1).copied().unwrap_or(0.0);
    println!("x = {x}");
}

Contrairement au C oĂč un dĂ©passement de tableau peut passer totalement inaperçu (et provoquer un bug des heures plus tard, ailleurs dans le programme), Rust ne laisse jamais un accĂšs hors borne silencieux : c'est v[i] qui panique immĂ©diatement, ou v.get(i) qui force explicitement Ă  gĂ©rer le cas d'absence via Option. Dans du code embarquĂ© critique, on prĂ©fĂšre souvent .get() (ou une vĂ©rification manuelle de longueur) pour Ă©viter tout panic imprĂ©vu en production.

5. Itérateurs de base

Rust encourage Ă  parcourir les collections via des itĂ©rateurs plutĂŽt que des boucles indexĂ©es manuelles — plus lisible, et le compilateur optimise gĂ©nĂ©ralement ce code aussi bien qu'une boucle C avec index. Un aperçu suffisant pour ce chapitre ; les itĂ©rateurs seront approfondis plus tard (adaptateurs paresseux, collect, performances).

fn main() {
    let vitesses: Vec<f64> = vec![0.5, 1.2, 0.8, 2.0];

    // Parcours simple par référence : ne consomme pas le Vec.
    for v in &vitesses {
        print!("{v} ");
    }
    println!();

    // .iter() explicite (équivalent à &vitesses dans un for), avec index via enumerate().
    for (i, v) in vitesses.iter().enumerate() {
        println!("vitesse[{i}] = {v}");
    }

    // .iter_mut() pour modifier en place.
    let mut copie = vitesses.clone();
    for v in copie.iter_mut() {
        *v *= 2.0;
    }
    println!("doublées = {:?}", copie);

    // .map() + .sum() : somme des carrés (utile pour une norme).
    let somme_carres: f64 = vitesses.iter().map(|v| v * v).sum();
    println!("somme des carrés = {somme_carres}");

    // .zip() : parcourir deux collections en parallÚle, élément par élément.
    // TrÚs utile pour les opérations vectorielles (addition, produit scalaire...).
    let a = vec![1.0, 2.0, 3.0];
    let b = vec![4.0, 5.0, 6.0];
    let produits: Vec<f64> = a.iter().zip(b.iter()).map(|(x, y)| x * y).collect();
    println!("produits terme Ă  terme = {:?}", produits);
}

6. Fil rouge : vecteurs d'état, trajectoires, opérations vectorielles

Pour reprĂ©senter l'Ă©tat d'un robot, deux choix lĂ©gitimes selon le contexte : [f64; N] quand N est fixe et connu (ex. un bras Ă  6 axes, toujours [f64; 6]), ou Vec<f64> quand la dimension varie selon la configuration chargĂ©e au dĂ©marrage. Une trajectoire — une suite de points Vec2 Ă©chantillonnĂ©s dans le temps — se reprĂ©sente naturellement en Vec<Vec2>, puisque son nombre de points n'est pas connu Ă  la compilation.

#[derive(Debug, Clone, Copy)]
struct Vec2 {
    x: f64,
    y: f64,
}

/// Norme euclidienne d'un vecteur donné sous forme de slice.
/// Accepte aussi bien un [f64; N] qu'un Vec<f64>, grĂące Ă  &[f64].
fn norme(v: &[f64]) -> f64 {
    v.iter().map(|c| c * c).sum::<f64>().sqrt()
}

/// Produit scalaire de deux vecteurs de mĂȘme longueur, via zip.
fn produit_scalaire(a: &[f64], b: &[f64]) -> f64 {
    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}

/// Addition Ă©lĂ©ment par Ă©lĂ©ment de deux vecteurs de mĂȘme longueur.
fn addition(a: &[f64], b: &[f64]) -> Vec<f64> {
    a.iter().zip(b.iter()).map(|(x, y)| x + y).collect()
}

fn longueur_trajectoire(traj: &[Vec2]) -> f64 {
    let mut total = 0.0;
    for fenetre in traj.windows(2) {
        let dx = fenetre[1].x - fenetre[0].x;
        let dy = fenetre[1].y - fenetre[0].y;
        total += (dx * dx + dy * dy).sqrt();
    }
    total
}

fn main() {
    // État à dimension fixe : bras 6 axes, angles en radians.
    let etat_bras: [f64; 6] = [0.0, 0.3, -0.2, 0.0, 1.1, 0.0];
    println!("norme de l'état bras = {:.3}", norme(&etat_bras));

    // Deux vecteurs vitesse/accélération (dimension dynamique).
    let vitesse: Vec<f64> = vec![0.4, -0.1, 0.05];
    let acceleration: Vec<f64> = vec![0.02, 0.0, -0.01];

    println!("produit scalaire = {}", produit_scalaire(&vitesse, &acceleration));
    println!("somme            = {:?}", addition(&vitesse, &acceleration));

    // Trajectoire : suite de points 2D échantillonnés.
    let trajectoire: Vec<Vec2> = vec![
        Vec2 { x: 0.0, y: 0.0 },
        Vec2 { x: 1.0, y: 0.0 },
        Vec2 { x: 1.0, y: 1.0 },
    ];
    println!("longueur trajectoire = {:.3}", longueur_trajectoire(&trajectoire));
}

.windows(2) renvoie des slices glissantes de 2 Ă©lĂ©ments consĂ©cutifs — trĂšs pratique pour calculer des diffĂ©rences entre points successifs d'une trajectoire, sans gĂ©rer manuellement les index i et i-1 comme on le ferait en C.

Exercices

Exercice 1 — Somme et moyenne d'un Vec

Écris une fonction moyenne(v: &[f64]) -> f64 qui renvoie la moyenne arithmĂ©tique des Ă©lĂ©ments d'une slice de f64 (renvoie 0.0 si la slice est vide, pour Ă©viter une division par zĂ©ro). Teste-la avec un Vec<f64> contenant quelques mesures.

Voir la solution
fn moyenne(v: &[f64]) -> f64 {
    if v.is_empty() {
        return 0.0;
    }
    let somme: f64 = v.iter().sum();
    somme / v.len() as f64
}

fn main() {
    let mesures: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0];
    println!("moyenne = {}", moyenne(&mesures));

    let vide: Vec<f64> = Vec::new();
    println!("moyenne (vide) = {}", moyenne(&vide));
}

v.iter().sum() additionne tous les éléments de la slice ; le test is_empty() évite le piÚge classique de la division par zéro sur un tableau vide, qui en C produirait un comportement indéfini plutÎt qu'un NaN ou une erreur explicite.

Exercice 2 — Produit scalaire avec .zip()

Sans regarder la section 6, réécris produit_scalaire(a: &[f64], b: &[f64]) -> f64 en utilisant .zip(), .map() et .sum(). Vérifie-la avec deux slices de longueur 3.

Voir la solution
fn produit_scalaire(a: &[f64], b: &[f64]) -> f64 {
    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}

fn main() {
    let a = [1.0, 2.0, 3.0];
    let b = [4.0, 5.0, 6.0];
    // 1*4 + 2*5 + 3*6 = 4 + 10 + 18 = 32
    println!("produit scalaire = {}", produit_scalaire(&a, &b));
}

zip combine deux itĂ©rateurs en un itĂ©rateur de paires (x, y), en s'arrĂȘtant dĂšs que le plus court des deux est Ă©puisĂ© — utile si les slices n'ont pas exactement la mĂȘme longueur, mĂȘme si dans un vrai calcul vectoriel on vĂ©rifierait gĂ©nĂ©ralement l'Ă©galitĂ© des longueurs avant.

Exercice 3 — Trouver le maximum avec .iter()

Écris une fonction maximum(v: &[f64]) -> Option<f64> qui renvoie la plus grande valeur d'une slice, ou None si elle est vide. Indice : les f64 n'implĂ©mentant pas Ord (Ă  cause de NaN), .max() classique ne fonctionne pas directement — utilise plutĂŽt .fold() ou f64::max.

Voir la solution
fn maximum(v: &[f64]) -> Option<f64> {
    if v.is_empty() {
        return None;
    }
    let mut max = v[0];
    for &x in v.iter() {
        if x > max {
            max = x;
        }
    }
    Some(max)
}

fn main() {
    let mesures = [3.0, 7.5, -2.0, 4.1];
    println!("max = {:?}", maximum(&mesures));

    let vide: [f64; 0] = [];
    println!("max (vide) = {:?}", maximum(&vide));
}

On aurait aussi pu Ă©crire v.iter().copied().fold(f64::MIN, f64::max), mais cette version renverrait f64::MIN plutĂŽt que None pour une slice vide — moins explicite. Retourner un Option force l'appelant Ă  gĂ©rer le cas « aucune donnĂ©e », ce qu'un -inf silencieux en C laisserait facilement passer inaperçu.

Exercice 4 — Addition de deux vecteurs Ă©lĂ©ment par Ă©lĂ©ment

Écris une fonction addition(a: &[f64], b: &[f64]) -> Vec<f64> qui renvoie un nouveau Vec contenant la somme terme Ă  terme de deux slices de mĂȘme longueur (on suppose ici qu'elles ont la mĂȘme longueur). Utilise Vec::with_capacity pour rĂ©server la place Ă  l'avance plutĂŽt que de laisser le Vec rĂ©allouer au fil des push.

Voir la solution
fn addition(a: &[f64], b: &[f64]) -> Vec<f64> {
    debug_assert_eq!(a.len(), b.len(), "les deux vecteurs doivent avoir la mĂȘme longueur");

    let mut resultat = Vec::with_capacity(a.len());
    for (x, y) in a.iter().zip(b.iter()) {
        resultat.push(x + y);
    }
    resultat
}

fn main() {
    let vitesse = vec![0.4, -0.1, 0.05];
    let correction = vec![0.02, 0.0, -0.01];

    println!("somme = {:?}", addition(&vitesse, &correction));
}

Vec::with_capacity(a.len()) alloue une seule fois la mĂ©moire nĂ©cessaire pour tous les rĂ©sultats : les appels suivants Ă  push ne dĂ©clenchent aucune rĂ©allocation, puisque la capacitĂ© rĂ©servĂ©e est suffisante dĂšs le dĂ©part. On aurait aussi pu Ă©crire a.iter().zip(b.iter()).map(|(x, y)| x + y).collect() — plus concis, et collect rĂ©serve lui-mĂȘme la bonne capacitĂ© en amont.

Récapitulatif