🦀 Rust pour la robotique · temps réel

Chapitre 03
Fonctions & contrôle de flux

Objectifs du chapitre

1. Déclarer une fonction

En Rust, une fonction se déclare avec le mot-clé fn. Chaque paramètre doit avoir un type explicite (pas d'inférence sur les paramètres, contrairement aux variables locales), et le type de retour s'annonce après une flèche ->. Si la fonction ne retourne rien, on omet la flèche (le type de retour est alors implicitement (), le « tuple unité »).

// Convertit une vitesse angulaire en tours par seconde.
fn rad_par_s_vers_tours_par_s(omega: f64) -> f64 {
    omega / (2.0 * std::f64::consts::PI)
}

// Fonction sans valeur de retour : type () implicite.
fn journaliser(message: &str) {
    println!("[LOG] {message}");
}

fn main() {
    let vitesse = rad_par_s_vers_tours_par_s(31.4);
    journaliser(&format!("vitesse = {vitesse:.3} tr/s"));
}

Contrairement au C, l'ordre de déclaration des fonctions au sein d'un module n'a pas d'importance : main peut appeler une fonction définie plus bas dans le fichier sans prototype préalable.

2. Expressions vs instructions

C'est l'un des points qui surprend le plus les personnes venant du C/C++. En Rust, presque tout est une expression qui produit une valeur : un bloc { ... }, un if, un match. Une instruction (comme une affectation let x = 5;), elle, ne produit rien et se termine par un point-virgule.

La règle clé : dans un bloc de fonction, si la dernière ligne n'a pas de point-virgule, sa valeur devient la valeur de retour du bloc (donc, en fin de fonction, la valeur de retour de la fonction). Ajouter un point-virgule par réflexe — comme en C où chaque ligne se termine ainsi — transforme l'expression en instruction qui retourne (), ce qui casse la compilation si le type de retour attendu n'est pas ().

fn carre(x: f64) -> f64 {
    x * x   // pas de point-virgule : c'est la valeur retournée
}

fn carre_bugue(x: f64) -> f64 {
    x * x;  // erreur de compilation : ceci retourne (), pas f64
}

Ajouter un ; à la dernière ligne d'une fonction est l'erreur numéro un des débutant·e·s venant du C/C++. Le compilateur Rust affiche alors un message explicite du type « expected `f64`, found `()`» — c'est un excellent réflexe de vérifier immédiatement la dernière ligne du bloc quand cette erreur apparaît.

On peut bien sûr utiliser return pour un retour anticipé, typiquement dans une garde en début de fonction — le mot-clé reste utile et parfaitement idiomatique dans ce cas :

fn inverse(x: f64) -> Option<f64> {
    if x == 0.0 {
        return None; // retour anticipé explicite
    }
    Some(1.0 / x)
}

3. if/else comme expression

Puisque if est une expression, on peut directement l'assigner à une variable — plus besoin de l'opérateur ternaire ?: du C. Les deux branches doivent impérativement retourner le même type.

fn saturer(valeur: f64, limite: f64) -> f64 {
    let resultat = if valeur > limite {
        limite
    } else if valeur < -limite {
        -limite
    } else {
        valeur
    };
    resultat
}

Comme les deux branches d'un if-expression doivent avoir le même type, le compilateur détecte immédiatement les incohérences (par exemple oublier une branche else alors que la valeur est utilisée) — une classe entière de bugs de logique de commande est éliminée à la compilation.

4. match : le switch qui ne laisse rien passer

match ressemble au switch du C, mais avec deux différences majeures. D'abord, il est exhaustif : le compilateur refuse de compiler si un cas possible n'est pas couvert (fini les oublis de default qui font planter le robot en silence). Ensuite, match est lui aussi une expression qui peut produire une valeur, et il n'y a jamais de fall-through implicite comme en C — pas besoin de break.

fn classer_code_erreur(code: i32) -> &'static str {
    match code {
        0 => "OK",
        1 | 2 => "avertissement mineur",       // plusieurs motifs
        3..=9 => "erreur récupérable",          // plage inclusive
        10..=99 => "erreur critique",
        _ => "code inconnu",                    // motif « fourre-tout » obligatoire
    }
}

Le motif _ joue le rôle du default du C, mais ici il n'est requis que si les autres motifs ne couvrent pas déjà toutes les valeurs possibles du type. Sur une énumération (enum), il est souvent possible — et préférable — de couvrir explicitement chaque variante plutôt que de se reposer sur _, afin que le compilateur signale toute variante oubliée si l'énumération évolue plus tard.

enum EtatCapteur {
    Ok,
    HorsLimite,
    Deconnecte,
}

