Retour au cours

backend / rust

Closures et itérateurs

Leçon 121 exercice

Explication

Ce que vous allez apprendre

  • Distinguer les trois façons dont une closure capture son environnement : Fn, FnMut, FnOnce
  • Utiliser move pour forcer une capture par valeur, indispensable pour les threads
  • Comprendre pourquoi les itérateurs Rust sont paresseux
  • Enchaîner filter, map, take, fold pour des traitements de collection lisibles
  • Implémenter son propre itérateur en définissant seulement la méthode next()

Dans quel contexte ?

Un développeur doit traiter une liste de plusieurs millions de commandes pour n'en garder que les impaires, les élever au carré, et sommer le résultat. Une boucle manuelle avec des variables intermédiaires ferait le travail, mais une chaîne d'itérateurs (filter().map().sum()) exprime exactement la même intention de façon plus déclarative — et compile généralement en un code tout aussi rapide, voire plus rapide grâce à l'inlining et à la fusion des étapes.

D'abord, il faut comprendre comment une closure capture son environnement

Une closure est une fonction anonyme qui peut utiliser des variables de son contexte environnant. Rust distingue trois façons de capturer ces variables, reflétées par trois traits différents que le compilateur choisit automatiquement selon l'usage réel fait dans le corps de la closure.

TraitCaptureExemple d'usage
FnPar référence immuableLire une variable capturée, sans la modifier
FnMutPar référence mutableModifier une variable capturée (comme un compteur)
FnOncePar valeur (consomme)Une closure qui ne peut être appelée qu'une seule fois

Une fois cette distinction acquise, un mot-clé devient indispensable dans certains contextes

move force la closure à prendre l'ownership des variables capturées plutôt que de les emprunter. C'est absolument nécessaire dès qu'une closure est envoyée à un autre thread (vu dans une prochaine leçon) : le compilateur doit garantir que la donnée capturée ne sera pas libérée par le thread d'origine pendant que le nouveau thread l'utilise encore.

Prérequis

Il faut être à l'aise avec l'ownership et le borrowing (leçons précédentes) : la capture d'une closure applique exactement les mêmes règles que le passage de paramètres à une fonction classique.

Il reste une caractéristique fondamentale des itérateurs à intégrer : la paresse

nombres.iter().filter(...).map(...) ne calcule strictement rien tant qu'aucune méthode "consommatrice" (comme .collect(), .sum(), ou une boucle for) n'est appelée. Cette paresse permet au compilateur de fusionner toute la chaîne en une seule passe optimisée sur les données, plutôt que de créer une collection intermédiaire à chaque étape.

Piège fréquent

Écrire une longue chaîne d'itérateurs sans jamais appeler de méthode consommatrice à la fin ne produit aucun résultat ni aucune erreur visible immédiatement : rien ne s'exécute simplement. Vérifie toujours que ta chaîne se termine par .collect(), .sum(), .for_each() ou une utilisation équivalente qui déclenche réellement l'évaluation.

Enfin, implémenter son propre itérateur ne demande qu'une seule méthode

Un type qui implémente Iterator en ne définissant que fn next(&mut self) -> Option<Self::Item> reçoit gratuitement tout l'arsenal des méthodes de la stdlib (map, filter, fold, sum, take...) sans avoir à les réécrire. C'est un excellent exemple de la puissance des traits avec méthodes par défaut, vus dans la leçon précédente.

Bonne pratique

Préfère une chaîne d'itérateurs à une boucle manuelle dès que la logique s'y prête naturellement (filtrer, transformer, réduire) : le code Rust idiomatique privilégie ce style, généralement aussi rapide qu'une boucle écrite à la main, souvent plus lisible.

Maintenant que tu maîtrises closures et itérateurs, la prochaine leçon aborde des cas de propriété plus complexes que le simple ownership unique vu jusqu'ici : les smart pointers Box, Rc et RefCell.

Commandes & code

Closures et itérateurs

