Contrôle de flux et expressions

Rust est un langage orienté expression : la plupart de ses constructions de contrôle produisent une valeur au lieu de se contenter de piloter l’exécution. Comprendre ce principe, et le type ! qui le rend cohérent, explique une bonne partie de la syntaxe idiomatique.

Presque tout est une expression

Rust distingue :

  • les instructions (statements), qui ne produisent pas de valeur et se terminent par un point-virgule ;
  • les expressions, qui en produisent une.

Un bloc { } est lui-même une expression : sa valeur est celle de sa dernière expression, écrite sans point-virgule.

let y = {
    let x = 3;
    x + 1           // Le bloc vaut 4
};

Ajouter un point-virgule change la valeur du bloc en (), le tuple vide — c’est la cause la plus fréquente de l’erreur expected i32, found ().

fn additionner(a: i32, b: i32) -> i32 {
    a + b           // Expression de retour
}

fn cassee(a: i32, b: i32) -> i32 {
    a + b;          // Erreur : le bloc vaut (), pas i32
}

Le corollaire pratique : if, match et loop s’utilisent partout où une valeur est attendue.

let etiquette = if nombre % 2 == 0 { "pair" } else { "impair" };

let mention = match note {
    0..=9 => "Insuffisant",
    10..=13 => "Passable",
    _ => "Bien",
};

let premiere_puissance = loop {
    candidat *= 2;
    if candidat > seuil {
        break candidat;
    }
};
Note

C’est pourquoi Rust n’a pas d’opérateur ternaire : if en expression le remplace exactement, sans syntaxe supplémentaire.

Les branches doivent alors toutes produire le même type. if sans else vaut (), et ne peut donc être affecté qu’à une variable de type ().

Les conditions

if nombre % 2 == 0 {
    println!("Pair");
} else if nombre % 3 == 0 {
    println!("Divisible par 3");
} else {
    println!("Impair");
}

La condition doit obligatoirement être un bool. Contrairement au C, if nombre { ... } ne compile pas : il n’existe pas de conversion implicite vers un booléen.

Pour le filtrage par motif — match, if let, while let, let ... else — voir Déstructuration et patterns, qui traite le sujet en détail.

Les boucles

Rust propose trois formes, sans équivalent du do...while :

// loop : infinie, et seule forme pouvant renvoyer une valeur
loop {
    // ...
}

// while : tant que la condition est vraie
while compteur != 0 {
    compteur -= 1;
}

// for : parcours d'un itérateur
for i in 0..5 {
    println!("{i}");
}

for est de loin la forme la plus courante. Elle repose entièrement sur le trait IntoIterator, comme détaillé dans Les itérateurs.

break avec valeur

Seul loop peut renvoyer une valeur, en la passant à break :

let mut compteur = 0;
let resultat = loop {
    compteur += 1;
    if compteur == 10 {
        break compteur * 2;     // resultat vaut 20
    }
};

C’est la forme idiomatique du « répéter jusqu’à obtenir quelque chose » : une tentative de connexion, un tirage jusqu’à satisfaction d’un critère, la lecture jusqu’à une sentinelle.

Les étiquettes

Une étiquette, préfixée d’une apostrophe, permet de cibler un break ou un continue sur une boucle englobante :

'externe: for i in 0..5 {
    for j in 0..5 {
        if i * j > 6 {
            break 'externe;
        }
    }
}

Les blocs étiquetés

Depuis Rust 1.65, un bloc peut lui aussi porter une étiquette et recevoir un break. Il ne boucle pas : il offre une sortie anticipée vers un point précis, avec une valeur.

let resultat = 'recherche: {
    for candidat in &liste {
        if candidat.correspond() {
            break 'recherche Some(candidat);
        }
    }
    None
};
Tip

C’est le remplaçant direct des drapeaux booléens et des variables resultat initialisées à None que l’on trouve dans le code venu d’autres langages. Contrairement à return, la sortie reste locale : la fonction poursuit son exécution après le bloc.

Le type ! et la divergence

Certaines expressions ne renvoient jamais de valeur, parce qu’elles interrompent le flot : return, break, continue, panic!, std::process::exit, ou une boucle infinie sans break. Leur type est !, appelé never type ou type divergent.

La propriété essentielle de ! est qu’il s’unifie avec n’importe quel autre type. Une expression divergente est donc acceptée dans une branche censée produire un i32, un String ou n’importe quoi d’autre :

let x: i32 = if condition { 1 } else { return };
//                                     ^^^^^^ de type !, accepté ici

let n: i32 = match option {
    Some(n) => n,
    None => panic!("valeur absente"),   // ! s'unifie avec i32
};

let valeur: i32 = loop { break 3 };

