Itérateurs et closures en Rust

1. Aperçu

Les closures et les itérateurs sont deux outils qui vous permettent de traiter les données de manière fonctionnelle en Rust tout en conservant les performances d’une boucle écrite à la main. Cet article explique comment les closures capturent les variables de l’environnement et les trois traits Fn, FnMut, FnOnce. Ensuite, l’article couvre le trait Iterator, les adaptateurs courants et comment écrire votre propre itérateur. Après l’avoir lu, vous écrirez en toute confiance des pipelines comme filter → map → collect. Vous comprendrez aussi pourquoi le compilateur refuse parfois votre code.

Tous les exemples utilisent Rust 1.80+ (édition 2021), avec seulement la bibliothèque standard.

2. Closures de base

2.1. Syntaxe

Une closure est une fonction anonyme qui peut “voir” les variables de la portée qui l’entoure :

fn main() {
    let discount = 10; // pourcentage
    let apply_discount = |price: u32| price - price * discount / 100;

    println!("{}", apply_discount(250));
    println!("{}", apply_discount(1000));
}
225
900

Les paramètres sont entre deux barres verticales |...|, et le corps de la closure vient immédiatement après. La closure utilise la variable discount bien qu’elle ne soit pas passée en paramètre. Une fonction fn ordinaire ne pourrait pas faire cela.

Les types de paramètres et le type de retour sont généralement déduits automatiquement. Vous pouvez écrire |price| ... si le compilateur a suffisamment d’informations. Cependant, le type n’est déduit qu’une seule fois. Si lors du premier appel vous passez un u32, et lors du second appel un i64, vous obtiendrez une erreur de compilation.

2.2. Trois façons de capturer une variable

Une closure capture une variable selon la façon “la plus légère” dont le corps de la closure en a besoin. L’ordre de priorité est : emprunt immuable, puis emprunt mutable, puis prise de propriété.

fn main() {
    let mut cart = vec!["stylo"];

    let print_cart = || println!("Panier: {:?}", cart); // emprunte &cart
    print_cart();

    let mut add_item = |item| cart.push(item);        // emprunte &mut cart
    add_item("cahier");
    add_item("règle");

    println!("Finalement: {:?}", cart);
}
Panier: ["stylo"]
Finalement: ["stylo", "cahier", "règle"]

Analysons chaque closure :

  • print_cart ne fait que lire cart, donc elle garde une &cart.
  • add_item appelle push, donc elle a besoin de &mut cart. La variable closure doit aussi être déclarée mut, car l’appeler va modifier l’état interne.
  • Le code compile grâce aux durées de vie non lexicales. print_cart n’est plus utilisée après son premier appel, donc l’emprunt immuable se termine avant que l’emprunt mutable ne commence.

Lorsque vous devez que la closure possède les données, vous ajoutez le mot-clé move. Le cas typique est de passer une closure à un autre thread :

use std::thread;

fn main() {
    let customer = String::from("Lan");

    let handle = thread::spawn(move || {
        println!("En train de traiter la commande pour {customer}");
    });

    handle.join().unwrap();
}
En train de traiter la commande pour Lan

Sans move, le compilateur génère une erreur. Le nouveau thread pourrait vivre plus longtemps que main, donc une référence à customer ne serait pas sûre. Avec move, la String est entièrement transférée dans la closure. Après cette ligne, vous ne pouvez plus utiliser customer dans main.

3. Les trois traits Fn, FnMut et FnOnce

Chaque closure implémente automatiquement un ou plusieurs des trois traits suivants. Le trait implémenté dépend de ce que la closure fait avec les variables capturées :

Trait La closure fait quoi avec la variable capturée Peut être appelée combien de fois
Fn Seulement lire Plusieurs fois
FnMut Modifier Plusieurs fois
FnOnce Consommer (déplacer vers l’extérieur) Une seule fois

Ces trois traits ont une relation d’héritage : tout Fn est aussi un FnMut, et tout FnMut est aussi un FnOnce. Lorsque vous écrivez une fonction qui accepte une closure, vous devez choisir le trait le plus souple dont la fonction a besoin. De cette façon, l’appelant a le plus de choix.