Les itérateurs Rust sont paresseux (lazy) et compilés en code aussi rapide qu'une boucle manuelle.

rust
fn main() {
    // Closure : fonction anonyme qui capture son environnement
    let facteur = 3;
    let multiplier = |x: i32| x * facteur; // capture facteur par reference (Fn)
    println!("{}", multiplier(5));

    // FnMut : closure qui modifie une variable capturee
    let mut total = 0;
    let mut ajouter = |x: i32| total += x;
    ajouter(5);
    ajouter(10);
    println!("{}", total);

    // FnOnce : closure qui consomme une valeur capturee (ne peut etre appelee qu'une fois)
    let texte = String::from("valeur");
    let consommer = move || println!("consomme: {}", texte); // move force la capture par valeur
    consommer();

    // Iterateurs : paresseux, ne font rien tant qu'on ne les consomme pas (collect, sum, for...)
    let nombres = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    let resultat: Vec<i32> = nombres
        .iter()
        .filter(|&&n| n % 2 == 0)   // garde les pairs
        .map(|&n| n * n)             // eleve au carre
        .take(3)                      // limite a 3 elements
        .collect();
    println!("{:?}", resultat);

    // Chaine complexe et lisible, evaluee en UNE seule passe (fusion des iterateurs)
    let somme_carres_impairs: i32 = nombres.iter()
        .filter(|&&n| n % 2 != 0)
        .map(|&n| n * n)
        .sum();
    println!("{}", somme_carres_impairs);

    // enumerate, zip, chain : combinaisons courantes
    for (i, n) in nombres.iter().enumerate().take(3) {
        println!("index {} -> {}", i, n);
    }

    let noms = vec!["a", "b", "c"];
    let ages = vec![10, 20, 30];
    let paires: Vec<(&str, i32)> = noms.iter().copied().zip(ages.iter().copied()).collect();
    println!("{:?}", paires);

    // fold : reduction generique (equivalent de Reduce)
    let produit = nombres.iter().fold(1, |acc, &n| acc * n);
    println!("{}", produit);

    // any / all : tests booleens sur une collection
    println!("{}", nombres.iter().any(|&n| n > 9));
    println!("{}", nombres.iter().all(|&n| n > 0));

    // Implementer son propre iterateur
    struct Compteur { valeur: u32, max: u32 }
    impl Iterator for Compteur {
        type Item = u32;
        fn next(&mut self) -> Option<u32> {
            if self.valeur < self.max {
                self.valeur += 1;
                Some(self.valeur)
            } else {
                None
            }
        }
    }
    let compteur = Compteur { valeur: 0, max: 5 };
    let total: u32 = compteur.sum();
    println!("{}", total);
}

Résumé

  • Fn, FnMut, FnOnce décrivent comment une closure capture son environnement (lecture, mutation, consommation).
  • move force la capture par valeur — indispensable pour envoyer une closure à un autre thread.
  • Les itérateurs sont paresseux : rien ne s'exécute avant collect, sum, for, etc.
  • Implémenter Iterator (juste next()) donne accès gratuitement à toute la boîte à outils (map, filter, fold...).

Exercices pratiques

1 disponible
1

Mission : une chaine d'iterateurs qui n'affiche jamais rien

Objectif : Diagnostiquer une chaine d'iterateurs paresseuse jamais consommee et une closure qui ne peut pas capturer par move, puis les corriger.

Contexte

Un dev a ecrit ce code pour filtrer et logger les commandes superieures a 100 euros, mais rien ne s'affiche jamais a l'ecran, sans la moindre erreur de compilation :

rust
fn main() {
    let montants = vec![50, 120, 30, 200, 80];
    montants.iter().filter(|&&m| m > 100).map(|&m| println!("grosse commande: {}", m));
}

Separement, un autre dev veut envoyer une closure a un thread (sujet de la prochaine lecon) mais obtient une erreur de lifetime sur la variable capturee, qu'il ne comprend pas encore.

Résoudre l’exercice →