Les closures

Une closure est une fonction anonyme capable de capturer son environnement. C’est la brique de base des chaînes d’itérateurs et des combinateurs d’Option — map, filter, unwrap_or_else en prennent tous une. La particularité de Rust tient à ce que cette capture doit se concilier avec les règles de propriété, ce qui donne naissance à trois traits distincts.

Syntaxe

let ajouter = |a: i32, b: i32| -> i32 { a + b };
let ajouter = |a, b| a + b;             // Types inférés, corps en expression unique

assert_eq!(ajouter(2, 3), 5);

Contrairement aux fonctions, les annotations de type sont facultatives : le compilateur les déduit du contexte d’utilisation.

Note

Cette inférence n’a lieu qu’une seule fois. Une closure dont les types ont été déduits d’un premier appel ne peut pas être réutilisée avec d’autres types :

let identite = |x| x;
let s = identite(String::from("a"));
let n = identite(5);                    // Erreur : attendu String, trouvé entier

La capture de l’environnement

C’est ce qui distingue une closure d’une simple fonction : elle accède aux variables de la portée englobante.

let facteur = 3;
let multiplier = |x| x * facteur;       // facteur est capturé
assert_eq!(multiplier(5), 15);

Le compilateur choisit le mode de capture le moins contraignant possible, dans cet ordre :

  1. par référence partagée &T, si la closure se contente de lire ;
  2. par référence mutable &mut T, si elle modifie ;
  3. par valeur, si elle consomme la variable.
let liste = vec![1, 2, 3];
let afficher = || println!("{liste:?}");    // Capture par &
afficher();
println!("{liste:?}");                       // liste reste utilisable

let mut liste = vec![1, 2, 3];
let mut ajouter = || liste.push(4);          // Capture par &mut
ajouter();
println!("{liste:?}");                       // Utilisable après la dernière invocation

Une closure capturant par référence mutable détient un emprunt exclusif tant qu’elle est vivante : la variable d’origine reste inaccessible pendant ce temps, exactement comme le décrit Ownership et emprunt.

Le mot-clé move

move force la capture par valeur, même lorsqu’une référence suffirait :

let liste = vec![1, 2, 3];
let posseder = move || println!("{liste:?}");
posseder();
// println!("{liste:?}");               // Erreur : liste a été déplacée dans la closure

C’est indispensable dès que la closure doit survivre à la portée qui l’a créée — typiquement pour un fil d’exécution ou une tâche asynchrone, dont la durée de vie est inconnue au moment de la création.

let donnees = vec![1, 2, 3];
std::thread::spawn(move || {
    println!("{}", donnees.len());
}).join().unwrap();
Warning

move ne rend pas la closure consommatrice pour autant. Il décrit comment l’environnement est capturé, pas ce que la closure fait de ce qu’elle a capturé. Une closure move qui se contente de lire reste appelable plusieurs fois.

Les trois traits

Une closure n’a pas de type nommable : le compilateur en fabrique un, anonyme, différent pour chaque closure écrite. Ce type implémente automatiquement un ou plusieurs des trois traits suivants, selon ce que le corps fait de son environnement.

TraitReceveurCe que la closure faitAppels possibles
FnOnceselfConsomme l’environnement capturéUn seul
FnMut&mut selfModifie l’environnementPlusieurs
Fn&selfSe contente de lirePlusieurs, y compris en parallèle

Ces traits sont emboîtés : toute closure Fn est aussi FnMut, et toute FnMut est aussi FnOnce. FnOnce est donc la contrainte la plus permissive à l’écriture, mais la plus restrictive à l’usage.

let message = String::from("Bonjour");

let lire = || println!("{message}");            // Fn
let mut compteur = 0;
let mut incrementer = || compteur += 1;         // FnMut
let consommer = move || message;                // FnOnce : rend la String capturée

assert_eq!(consommer(), "Bonjour");
// consommer();                                  // Erreur : déjà consommée

Choisir la contrainte en paramètre