fn classer_capteur(etat: &EtatCapteur) -> &'static str {
    match etat {
        EtatCapteur::Ok => "capteur nominal",
        EtatCapteur::HorsLimite => "valeur hors plage — vérifier le montage",
        EtatCapteur::Deconnecte => "capteur absent du bus",
        // pas de `_` nécessaire : les trois variantes sont couvertes
    }
}

En robotique, cette exhaustivité est précieuse : si on ajoute plus tard une variante EtatCapteur::Saturation, chaque match sur cette énumération qui ne la gère pas encore provoquera une erreur de compilation, pas un bug silencieux découvert en test sur banc, voire pire, sur le robot en mouvement.

5. Les trois formes de boucles

Rust propose loop, while et for. Contrairement au C, il n'y a pas de boucle for à trois clauses (for(i=0; i<n; i++)) : on itère sur un itérateur, le plus souvent une plage (range).

// loop : boucle infinie explicite, sort avec break (éventuellement avec une valeur)
fn attendre_stabilisation(seuil: f64, mesures: &[f64]) -> usize {
    let mut i = 0;
    let indice_stable = loop {
        if i >= mesures.len() || mesures[i].abs() < seuil {
            break i; // `break` avec une valeur : loop devient une expression
        }
        i += 1;
    };
    indice_stable
}

// while : condition classique évaluée avant chaque itération
fn compter_jusqu_a_zero(mut compteur: i32) {
    while compteur > 0 {
        compteur -= 1;
    }
}

// for : itération sur une plage — bornée par construction
fn somme_plage(n: u32) -> u32 {
    let mut total = 0;
    for i in 0..n {       // 0..n : exclut n
        total += i;
    }
    total
}

fn somme_plage_inclusive(n: u32) -> u32 {
    let mut total = 0;
    for i in 0..=n {      // 0..=n : inclut n
        total += i;
    }
    total
}

// for sur une collection : pas d'indices à gérer manuellement
fn moyenne(mesures: &[f64]) -> f64 {
    let mut somme = 0.0;
    for m in mesures {
        somme += m;
    }
    somme / mesures.len() as f64
}

Les étiquettes de boucle (labels) permettent de cibler précisément quelle boucle break ou continue affecte, très utile dans des boucles imbriquées de balayage de capteurs ou de recherche dans une grille d'occupation :

fn chercher_collision(grille: &[[bool; 4]; 4]) -> Option<(usize, usize)> {
    let mut trouve = None;
    'lignes: for i in 0..4 {
        for j in 0..4 {
            if grille[i][j] {
                trouve = Some((i, j));
                break 'lignes; // sort directement de la boucle externe étiquetée
            }
        }
    }
    trouve
}

Dans un contexte temps réel, privilégiez systématiquement les boucles bornées et déterministes : un for i in 0..n sur une plage ou une collection de taille connue a un temps d'exécution prévisible. Un loop ou un while dont la condition de sortie dépend d'une convergence numérique (filtrage, recherche de racine, stabilisation) peut, en théorie, ne jamais se terminer dans le budget de temps imparti à une tâche périodique : bornez toujours ces boucles avec un compteur maximal d'itérations, quitte à sortir en échec explicite plutôt que de dépasser l'échéance (deadline) de la boucle de contrôle.

6. Fil rouge : conversions et classification d'angles

Reprenons le fil rouge du cours : un petit module de traitement d'angles et de capteurs, tel qu'on pourrait en écrire pour un bras robotique ou une base mobile.

use std::f64::consts::PI;

/// Convertit un angle de degrés vers radians.
fn deg_to_rad(deg: f64) -> f64 {
    deg * PI / 180.0
}

/// Ramène un angle (en radians) dans l'intervalle [-π, π],
/// utile après une accumulation d'angle sur plusieurs tours de roue.
fn normaliser_angle(angle: f64) -> f64 {
    // rem_euclid garantit un reste toujours positif, contrairement à `%` en Rust
    // (comme en C, `%` peut renvoyer un résultat négatif pour un dividende négatif).
    let deux_pi = 2.0 * PI;
    let reste = angle.rem_euclid(deux_pi); // reste dans [0, 2π)
    if reste > PI {
        reste - deux_pi
    } else {
        reste
    }
}

enum EtatCapteur {
    Ok,
    HorsLimite,
}

/// Classe une mesure de capteur selon une plage de fonctionnement nominale.
fn classer_mesure(valeur: f64, min: f64, max: f64) -> EtatCapteur {
    match valeur {
        v if v >= min && v <= max => EtatCapteur::Ok,
        _ => EtatCapteur::HorsLimite,
    }
}

