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_cartne fait que lirecart, donc elle garde une&cart.add_itemappellepush, donc elle a besoin de&mut cart. La variable closure doit aussi être déclaréemut, car l’appeler va modifier l’état interne.- Le code compile grâce aux durées de vie non lexicales.
print_cartn’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 estFn. - La deuxième closure incrémente
count, donc elle estFnMut. Si vous la passez àrun_twice, vous obtiendrez une erreur. - La dernière closure utilise
name + "...". L’opérateur+surStringprend la propriété dename, donc la closure ne peut être appelée qu’une seule fois et n’implémente queFnOnce.
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 danspick_ruleont deux types différents, donc elles doivent être converties en un même trait object.movedansmake_discountest obligatoire. Sans lui, la closure emprunteraitpercent, 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é. Écrirefor p in pricesappelle aussiinto_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éellementnextet 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 retournantbool. Le paramètreoa le type&&Order, carfilterpasse une référence à l’élément, et l’élément deiter()est déjà&Order. La déréférence automatique vous permet d’écrireo.paidnormalement.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 retourVec<&str>de la fonction.foldaccepte 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 = Stringdéclare le type d’élément que l’itérateur produit.nextmet à jour l’état via&mut selfet retourneNonelorsqu’on dépasseend.zipfusionne 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 boucleforoufor_each.
Voici d’autres erreurs que vous rencontrerez :
collectne connaît pas le type de destination. Écrirelet v = it.collect();génère une erreur « type annotations needed ». Vous devez spécifierlet v: Vec<_> = ...ou utiliser la syntaxe turbofishcollect::<Vec<_>>().- Utiliser
into_iter()puis vouloir réutiliser la collection. Si vous n’avez besoin que de lire, utiliseziter(). - Une closure maintient un emprunt trop longtemps. Une closure
FnMutqui capture&mut cartvous empêche de lirecartjusqu’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 unemap. L’opérateur?dans une closure retourne seulement de la closure, pas de la fonction englobante. Utilisez plutôtcollect::<Result<Vec<_>, _>>(). Cette approche s’arrête à la première erreur et retourneErr.
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 boucleforclaire est plus lisible qu’unfoldde 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 uneenumou 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.