impl Fn() vs. Box<dyn Fn()>: Le Duel du Dispatch des Closures en Rust
Table des matières
Le système de closures de Rust offre deux façons de gérer un comportement function-like : impl Fn() pour le static dispatch et Box<dyn Fn()> pour le dynamic dispatch. Chacune a des caractéristiques distinctes de performance et flexibilité, guidées par l'ownership, les traits, et les lifetimes de Rust. Je vais les comparer et expliquer quand choisir l'une plutôt que l'autre.
Différences Clés
| Aspect | impl Fn() (Static Dispatch) | Box<dyn Fn()> (Dynamic Dispatch) |
|---|---|---|
| Mécanisme de Dispatch | Monomorphized au moment de la compilation (zero-cost) | Utilise des vtables (runtime lookup) |
| Performance | Plus rapide (~1–2 ns, appel direct) | Plus lent (~5–10 ns, vtable lookup) |
| Flexibilité | Type concret unique par instance | Peut stocker des closures hétérogènes |
| Mémoire | Stack-allocated (sauf si moved) | Heap-allocated (fat pointer + heap data) |
| Cas d'Usage | Type de closure fixe, critique en performance | Comportement dynamique, types de closures multiples |
Quand Utiliser Chacune
1. impl Fn() (Static Dispatch)
- Utilise Quand :
- Le type de closure est fixe et connu au moment de la compilation.
- La performance est critique (ex : hot loops, systèmes embarqués).
- Les zero-cost abstractions sont désirées.
- Pourquoi : Le compilateur génère une fonction unique pour chaque type de closure via monomorphization, permettant l'inlining et aucun overhead runtime.
Exemple :
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
fn main() {
let add_five = make_adder(5); // Type: closure(5)
println!("{}", add_five(3)); // 8
}
Pas d'allocation heap, appels de fonction directs, et performance optimale.
2. Box<dyn Fn()> (Dynamic Dispatch)
- Utilise Quand :
- Tu dois stocker différentes closures dans la même collection (ex : callbacks).
- Les types de closures varient au runtime (ex : systèmes de plugins).
- La flexibilité l'emporte sur les coûts de performance.
- Pourquoi :
dyn Fn()utilise une vtable pour la résolution de méthode au runtime, permettant des closures hétérogènes au coût d'allocation heap et d'overhead de lookup.
Exemple :
fn create_op(is_add: bool) -> Box<dyn Fn(i32, i32) -> i32> {
if is_add {
Box::new(|x, y| x + y)
} else {
Box::new(|x, y| x * y)
}
}
fn main() {
let add = create_op(true);
let mul = create_op(false);
println!("{} {}", add(2, 3), mul(2, 3)); // 5 6
}
Supporte le comportement dynamique, idéal pour les event handlers ou plugins.
Considérations de Lifetime
- Box<dyn Fn()> : Nécessite des lifetimes explicites si la closure capture des références :
struct Handler<'a> { callback: Box<dyn Fn() -> &'a str + 'a>, } - impl Fn() : Les lifetimes sont typiquement inférées sauf si des références sont capturées, simplifiant l'usage.
Trade-offs de Performance
| Scénario | impl Fn() | Box<dyn Fn()> |
|---|---|---|
| Vitesse d'Appel | ~1–2 ns (appel direct) | ~5–10 ns (vtable lookup) |
| Overhead Mémoire | Aucun (stack-allocated) | 16–24 bytes (fat pointer + heap data) |
| Code Bloat | Possible (monomorphization) | Minimal (vtable unique) |
Exemples Avancés
Collection de Closures Hétérogènes
// ✅ Dynamic dispatch permet ceci
fn create_handlers() -> Vec<Box<dyn Fn(i32) -> String>> {
vec![
Box::new(|x| format!("Double: {}", x * 2)),
Box::new(|x| format!("Square: {}", x * x)),
Box::new(|x| format!("Cube: {}", x * x * x)),
]
}
fn main() {
let handlers = create_handlers();
for (i, handler) in handlers.iter().enumerate() {
println!("Handler {}: {}", i, handler(5));
}
}
Static Dispatch avec Générics
// ✅ Static dispatch avec type parameter
fn process_data<F>(data: &[i32], processor: F) -> Vec<i32>
where
F: Fn(i32) -> i32,
{
data.iter().map(|&x| processor(x)).collect()
}
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
// Chaque appel est monomorphized
let doubled = process_data(&numbers, |x| x * 2);
let squared = process_data(&numbers, |x| x * x);
println!("Doubled: {:?}", doubled);
println!("Squared: {:?}", squared);
}
Event System avec Dynamic Dispatch
use std::collections::HashMap;
type EventHandler = Box<dyn Fn(&str) + Send + Sync>;
struct EventSystem {
handlers: HashMap<String, Vec<EventHandler>>,
}
impl EventSystem {
fn new() -> Self {
Self {
handlers: HashMap::new(),
}
}
fn subscribe<F>(&mut self, event: &str, handler: F)
where
F: Fn(&str) + Send + Sync + 'static,
{
self.handlers
.entry(event.to_string())
.or_insert_with(Vec::new)
.push(Box::new(handler));
}
fn emit(&self, event: &str, data: &str) {
if let Some(handlers) = self.handlers.get(event) {
for handler in handlers {
handler(data);
}
}
}
}
fn main() {
let mut system = EventSystem::new();
// Différents handlers pour le même event
system.subscribe("user_login", |data| {
println!("Logging: User {} logged in", data);
});
system.subscribe("user_login", |data| {
println!("Analytics: Track login for {}", data);
});
system.emit("user_login", "alice");
}
Patterns d'Optimisation
Éviter Dynamic Dispatch dans les Hot Paths
// ❌ Mauvais - dynamic dispatch dans une boucle critique
fn bad_hot_loop(data: &[i32], op: &dyn Fn(i32) -> i32) -> Vec<i32> {
let mut result = Vec::new();
for &x in data {
result.push(op(x)); // Vtable lookup à chaque itération
}
result
}
// ✅ Bon - static dispatch
fn good_hot_loop<F>(data: &[i32], op: F) -> Vec<i32>
where
F: Fn(i32) -> i32,
{
let mut result = Vec::new();
for &x in data {
result.push(op(x)); // Inlined
}
result
}
Hybrid Approach
// Combiner les deux approches selon le contexte
enum Operation {
Static(fn(i32) -> i32), // Function pointer (fast)
Dynamic(Box<dyn Fn(i32) -> i32>), // Closure (flexible)
}
impl Operation {
fn call(&self, x: i32) -> i32 {
match self {
Operation::Static(f) => f(x),
Operation::Dynamic(f) => f(x),
}
}
}
fn main() {
let ops = vec![
Operation::Static(|x| x * 2), // Compile-time connu
Operation::Dynamic(Box::new(|x| x + 10)), // Runtime flexible
];
for op in &ops {
println!("{}", op.call(5));
}
}
Benchmarking
Setup de Benchmark avec Criterion
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn bench_dispatch(c: &mut Criterion) {
let static_fn = |x: i32| x * 2 + 1;
let dynamic_fn: Box<dyn Fn(i32) -> i32> = Box::new(|x| x * 2 + 1);
c.bench_function("static_dispatch", |b| {
b.iter(|| static_fn(black_box(42)))
});
c.bench_function("dynamic_dispatch", |b| {
b.iter(|| dynamic_fn(black_box(42)))
});
}
criterion_group!(benches, bench_dispatch);
criterion_main!(benches);
Résultats Typiques
static_dispatch time: [1.2345 ns 1.2456 ns 1.2567 ns]
dynamic_dispatch time: [8.7654 ns 8.8765 ns 8.9876 ns]
Observation : impl Fn() est ~7x plus rapide que Box<dyn Fn()>.
Memory Layout et Overhead
Static Dispatch Memory Layout
use std::mem;
fn analyze_static() {
let x = 42;
let closure = |y| x + y;
println!("Static closure size: {} bytes", mem::size_of_val(&closure));
// Typiquement 4 bytes (taille de x capturé)
}
Dynamic Dispatch Memory Layout
fn analyze_dynamic() {
let x = 42;
let closure: Box<dyn Fn(i32) -> i32> = Box::new(move |y| x + y);
println!("Dynamic closure size: {} bytes", mem::size_of_val(&closure));
// Typiquement 16 bytes (fat pointer: 8 bytes data + 8 bytes vtable)
// Plus l'allocation heap pour les données capturées
}
Points Clés
✅ Choisis impl Fn() pour :
- Code sensible aux performances (ex : chaînes d'iterators).
- Type de closure unique (ex : factory functions).
- Zero-cost abstractions.
✅ Choisis Box<dyn Fn()> pour :
- Comportement dynamique (ex : event handlers, plugins).
- Stockage de types de closures mixtes (ex :
Vec<Box<dyn Fn()>>). - Flexibilité runtime.
Exemples Réels :
impl Fn(): Utilisé dans les adaptateurs d'iterators commemapetfilterpour une performance zero-cost.Box<dyn Fn()>: Commun dans les frameworks GUI pour les callbacks d'événements où la flexibilité est clé.
Verification de Performance
Pour quantifier la différence de performance, benchmark avec criterion :
use criterion::{black_box, Criterion};
fn bench(c: &mut Criterion) {
let impl_fn = |x: i32| x + 5;
let dyn_fn: Box<dyn Fn(i32) -> i32> = Box::new(|x| x + 5);
c.bench_function("impl_fn", |b| b.iter(|| impl_fn(black_box(3))));
c.bench_function("dyn_fn", |b| b.iter(|| dyn_fn(black_box(3))));
}
impl Fn() est plus rapide et utilise moins de mémoire.
Conclusion
Utilise impl Fn() pour un dispatch static zero-cost dans les scénarios critiques en performance avec des types de closures connus. Opte pour Box<dyn Fn()> quand la flexibilité est nécessaire, comme dans les systèmes de plugins ou applications event-driven nécessitant du polymorphisme runtime. Le système d'ownership et de traits de Rust assure que les deux approches sont sûres, le choix dépendant de l'équilibre entre performance et exigences dynamiques.