L'histoire de la question provient de la décision de Rust d'implémenter les fermetures comme des abstractions à coût zéro via des structures anonymes plutôt que des objets fonctionnels collectés par un ramasse-miettes. Contrairement à des langages comme JavaScript ou Python, Rust doit encoder les règles de propriété, d'emprunt et de mutabilité directement dans le type de la fermeture. Les trois traits—Fn, FnMut et FnOnce—forment une hiérarchie stricte basée sur le paramètre self dans leurs méthodes call, permettant au compilateur de vérifier à la compilation que l'utilisation d'une fermeture respecte les invariants de sécurité mémoire de son environnement capturé.
Le problème se concentre sur la distinction entre la façon dont une fermeture capture des variables (par référence ou par valeur via move) et comment elle les utilise en interne. FnOnce nécessite self (consommant la propriété), permettant à la fermeture de déplacer des variables capturées hors de son environnement mais la restreignant à une seule invocation. FnMut nécessite &mut self, permettant la mutation de l'état capturé mais nécessitant un accès unique à la fermeture elle-même. Fn nécessite &self, permettant plusieurs invocations simultanées mais interdisant la mutation des variables capturées à moins qu'une mutabilité intérieure ne soit utilisée. Une fermeture qui déplace un type non Copy dans son corps devient FnOnce car la première invocation laisserait l'environnement dans un état déplacé, invalidant les appels suivants. Les candidats confondent souvent le mot-clé move—qui force simplement la capture par valeur—avec le trait FnOnce, ne reconnaissant pas qu'une fermeture move contenant uniquement des types Copy implémente toujours Fn.
La solution consiste à choisir la contrainte de trait la moins restrictive nécessaire pour l'API. Si la fermeture est invoquée exactement une fois, utilisez FnOnce pour accepter la plus grande variété de fermetures (y compris celles qui consomment leur environnement). Si plusieurs invocations avec mutation sont requises, utilisez FnMut. Pour un accès concurrent ou répété en lecture seule, utilisez Fn. Le compilateur dérive automatiquement ces implémentations en fonction de l'analyse de capture, nécessitant aucune implémentation manuelle de traits.
fn apply_once<F: FnOnce()>(f: F) { f(); } fn apply_mut<F: FnMut()>(mut f: F) { f(); f(); } fn apply_fn<F: Fn()>(f: F) { f(); f(); } let data = vec![1, 2, 3]; let consume = move || drop(data); // FnOnce: Vec n'est pas Copy apply_once(consume); let mut count = 0; let mut increment = || { count += 1; }; // FnMut: modifie la capture apply_mut(&mut increment); let value = 42; let print = move || println!("{}", value); // Fn: i32 est Copy apply_fn(print); apply_fn(print); // Valide: print est Fn
Considérons un planificateur de tâches asynchrone dans un serveur web à haut débit qui accepte des hooks définis par l'utilisateur pour traiter les requêtes entrantes. L'API du planificateur exigeait initialement que tous les hooks implémentent Fn pour permettre une exécution parallèle potentielle.
Description du problème : Une nouvelle fonctionnalité exigeait que les hooks maintiennent des statistiques par connexion, nécessitant la mutation de compteurs capturés. Les développeurs ont essayé de passer des fermetures move capturant des variables mut counter, mais le compilateur a rejeté ces fermetures car Fn nécessite &self, ce qui ne peut pas modifier des champs mut possédés sans mutabilité intérieure. L'équipe était confrontée à un choix entre assouplir la contrainte de trait ou restructurer la signature du hook.
Solution 1 : Mutabilité intérieure avec des types atomiques :
Remplacez le compteur u64 par AtomicU64 et capturez-le via Arc. La fermeture implémente Fn car la mutation se produit à travers des opérations atomiques sur &self, nécessitant aucun accès mutable à la fermeture elle-même.
Avantages : Maintient la contrainte Fn, permet au planificateur d'exécuter des hooks de manière concurrente à partir de plusieurs threads sans synchronisation sur la fermeture elle-même.
Inconvénients : Introduit une surcharge atomique au niveau matériel et une complexité de l'ordre mémoire. Nécessite une allocation Arc même pour un usage mono-thread, ce qui contrarie les principes d'abstraction à coût zéro pour des compteurs simples.
Solution 2 : Contrainte FnMut avec exécution séquentielle :
Changez l'API du planificateur pour accepter des fermetures FnMut. Le planificateur stocke les hooks dans un Vec<Box<dyn FnMut()>> et les invoque séquentiellement tout en conservant un accès &mut.
Avantages : Zéro surcharge d'exécution pour la mutation. Garantie à la compilation qu'aucune course de données ne se produit, car le système de type impose un accès unique pendant l'invocation.
Inconvénients : Empêche l'invocation concurrente du même hook et complique le stockage interne du planificateur (nécessite &mut self sur le planificateur lui-même). Rompt la compatibilité avec les hooks existants Fn à moins d'utiliser des implémentations générales.
Solution choisie : La solution 2 (FnMut) a été sélectionnée car l'architecture du serveur traitait les connexions par thread, éliminant le besoin d'exécution concurrente des hooks. L'équipe a préféré la sécurité à la compilation à la flexibilité des hooks concurrents, acceptant le changement d'API comme une évolution correcte mais perturbante.
Résultat : Le planificateur a réussi à gérer des hooks avec état sans surcharge d'exécution. Le système de type a empêché un bogue subtil où deux threads auraient pu incrémenter simultanément un compteur non atomique, ce qui aurait été possible si RefCell avait été utilisé avec Fn sans synchronisation appropriée.
Le mot-clé move dans la définition d'une fermeture rend-il automatiquement cette fermeture implémentant FnOnce plutôt que Fn ou FnMut ?
Non. Le mot-clé move indique uniquement que les variables capturées sont déplacées dans l'environnement de la fermeture par valeur, plutôt que d'être empruntées. L'implémentation du trait dépend uniquement de la façon dont le corps de la fermeture utilise ses captures. Si la fermeture déplace un type non Copy hors de son environnement (le consommant), elle implémente FnOnce. Si elle ne fait que muter des captures, elle implémente FnMut. Si elle ne fait que lire ou utiliser des types Copy par valeur, elle implémente Fn, même avec le mot-clé move. Par exemple, let x = 5; let f = move || x + 1; implémente Fn car i32 est Copy.
Pourquoi une fonction acceptant FnOnce peut-elle être appelée avec une fermeture implémentant Fn, mais pas vice versa ?
Fn est un sous-trait de FnMut, qui est un sous-trait de FnOnce. Cela signifie que chaque fermeture implémentant Fn implémente automatiquement FnMut et FnOnce, mais l'inverse n'est pas vrai. Un paramètre de fonction limité par FnOnce accepte toute fermeture pouvant être appelée une fois, ce qui inclut celles qui peuvent être appelées plusieurs fois (Fn et FnMut). En revanche, une fonction exigeant Fn demande que la fermeture supporte l'invocation via une référence partagée (&self), ce que les fermetures consommant leur environnement (FnOnce uniquement) ne peuvent pas satisfaire. Cela suit la sous-typage standard : un type plus capable (Fn) peut être utilisé là où un moins capable (FnOnce) est requis.
Comment le compilateur détermine-t-il quel trait une fermeture implémente lorsqu'elle capture des références à des variables dans la portée englobante ?
Le compilateur analyse le corps de la fermeture pour voir comment les variables capturées sont accessibles. Si la fermeture déplace une variable capturée (et que le type n'est pas Copy), elle implémente FnOnce. Si elle mute une variable capturée (l'affecte ou appelle des méthodes &mut self), elle implémente FnMut (et FnOnce). Si elle ne lit que la variable ou appelle des méthodes &self, elle implémente Fn (et les autres). Pour des captures par référence (&T ou &mut T), la fermeture détient des références. Si elle capture &mut T, elle implémente généralement FnMut car son appel nécessite un accès unique à la fermeture elle-même pour maintenir l'unicité de l'emprunt mutable. Si elle capture &T, elle implémente Fn.