Why passing async function as a parameter is complicated?

Quite new to Rust language.

I needed to pass a function as a parameter and it was simple and clean

fn foo(bar: String) -> String {
    bar
}

fn baz(f: fn (String) -> String) {
    println!("{}", f("Hello world".to_string()));
}

And later I found out that I needed to do some async operations in there. First of all, it took me a bit to learn how to do it, but also somehow it ended up being way less readable construction.

async fn foo(bar: String) -> String {
    bar
}

async fn baz<F,Fut>(f: F) 
where
    F: FnOnce(String) -> Fut,
    Fut: Future<Output=String>
{
    println!("{}", f("Hello world".to_string()).await);
}

I am curious. Why it is like that?

It looks like the compiler could have handled something like that:
fn baz(f: async fn (String) -> String) {

I believe this is something that's being looked into, it just hasn't been implemented yet (remember, Rust is still pretty young! async/await have only been around for a year or two)

3 Likes

It's more complicated because the syntax async fn (String) -> String implies that we are talking about a single type for async function pointers, but such a single type can't exist. Every future is actually an entirely separate type (they just implement the same trait), and therefore no two async fns have the same return type.

The generics let you accept any function pointer that returns any type that implements the Future trait.

8 Likes

Thank you. This is interesting. A follow-up question (to make sure that I understand)

"Every future is actually an entirely separate type"

Please bear with me (still new to this).

Does it mean that Future<Output=String> written in one place and Future<Output=String> written in another place will generate two separate types?

And probably going down one level, so does it mean that any generic usage will generate its own type?

Thank you. I know that Rust is reasonably new. However, I didn't realize that async/await is that new.

No, Future is a trait. It's not a type. You can only use it in places that require a trait.

There are certain things you can put in front of a trait to turn it into a type in various ways, though.

1 Like

Generics will duplicate the function for any combination of types its used with. So e.g. your baz will be compiled once for every call with a different return type.

With the right kind of helper trait, it can be made (arguably) somewhat more readable.

async fn foo(bar: String) -> String {
    bar
}

async fn baz(f: impl AsyncFnOnce1<String, Output = String>) {
    println!("{}", f("Hello world".to_string()).await);
}

async fn qux() {
    baz(foo).await;
}
7 Likes

This is pretty cool, and it looks like I am not the only one bugged by this syntaxis :slight_smile:

Of course such type can exist (not with today's compiler, of course, but in principle)! You can handle that is the exact same way str is handled: sure every feature is different, like every string literal type is kinda-sorta different, but that difference only matters to the code which touches that future, that is, the actual implementation of said async fn, everything else may just treat said future as an opaque chunk of memory and then a simple fat pointer would work for async fn.

I really wonder why async in Rust wasn't implemented that way.

Is that really that important from performance POV?

I just wonder if anyone have tried to actually implement async in a way where async fn is a type (like &str is a type).

I understand that it's a lot of work, but I'm just curious if that was contemplated or not. Because there are really no need to make async function pointers special and force that generalization.

Was it just easier to implement that way or are there hidden reasons not to do that?

Well, okay, you're right, such a function pointer can certainly exist, and it does. It's just that the return value is (approximately) a Box<dyn Future> (i.e. a fat pointer). The reason Rust doesn't use this type by default for async fns is that it requires a Box, which involves a heap allocation. One of the major factors in the design of async/await is that it should not require heap allocations.

Yeah, that's the band-aid currently used to turn templates into generics. In all places in the languages and in async, too.

It requires Box precisely because it's just a trait and we have no idea about it's actual implementation.

But all these types are, in reality, generated by a compiler. Compiler can easily include size of the Future in the AsyncFn pointer.

Sure, to actually use that pointer you would need to allocate memory somewhere (on heap or may be in some kind of arena, or in some data structure, maybe) — but that's true anyway: actual code in async fn requires some actual space allocated before you can execute it.

Yes, I understand that. If you call async functions directly and don't pass pointers to such functions around you may avoid heap allocation. But if you do want to pass them around… and call and use arbitrary async functions… at this point memory allocation is basically, a strong requirement and it would be nice not to pay price for the monomorphization when code is structured like that.

If the answer is “we wanted to first make code which can be implemented without heap allocations possible and then, maybe, think about other case” then I'm happy to accept that: async implementation ideas are not as established as “normal” functions implementations (which were, basically, finished half-century ago), and they are easy for managed languages, but hard for unmanaged ones. It's still unclear what's the best way to implement async in non-managed language.

I mean, what you've described here is more or less exactly what fn() -> Box<dyn Future> already is. If not monomorphizing your code is what you're looking for, then go for that.

use futures::future::BoxFuture;

async fn foo(bar: String) -> String {
    bar
}

async fn baz(f: fn(String) -> BoxFuture<'static, String>) {
    println!("{}", f("Hello world".to_string()).await);
}

However you'll need to call it with a smaller helper wrapper that puts it in a box:

baz(|s| Box::pin(foo(s)));

Note that I've used the BoxFuture alias in the futures crate to deal with some details regarding pinning and send in the actual type that aren't worth worrying about.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.