La règle est de demander le moins possible, afin d’accepter le plus d’appelants :

fn appliquer_une_fois<F: FnOnce() -> String>(f: F) -> String { f() }
fn appliquer_plusieurs<F: FnMut()>(mut f: F) { f(); f(); }
fn appliquer_partout<F: Fn(i32) -> i32>(f: F, v: &[i32]) -> Vec<i32> {
    v.iter().map(|&x| f(x)).collect()
}

Un repère pratique : si la fonction n’appelle la closure qu’une fois, FnOnce suffit — c’est ce que fait unwrap_or_else. Si elle l’appelle dans une boucle, il faut FnMut, ce qu’exigent map et filter. Fn ne devient nécessaire que lorsque plusieurs appels doivent pouvoir coexister, notamment entre fils d’exécution.

Retourner une closure

Le type étant inexprimable, deux formes existent :

// impl Trait : sans coût, mais un seul type concret possible
fn multiplicateur(facteur: i32) -> impl Fn(i32) -> i32 {
    move |x| x * facteur
}

// Box<dyn Trait> : nécessaire si le type varie selon une branche
fn operation(nom: &str) -> Box<dyn Fn(i32) -> i32> {
    match nom {
        "double" => Box::new(|x| x * 2),
        "carre" => Box::new(|x| x * x),
        _ => Box::new(|x| x),
    }
}

Le move de la première forme est obligatoire : sans lui, la closure emprunterait facteur, une variable locale détruite au retour de la fonction.

Closures et pointeurs de fonction

Une closure qui ne capture rien peut être convertie en pointeur de fonction fn, et réciproquement toute fonction est acceptée là où une closure est attendue :

fn doubler(x: i32) -> i32 { x * 2 }

let nombres = vec![1, 2, 3];
let a: Vec<i32> = nombres.iter().map(|&x| doubler(x)).collect();
let b: Vec<i32> = nombres.iter().copied().map(doubler).collect();   // Directement

Ce passage direct du nom de fonction allège beaucoup de chaînes d’itérateurs — map(str::trim), filter_map(|s| s.parse().ok()), map(String::from). Les constructeurs de variantes en profitent aussi, Some et Ok étant eux-mêmes des fonctions :

let options: Vec<Option<i32>> = nombres.iter().copied().map(Some).collect();

Pièges courants

Capturer self par mégarde

Dans une méthode, mentionner un champ dans une closure capture self en entier, et non le seul champ concerné. L’emprunt qui en résulte bloque tout autre accès à l’instance.

// L'emprunt de self dure aussi longtemps que la closure
let f = || self.champ.len();

// Capturer le champ isolément lève la contrainte
let champ = &self.champ;
let f = || champ.len();
Info

Depuis l’édition 2021, la capture disjointe améliore ce comportement : une closure capture désormais les champs individuellement plutôt que la structure entière, ce qui résout automatiquement une partie de ces conflits. La capture explicite reste nécessaire lorsque la closure doit survivre à self.

Confondre Fn et fn

fn avec une minuscule est un type, celui des pointeurs de fonction. Fn avec une majuscule est un trait, employé comme contrainte. Le premier n’accepte aucune capture, le second si.

move et types Copy

move déplace la variable capturée, sauf si son type est Copy — auquel cas elle est copiée et reste utilisable :

let n = 5;
let f = move || n * 2;
println!("{n}");            // Compile : i32 est Copy, n a été copié

C’est une source de confusion fréquente quand on teste le comportement de move sur un entier plutôt que sur une String.

À retenir

  • Une closure capture son environnement selon le mode le moins contraignant possible ; move force la capture par valeur.
  • FnOnce, FnMut et Fn décrivent ce que la closure fait de ce qu’elle a capturé, du plus permissif au plus restrictif.
  • En paramètre, exiger la contrainte la plus faible qui convienne.
  • Une closure sans capture équivaut à un pointeur de fonction, ce qui permet de passer un nom de fonction directement.

Ressources