fn run_twice<F: Fn()>(f: F) {
    f();
    f();
}

fn run_twice_mut<F: FnMut()>(mut f: F) {
    f();
    f();
}

fn run_once<F: FnOnce() -> String>(f: F) -> String {
    f()
}

fn main() {
    let name = String::from("Minh");
    run_twice(|| println!("Bonjour {name}"));

    let mut count = 0;
    run_twice_mut(|| count += 1);
    println!("count = {count}");

    let receipt = run_once(move || name + " - a payé");
    println!("{receipt}");
}
Bonjour Minh
Bonjour Minh
count = 2
Minh - a payé

Les trois appels correspondent aux trois traits :

  • La première closure ne fait que lire name, donc elle est Fn.
  • La deuxième closure incrémente count, donc elle est FnMut. Si vous la passez à run_twice, vous obtiendrez une erreur.
  • La dernière closure utilise name + "...". L’opérateur + sur String prend la propriété de name, donc la closure ne peut être appelée qu’une seule fois et n’implémente que FnOnce.

3.1. Retourner une closure depuis une fonction

Chaque closure a un type propre et anonyme. Par conséquent, vous ne pouvez pas écrire le nom de ce type. Il y a deux façons de retourner une closure :

fn make_discount(percent: u32) -> impl Fn(u32) -> u32 {
    move |price| price - price * percent / 100
}

fn pick_rule(is_vip: bool) -> Box<dyn Fn(u32) -> u32> {
    if is_vip {
        Box::new(|price| price * 80 / 100)
    } else {
        Box::new(|price| price)
    }
}

fn main() {
    let summer_sale = make_discount(15);
    println!("{}", summer_sale(200));

    let rule = pick_rule(true);
    println!("{}", rule(500));
}
170
400
  • impl Fn(...) est utilisé lorsque la fonction retourne toujours un même type de closure fixe. Cette approche n’alloue pas sur le tas et n’a pas de coût d’appel dynamique.
  • Box<dyn Fn(...)> est utilisé lorsque les différentes branches retournent des closures différentes. Les deux closures dans pick_rule ont deux types différents, donc elles doivent être converties en un même trait object.
  • move dans make_discount est obligatoire. Sans lui, la closure emprunterait percent, ce qui disparaîtrait à la fin de la fonction.

4. Le trait Iterator

4.1. Le cœur : la fonction next

Chaque itérateur en Rust repose sur un trait très petit :

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    // ... des dizaines de méthodes disponibles comme map, filter, sum ...
}

next retourne Some(élément) jusqu’à épuisement des données, puis retourne None. Une boucle for n’est en réalité que de la syntaxe sucre pour appeler next répétitivement.

4.2. iter, iter_mut et into_iter

Une collection peut fournir trois types d’itérateurs différents :

fn main() {
    let mut prices = vec![100, 200, 300];

    for p in prices.iter() {          // p: &i32
        print!("{p} ");
    }
    println!();

    for p in prices.iter_mut() {      // p: &mut i32
        *p += 10;
    }

    for p in prices.into_iter() {     // p: i32, le vecteur est déplacé
        print!("{p} ");
    }
    println!();
    // println!("{:?}", prices); // erreur: valeur empruntée après déplacement
}
100 200 300 
110 210 310 
  • iter() fournit des références immuables. La collection peut toujours être utilisée après.
  • iter_mut() fournit des références mutables pour modifier les éléments sur place.
  • into_iter() prend la propriété. Écrire for p in prices appelle aussi into_iter() implicitement.

5. Adaptateurs et consommateurs

Les méthodes sur un itérateur se divisent en deux groupes :

  • Adaptateurs (map, filter, take, zip, enumerate…) transforment cet itérateur en un autre itérateur. Ils sont paresseux : rien ne s’exécute jusqu’à ce qu’un consommateur n’intervienne.
  • Consommateurs (collect, sum, count, fold, for_each, find…) appellent réellement next et produisent un résultat.

