Trade-offs of different ways to pass closure to functions

I am trying to understand the trade offs of the different ways you can pass a closure to a function. All the ways I could discover I have added in the example below. I understand how generics work (you are trading code size for speed with monomorphization). I am guessing that &dyn is passing a pair of pointers (one for the function and one for the data). But I don't know about the rest.

fn generic<T>(x: T) -> i64
where T: Fn(&str) -> i64 {
    println!("generic = {}", std::mem::size_of::<T>());
    x("foo")
}

fn impl_trait(x: impl Fn(&str) -> i64) -> i64 {
    // doesn't work
    // println!("{}", std::mem::size_of::<impl Fn(&str) -> i64>());
    x("foo")
}

fn dyn_ref(x: &dyn Fn(&str) -> i64) -> i64 {
    println!("dyn = {}", std::mem::size_of::<&dyn Fn(&str) -> i64>());
    x("foo")
}

fn impl_ref(x: &impl Fn(&str) -> i64) -> i64 {
    // doesn't work
    // println!("dyn = {}", std::mem::size_of::<&impl Fn(&str) -> i64>());
    x("foo")
}


fn main() {
    let func = |x: &str| {
        x.len() as i64
    };

    generic(func);
    impl_trait(func);
    dyn_ref(&func);
    impl_ref(&func);

    let outer = 9;
    let other = 5;

    let func = |x: &str| {
        (x.len() + outer + other) as i64
    };

    generic(func);
    impl_trait(func);
    dyn_ref(&func);
    impl_ref(&func);
}

This signature:

fn impl_trait(x: impl Fn(&str) -> i64) -> i64

is equivalent, codegen-wise, to this one:

fn impl_trait<F: Fn(&str) -> i64>(x: F) -> i64

So impl_trait and impl_ref both use monomorphization + static dispatch, while dyn_ref uses type erasure + dynamic dispatch.

The reason you can't call std::mem::size_of::<impl Fn(&str) -> i64>() is that impl Fn(&str) -> i64 is not a specific type, like &dyn Fn(&str) -> i64 is, but a placeholder that you can use in function signatures to either introduce an implicit generic parameter (when it's in argument position) or refer to an unnamed/unnameable concrete type (in return position).

1 Like

So there are really only two ways to accept closures correct?

  • monomorphization + static dispatch : generic, impl_trait, and impl_ref use this
  • type erasure + dynamic dispatch: dyn_ref uses this

Why are there so many different syntax's for first one?

Yep.

Well, there are only two syntaxes: with an explicit generic parameter and with impl. impl_trait and impl_ref are using the same syntax, it's just that you're passing the closure by value in one case and by shared reference in the other. The explictly-generic syntax, with <F: Fn(&str) -> i64>, is the "original" one; the impl syntax was added by this RFC, which describes the motivation for it.

Pedantically a third way, closures that don't capture state coerce to function pointers.

// This will accept your `func`
fn fn_ptr(x: fn(&str) -> i64) -> i64 {
    println!("{}", std::mem::size_of::<fn(&str) -> i64>());
    x("foo")
}
2 Likes