C’est ce mécanisme, et non une règle spéciale, qui explique plusieurs constructions déjà rencontrées :

  • le bloc else d’un let ... else doit diverger, puisqu’il n’a aucune valeur à fournir à la liaison ;
  • un bras de match peut appeler panic! sans rompre la cohérence de type ;
  • l’opérateur ? peut interrompre la fonction au milieu d’une expression.

Une fonction qui ne rend jamais la main se déclare avec -> ! :

fn boucle_principale() -> ! {
    loop {
        traiter_evenements();
    }
}

Les macros de divergence

MacroUsage
panic!("message")Erreur irrécupérable, avec message
unreachable!()Branche que la logique rend impossible à atteindre
todo!()Code non encore écrit — compile, échoue à l’exécution
unimplemented!()Cas volontairement non pris en charge
match etat {
    Etat::Actif => traiter(),
    Etat::Suspendu => attendre(),
    Etat::Archive => unreachable!("les archives sont filtrées en amont"),
}
Tip

todo!() est particulièrement utile pendant la conception : il permet d’écrire les signatures et de laisser le compilateur vérifier l’architecture avant d’implémenter le corps des fonctions. Les types sont validés, et chaque appel non implémenté échoue bruyamment s’il est atteint.

La sortie anticipée

L’opérateur ?

? propage l’échec à l’appelant et poursuit le flot normal sinon. Il fonctionne sur Result comme sur Option, mais sans mélange des deux au sein d’une même fonction.

fn lire_config(chemin: &str) -> Result<String, std::io::Error> {
    let contenu = std::fs::read_to_string(chemin)?;   // Retourne l'Err immédiatement
    Ok(contenu)
}

let ... else

Pour déstructurer ou sortir, sans indenter la suite :

let Some(racine) = &config.racine else {
    return Err(Erreur::RacineManquante);
};
// racine est ici une valeur, plus une Option

Les clauses de garde

L’idiome consiste à traiter les cas d’exclusion en tête de fonction, pour garder le cas nominal à plat plutôt que de l’enfouir sous plusieurs niveaux d’indentation.

fn traiter(&self, requete: &Requete) -> Result<Reponse, Erreur> {
    if !requete.est_valide() {
        return Err(Erreur::RequeteInvalide);
    }
    let Some(session) = self.session(requete.id) else {
        return Err(Erreur::NonAuthentifie);
    };

    // Cas nominal, sans imbrication
    self.executer(session, requete)
}

ControlFlow et les itérateurs

Une chaîne d’itérateurs ne peut pas contenir de break. Pour interrompre un parcours en cours de route, la bibliothèque standard fournit l’énumération ControlFlow, associée à try_fold et try_for_each.

use std::ops::ControlFlow;

let resultat = nombres.iter().try_fold(0i32, |acc, &n| {
    if n > 3 {
        ControlFlow::Break(acc)         // Interrompt et renvoie l'accumulateur
    } else {
        ControlFlow::Continue(acc + n)
    }
});

try_fold accepte en réalité tout type implémentant Try, ce qui inclut Option et Result — le parcours s’arrête alors au premier None ou Err :

// S'arrête au premier dépassement de capacité
let somme: Option<i32> = nombres.iter().try_fold(0i32, |a, &n| a.checked_add(n));

// S'arrête à la première erreur d'écriture
let ecriture: Result<(), std::io::Error> =
    lignes.iter().try_for_each(|ligne| writeln!(sortie, "{ligne}"));
Note

Avant d’atteindre try_fold, il faut vérifier qu’un consommateur dédié ne fait pas déjà le travail : find, position, any et all s’arrêtent tous dès que le résultat est connu. any et all sont notamment des boucles à sortie anticipée déguisées.

if nombres.iter().any(|&n| n > 100) { /* ... */ }

Ce qui n’existe pas

AbsentRemplacé par
Opérateur ternaire ? :if en expression
Chute d’un case à l’autre (fallthrough)Les alternatives de motifs : 1 | 2 | 3 => ...
gotoÉtiquettes de boucle et blocs étiquetés
do ... whileloop avec un break conditionnel en fin de corps
Exceptions, try / catchResult, ?, et panic! pour l’irrécupérable
Valeurs implicitement booléennesUne condition doit être un bool
Warning

Rust ne garantit pas l’optimisation des appels terminaux (tail call optimization). Une récursion profonde peut donc provoquer un débordement de pile, même écrite en position terminale. Pour un parcours de profondeur inconnue, mieux vaut une boucle avec une pile explicite.

À retenir

  • Un bloc vaut sa dernière expression ; un point-virgule le fait valoir ().
  • if, match et loop produisent des valeurs — d’où l’absence de ternaire.
  • Seul loop peut renvoyer une valeur via break ; les blocs étiquetés étendent ce mécanisme hors des boucles.
  • Le type ! s’unifie avec tout : c’est ce qui autorise panic!, return ou ? au milieu d’une expression typée.
  • Dans une chaîne d’itérateurs, la sortie anticipée passe par find, any, all ou try_fold.

Ressources