Utilisons une struct Order comme données d’exemple :

#[derive(Debug)]
pub struct Order {
    pub id: u32,
    pub customer: String,
    pub total: u32,
    pub paid: bool,
}

pub fn unpaid_total(orders: &[Order]) -> u32 {
    orders.iter()
        .filter(|o| !o.paid)
        .map(|o| o.total)
        .sum()
}

pub fn big_customers(orders: &[Order], threshold: u32) -> Vec<&str> {
    orders.iter()
        .filter(|o| o.total >= threshold)
        .map(|o| o.customer.as_str())
        .collect()
}

fn main() {
    let orders = vec![
        Order { id: 1, customer: "An".into(),   total: 150_000, paid: true },
        Order { id: 2, customer: "Bình".into(), total: 80_000,  paid: false },
        Order { id: 3, customer: "Chi".into(),  total: 320_000, paid: false },
    ];

    println!("Non encaissé: {}", unpaid_total(&orders));
    println!("Grands clients: {:?}", big_customers(&orders, 100_000));

    let summary = orders.iter()
        .fold(String::new(), |acc, o| acc + &format!("#{} ", o.id));
    println!("Commandes: {summary}");
}
Non encaissé: 400000
Grands clients: ["An", "Chi"]
Commandes: #1 #2 #3 

Les étapes dans l’exemple :

  • filter(|o| !o.paid) accepte une closure retournant bool. Le paramètre o a le type &&Order, car filter passe une référence à l’élément, et l’élément de iter() est déjà &Order. La déréférence automatique vous permet d’écrire o.paid normalement.
  • map(|o| o.total) convertit chaque commande en un montant. sum() les additionne tous.
  • collect() rassemble le résultat dans une collection. Le type de destination est déduit du type de retour Vec<&str> de la fonction.
  • fold accepte une valeur d’initialisation et une closure (acc, élément) -> acc_nouveau. C’est le consommateur le plus général.

5.1. La nature paresseuse

Le code suivant montre que les adaptateurs ne s’exécutent que lorsqu’ils sont consommés :

fn main() {
    let ids = vec![1, 2, 3];
    let doubled = ids.iter().map(|x| {
        println!("map {x}");
        x * 2
    });

    println!("Rien n'a encore été exécuté");
    let result: Vec<i32> = doubled.collect();
    println!("{result:?}");
}
Rien n'a encore été exécuté
map 1
map 2
map 3
[2, 4, 6]

Grâce à la nature paresseuse, une chaîne filter → map → sum ne traverse les données qu’une seule fois et ne crée pas de collections intermédiaires. Le compilateur optimise généralement ce pipeline en code machine équivalent à une boucle écrite manuellement. C’est pourquoi les itérateurs sont appelés une abstraction à coût zéro (zero-cost abstraction).

6. Écrire votre propre itérateur

Vous devez seulement implémenter next. Tous les autres adaptateurs viennent gratuitement. L’exemple suivant génère des numéros de facture au format FA-0001 :

pub struct InvoiceNumbers {
    current: u32,
    end: u32,
}

impl InvoiceNumbers {
    pub fn new(start: u32, end: u32) -> Self {
        Self { current: start, end }
    }
}

impl Iterator for InvoiceNumbers {
    type Item = String;

    fn next(&mut self) -> Option<Self::Item> {
        if self.current > self.end {
            return None;
        }
        let number = format!("FA-{:04}", self.current);
        self.current += 1;
        Some(number)
    }
}

fn main() {
    let customers = ["An", "Bình", "Chi"];

    for (invoice, customer) in InvoiceNumbers::new(7, 100).zip(customers) {
        println!("{invoice} -> {customer}");
    }
}
FA-0007 -> An
FA-0008 -> Bình
FA-0009 -> Chi

Explications :

  • type Item = String déclare le type d’élément que l’itérateur produit.
  • next met à jour l’état via &mut self et retourne None lorsqu’on dépasse end.
  • zip fusionne deux itérateurs en paires et s’arrête lorsque le plus court en est épuisé. Donc seulement trois lignes sont imprimées, bien que la plage s’étende jusqu’à 100.

