Why use type parameters as the standard closure-passing idiom?


#1

Hi all -

The standard way to pass a closure to a function is with a signature of the form:

fn do_thing<F, T>(some: Thing, f: F) -> T
    where F: Fn(Thing) -> T { f(some) }

But in Rust we generally avoid passing things by value unless there’s a real need; borrowing is preferred, which suggests this would be a better signature:

fn do_thing<F, T>(some: Thing, f: &F) -> T
    where F: Fn(Thing) -> T { f(some) }

But given that it’s a borrow we can avoid a concrete type and pass a trait object:

fn do_thing<T>(some: Thing, f: &Fn(Thing) -> T) -> T { f(some) }

This seems like a much nicer signature, and much closer to what new users would expect to see. It avoids the need for a boxed trait object, so there’s no allocation to worry about. There’s no concrete type so it won’t be explicitly monomorphized, but that doesn’t mean the compiler won’t be able to find inlining opportunities, well, opportunistically.

The Closure chapter does cover this as “dynamic dispatch”, so this is more a question of emphasis. Monomorphization can be pretty expensive in terms of code size, which is a tradeoff that isn’t really covered, and I think the dynamic form is pretty unusual in the wild.


#2

But in Rust we generally avoid passing things by value unless there’s a real need; borrowing is preferred.

In cases like this, the standard library usually implements the trait on &S where S: Trait so one can either pass by value or by reference (e.g., AsRef). Fn is no exception to this rule (you can pass &|...|{} or |...|{}.

If you were to only accept references to functions, the caller would have to manually reference closures when they pass them (e.g., iter.map(&|x| x+1)).

But given that it’s a borrow we can avoid a concrete type and pass a trait object.

This would (usually) inhibit optimizations and add a significant runtime cost to calling the closure. Currently, iter.map(|x| x + 1).filter(|x| x < 10).count() will (or at least should) be optimized just as well as:

let mut count = 0;
for x in iter {
    let x = x + 1;
    if x >= 10 {
        continue;
    }
    count += 1;
}

This is because the compiler will inline the closures and effectively re-write the first piece of code into the second.

Passing a trait object would, in most cases cases (this case may be too small to notice a difference), force the compiler to make virtual function calls.


#3

Monomorphization can be seen as a variant of inlining, and like inlining it has a tradeoff between code expansion vs optimisation opportunities exposed.

In this case, if the outer function is small, and the bulk of the work is expected to be done within the closure - such as your map example - then I think the tradeoff is heavily on the monomorph/inlining side.

But alternatively, if you have a large/complex function with lots of its own logic, which takes a closure for some part of that, then replicating that body for each different closure seems like a poor tradeoff - the increased code size/icache pressure is probably more expensive than the cost of just doing an indirect call. And if it turns out to be useful, it doesn’t prevent the compiler from inlining the closure anyway, effectively giving the same result as monomorphization.

My point is not that one way or the other is right or wrong, it’s that there is a tradeoff here, and we seem to be promoting one way over the other without also highlighting the tradeoff. It will tend to make Rust look good in microbenchmarks, but could result in bloated executables in real use.


#4

The usual way of dealing with this is to use a helper function:

pub fn my_fn<F: Fn()>(f: F) {
    complex_fn_helper(&f as &Fn);
}

fn complex_fn_helper(f: &Fn()) { /* ... */ }

This way, APIs are consistent and easy to use (no need to manually cast to a trait object) and only the small my_fn function is monomorphized (and optimized away). However, this usually doesn’t come up with closures (at least in the standard library) as you usually do want monomorphization.

However, this idiom is commonly used for AsRef and friends:

pub fn my_nice_api<P: AsRef<Path>>(path: P) {
    complex_fn_helper(path.as_ref());
}

fn complex_fn_helper(path: &Path) { /* ... */ }