fn main() {
    let angle_deg = 450.0;
    let angle_rad = deg_to_rad(angle_deg);
    let angle_norm = normaliser_angle(angle_rad);
    println!("{angle_deg}° -> {angle_rad:.3} rad -> normalisé {angle_norm:.3} rad");

    match classer_mesure(2.7, 0.0, 2.5) {
        EtatCapteur::Ok => println!("capteur dans la plage attendue"),
        EtatCapteur::HorsLimite => println!("capteur hors limite !"),
    }
}

Notez le motif v if condition => dans classer_mesure : c'est une garde de motif (match guard), qui ajoute une condition booléenne arbitraire au filtrage. On aurait aussi pu écrire normaliser_angle avec une boucle while angle > PI { angle -= deux_pi } / while angle < -PI { angle += deux_pi } — correct mathématiquement, mais non borné en nombre d'itérations si l'angle d'entrée est énorme (accumulation d'erreurs sur une longue mission). La version avec rem_euclid est préférable en temps réel car elle s'exécute en temps constant, quelle que soit la valeur d'entrée.

Exercices

Exercice 1 — Normaliser un angle

Écrivez une fonction fn normaliser_angle(angle: f64) -> f64 qui ramène n'importe quel angle en radians dans l'intervalle [-π, π]. Testez-la avec des valeurs comme 3.5 * PI et -4.0 * PI.

Voir la solution
use std::f64::consts::PI;

fn normaliser_angle(angle: f64) -> f64 {
    let deux_pi = 2.0 * PI;
    let reste = angle.rem_euclid(deux_pi); // toujours dans [0, 2π)
    if reste > PI {
        reste - deux_pi
    } else {
        reste
    }
}

fn main() {
    println!("{:.4}", normaliser_angle(3.5 * PI));  // -1.5708
    println!("{:.4}", normaliser_angle(-4.0 * PI));  // -0.0000 (zéro signé, mathématiquement égal à 0)
}

rem_euclid évite le piège du reste négatif de l'opérateur % et exécute l'opération en temps constant, sans boucle — un choix naturel pour du code temps réel.

Exercice 2 — Puissance entière

Écrivez une fonction fn puissance(base: f64, exposant: u32) -> f64 qui calcule base élevé à la puissance exposant à l'aide d'une boucle for (sans utiliser f64::powi). Réfléchissez au cas exposant == 0.

Voir la solution
fn puissance(base: f64, exposant: u32) -> f64 {
    let mut resultat = 1.0;
    for _ in 0..exposant {
        resultat *= base;
    }
    resultat
}

fn main() {
    println!("{}", puissance(2.0, 10)); // 1024
    println!("{}", puissance(5.0, 0));  // 1 (le cas 0 fonctionne naturellement : 0..0 est vide)
}

La boucle for _ in 0..exposant est bornée par construction : elle s'exécute exactement exposant fois, jamais plus — un exemple typique de boucle « temps réel friendly ».

Exercice 3 — Classer un état de capteur

Définissez une énumération enum EtatCapteur { Ok, HorsLimite } et une fonction fn diagnostiquer(valeur: f64, min: f64, max: f64) -> EtatCapteur qui utilise match avec une garde de motif pour renvoyer EtatCapteur::Ok si valeur est dans [min, max], sinon EtatCapteur::HorsLimite. Affichez le résultat avec un second match exhaustif.

Voir la solution
enum EtatCapteur {
    Ok,
    HorsLimite,
}

fn diagnostiquer(valeur: f64, min: f64, max: f64) -> EtatCapteur {
    match valeur {
        v if v >= min && v <= max => EtatCapteur::Ok,
        _ => EtatCapteur::HorsLimite,
    }
}

fn main() {
    let etat = diagnostiquer(37.2, 0.0, 40.0);
    match etat {
        EtatCapteur::Ok => println!("température nominale"),
        EtatCapteur::HorsLimite => println!("alerte : température hors limite"),
    }
}

Si l'on ajoutait plus tard une variante EtatCapteur::Deconnecte, le second match refuserait de compiler tant qu'il ne la traite pas explicitement (ou via _) — c'est l'exhaustivité de match au travail.

Exercice 4 — Somme avec for

Écrivez une fonction fn somme_carres(n: u32) -> u32 qui calcule la somme des carrés de 0 à n inclus (0² + 1² + ... + n²) à l'aide d'un for sur une plage inclusive.

Voir la solution
fn somme_carres(n: u32) -> u32 {
    let mut total: u32 = 0;
    for i in 0..=n {
        total += i * i;
    }
    total
}

fn main() {
    println!("{}", somme_carres(3)); // 0+1+4+9 = 14
}

La plage 0..=n inclut n, contrairement à 0..n. Sur des entrées volontairement grandes, méfiez-vous d'un dépassement de capacité (overflow) sur u32 — un sujet abordé plus en détail au chapitre sur la gestion des erreurs.

Récapitulatif