7. Écrire des tests

Rust a un framework de test intégré. Vous placez un module de test directement dans le fichier contenant unpaid_total et InvoiceNumbers :

#[cfg(test)]
mod tests {
    use super::*;

    fn order(id: u32, total: u32, paid: bool) -> Order {
        Order { id, customer: format!("KH{id}"), total, paid }
    }

    #[test]
    fn unpaid_total_only_counts_unpaid_orders() {
        let orders = vec![order(1, 100, true), order(2, 50, false), order(3, 70, false)];
        assert_eq!(unpaid_total(&orders), 120);
    }

    #[test]
    fn unpaid_total_of_empty_slice_is_zero() {
        assert_eq!(unpaid_total(&[]), 0);
    }

    #[test]
    fn invoice_numbers_stop_after_end() {
        let numbers: Vec<String> = InvoiceNumbers::new(9, 11).collect();
        assert_eq!(numbers, vec!["FA-0009", "FA-0010", "FA-0011"]);
    }
}
cargo test
running 3 tests
test tests::invoice_numbers_stop_after_end ... ok
test tests::unpaid_total_of_empty_slice_is_zero ... ok
test tests::unpaid_total_only_counts_unpaid_orders ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

Le second test vérifie un cas limité. sum() sur un itérateur vide retourne 0, il n’y a pas de panique.

8. Erreurs courantes et quand ne pas utiliser

Attention : Oublier un consommateur est l’erreur la plus courante. La ligne orders.iter().map(|o| println!("{}", o.id)); n’affiche rien. Le compilateur avertit « iterators are lazy and do nothing unless consumed ». Lorsque vous n’avez besoin que d’un effet de bord, utilisez plutôt une boucle for ou for_each.

Voici d’autres erreurs que vous rencontrerez :

  • collect ne connaît pas le type de destination. Écrire let v = it.collect(); génère une erreur « type annotations needed ». Vous devez spécifier let v: Vec<_> = ... ou utiliser la syntaxe turbofish collect::<Vec<_>>().
  • Utiliser into_iter() puis vouloir réutiliser la collection. Si vous n’avez besoin que de lire, utilisez iter().
  • Une closure maintient un emprunt trop longtemps. Une closure FnMut qui capture &mut cart vous empêche de lire cart jusqu’au dernier usage de cette closure. Réduisez la portée de la closure ou appelez-la d’abord, puis lisez.
  • Avoir besoin de ? dans une map. L’opérateur ? dans une closure retourne seulement de la closure, pas de la fonction englobante. Utilisez plutôt collect::<Result<Vec<_>, _>>(). Cette approche s’arrête à la première erreur et retourne Err.

Quand ne pas forcer tout en une chaîne d’itérateur :

  • La logique a beaucoup de branches, beaucoup de points de break/continue, ou plusieurs mises à jour de variables simultanées. Une boucle for claire est plus lisible qu’un fold de dix lignes.
  • Vous devez retourner une closure depuis plusieurs branches mais vous vous inquiétez pour les performances. Box<dyn Fn> a un coût d’allocation et d’appel dynamique. Envisagez d’utiliser une enum ou du générique à la place.

9. Conclusion

Les closures en Rust capturent les variables selon la façon “la plus légère” possible : emprunt, emprunt mutable, ou move. De là, une closure implémente automatiquement Fn, FnMut ou FnOnce. Lorsque vous écrivez une fonction qui accepte une closure, choisissez le trait le plus souple dont vous avez besoin.

Un itérateur repose uniquement sur la méthode next. Les adaptateurs comme map, filter, zip sont paresseux, tandis que les consommateurs comme collect, sum, fold exécutent réellement le pipeline. En combinant ces deux outils, vous écrivez du code de traitement de données concis, sûr en mémoire et aussi rapide qu’une boucle manuelle. Vous devez aussi savoir quand une simple boucle for est le meilleur choix.

rustitérateurclosureprogrammation fonctionnelle