An Alternative Syntax for Async Functions

When returning an existential from an async fn, that might get problematic:

trait Foo {
    async fn foo() -> impl Iterator<Item = usize>
    where
        return: Send;
        // developer really meant
        return::Output: Send;
}

async modifying the return type is definitely one of the weirdest parts of the language to me - not to say that the proposed

async fn foo() -> impl Future<Output = usize> { 1 }

is reasonable either.

Spitballing some intermediate points:

  1. fn foo() -> async(usize) { 1 }
  2. async fn foo() -> impl Future<usize> { 1 }
  3. fn foo() -> impl Future<usize> => async { 1 }
  4. async fn foo() -> k#future usize { 1 }
  5. fn foo() -> k#future usize => async { 1 }
  6. fn foo() -> k#fut usize => async { 1 }
  7. async fn foo() -> k#fut usize { 1 }
  8. fn foo() => async { 1 }: usize
2 Likes
trait Fut<T> = Future<Output = T>;

async fn foo() -> Fut<usize> { 1 }

Just curious, do you think that is reasonable from an aesthetic perspective?

IMHO the main value in async syntax is that you can initially treat it as a "colored" function, but then peel back that layer later.

Specifically, that you can say "for an async fn, to call it you need to be in an async fn and you call it as foo().await" without being problematically wrong. When it becomes necessary to join! or otherwise handle the unevaluated future, you can introduce leaving off the await as equivalent to a closure which is invoked with .await instead of ().

The main purpose of async fn is to write application code that will essentially be either immediately .awaited, select!()/join!()ed, or otherwise evaluated immediately. It's the exact same case as with regular fn: the normal case is to just run the code roughly "straightline."

This is, by my estimation, a supermajority of async code. A majority of code exists to move data from point A in form X to point B in form Y, and for that simple async.await is ideal.

In library cases which need to handle/support more involved use cases than quickly .awaiting, there's really nothing wrong with falling back to the equivalent (because functions aren't colored) formulation of just writing

fn foo(/* inputs */) -> impl Future<Output = _> {
    /* prep */
    async {
        /* work */
    }
}

There is a significant need around clarifying Send/Sync for use of async in traits. But also, the majority async user isn't going to be writing an async trait, they're going to be using the trait (trivial), or perhaps implementing. And we can make these easier without complicating the common case of just writing an async fn.

Almost all proposals to change async fn fall into the pitfall of, while trying to make complex tasks simpler to execute, making the simple tasks complex.

Rust has picked a path here. It will be more productive for everyone to forge ahead on the path we've chosen than to argue that perhaps we should've taken a different turn at a previous fork in the path. So long as we're accurate at pathfinding, moving forward will get us further than trying to move laterally. (If the metaphor makes any sense, anyway.)

9 Likes

The path chosen by Rust is over-fitted to the training data, to borrow a data science concept. This has obvious downsides. It is true that moving laterally would incur costs. However, that doesn't mean that staying the path is a clear winning strategy either. It is a trade-off that would incur other costs.
In particular:

  • Accidental complexity - there would be another instance in Rust where "there is more then one way to do it" since the current design can't work in additional scenarios such as in traits.
  • Lack of consistency and uniformly - this always impacts negatively on productivity. This is one major reason why C++ tend to be a productivity sinkhole.
2 Likes

More than one way to do things isn't a problem when one way is strictly more general than the other way.

Let the simple thing use the simple way, where it doesn't handle the full generality of the problem, but it serves the common case well.

You could also use the general way for the simple case, but you don't have to. The general way is there for the less common case when you need more control than the simple way can give you, and in exchange for that control you pay with more decision points and more complexity that you have to deal with.

Where more than one way to do things becomes problematic is if they have competing and/or incompatible benefits. async fn isn't that, just the same way that for or if aren't.

The common, simple way doesn't have to be the general way, if it offers a useful simplification (for async, approximating straightline code) on a well-defined common subset of the problem.

If we just had

fn foo(input: &Input) -> impl Future<Output=Output> + 'fn {
    async {
         input.process().await
    }
}

I could completely see the sugar of

async fn foo(input: &Input) -> Output {
    input.process().await
}

being added as a positive change. Maybe in some other form (moving async towards the return type, maybe), but the point of the sugar is to make it look more like straightline code.

And now, the requisite no, u: the "fully expanded" -> impl Future { async { form exists by necessity; it's the form that any sugared form is equivalent to. If you're arguing for any async fn type sugar, no matter the form, you're arguing that there should be more than one way to do it.

Looking at it from a "one way to do it" pov, but tempered with "simple simple, complex possible," I think it's clear that in that position I'd rather have today's async fn because it serves a specific purpose, whereas any sugar that "exposes the outer type" isn't differentiated enough from the generic case to be worth the extra way to do it. (Taking it to the extent of the argument, because eliminating the extra indentation of the body when the body is just one block expression is better handled by generally allowing the body to be a block expression.)

Fun future with "keyword block expression" as the body of a fn

This is not a suggestion, desire, or even what-if; it's an "alternate reality" Rust-ish language.

fn normal(input: &Input) -> Output {
    // body
}

fn future(input: &Input) -> impl Future<Output = Output> async {
    // body
}

fn unsafe_impl(input: &Input) -> Output unsafe {
    // body
}

fn result(input: &Input) -> Result<Output, Err> try {
    // body
}

fn const_impl(input: &Input) -> Output const {
    // body
}

fn run(input: Input) loop {
    // body
}

fn must_be_pre_thread() mut {
    // maybe?
}

It does have a certain charm to it, though. Or maybe I've just been writing too much C++ recently (East const gang).

2 Likes

I generally agree with the sentiment regarding a common subset made simple. The Pareto principle you've mentioned is indeed a good guiding principle. But the devil is in the details.

I'm totally on board of we leave the current sugar only for free functions and document this properly as you described. In this case removing the special case is indeed unjustified.

The problem is that we have lots of energy wasted on trying to expand the current shortcut to the more complicated use cases.

Thanks for making the other point, that a function's body can be generalised to any block expression which removes the extra indentation. This is the most elegant design imo.

To me that looks like that Fut<T> is a trait alias for Future<Output = T> and foo is returning an unboxed trait object that implements Fut<usize>, which is definitely not the case here.

1 Like

From an aesthetic perspective yeah, I don't love the angle brackets but I don't love my other thought of introducing a keyword either, fundamentally this level of annotation overhead seems in line with the rest of rust. (And is better than the status quo, which at least imo isn't.)

From a semantic perspective, @SkiFire13 has the right that silently dropping the impl makes it look like Fut is a concrete type rather than a trait.

2 Likes

Does adding impl make it that much worse? async fn foo() -> impl Future<usize> {}

type Future<T> = impl std::future::Future<Output = T>;

async fn foo() -> Future<i32> { 0 }

?

EDIT: looks like this wont work because type F = impl Trait doesn't work how i think it does.

impl Trait there refers to a single concrete type, based on a defining use of the type alias.

You will note this is ~verbatim option 2 from my list :stuck_out_tongue:

  1. async fn foo() -> impl Future<usize> { 1 }

Getting rid of only "Output = " doesn't make things that much better, but better at all yep.

I beg to differ. I think that introducing an inconsistency into the type system just to save you 7 keystrokes is not a good thing.

Or are you proposing to allow this sugar for all traits that have an associated type?

1 Like

Ultimately I am proposing that making all async fn type signatures much longer would be worse, and making them not as much longer would make them not as much worse. With various possibilities that if people like the broad shape of, we can go about making sensible general interpretations of.

For that one in particular, yeah I certainly would be opposed to special-casing Future in the compiler(and one of the arguments for the future keyword is that keywords clearly communicate special-casedness). If we do go this route, probably it would involve a way of declaring a trait to have positional associated types(or at least one positional associated type), which may or may not still be externally available by name.

1 Like

FWIW, I've just published the very basic async_fn crate, which features a #[bare_future] attribute which makes it so one has to specify the explicit type of the returned Future. It also features a Fut<'lt, Output> shorthand alias, which ends up yielding the following syntax:

#[bare_future]
async fn input(input: &Input) -> impl Fut<'_, i32> {
    let foo = input.stuff().await;
    foo.bar()
}
  • Granted, it currently involves the overhead of a proc-macro parsing and changing the given input (replacing the async fn … { … } with a fn … { async move { … }}), but if something like this were to be well received (I personally doubt so, but who knows), then a proposal for a language / non-proc-macro attribute could be advocated for, which would achieve the same result but without the compile-time penalty.
  • there is no extra rightward drift;
  • it remains async fn-greppable;
  • there is a marker that tells readers this is not a classic signature;
  • the Fut shorthand is quite handy;
  • there is a before_async! { … } "dual" macro to put, for instance, the necessary Arc::clone-ing and whatnot;
  • [TBD] there will be a 'args (or 'fn?) lifetime available to feature the semantics of an async fn.
7 Likes

Positional Associated Types · Issue #126 · rust-lang/lang-team · GitHub would make this possible.

// async fn foo() -> usize { 1 }
async fn foo() -> impl Future<usize> { 1 }

:pinching_hand:

2 Likes

There could be a different form of type aliases (which would match the intuition most people have in the first place about them), say macro type Fut<T> = impl Future<Output = T>;

Then it drops to async fn foo() -> Fut<usize> { 1 }.

1 Like

The main argument against this proposal seems to be that "it optimizes against the common case". We can get the syntax down to async fn foo() -> impl Fut<usize> { 1 } on stable, and so I don't think that this argument stands. The function is perfectly readable (returns a future that outputs a usize), and is succinct enough that I don't think it would become cumbersome to right regularly. After all, it's very similar to many other typed languages:

// rust
async fn foo(input: usize) -> impl Fut<usize> {
    1
}

// typescript
const foo = async (input: usize): Promise<usize> => {
    1
}


// scala
def foo(input: usize): Future[usize] = Future {
    1
}

// dart
Future<usize> foo(usize input) async {
  return 1;
}

// c#
async Task<usize> foo(usize input) {
    return 1;
}

The rust version has one additional piece than the above examples, impl. And the distinction between -> impl Future and -> Box<dyn Future> is important! Rust has made that clear with explicit keywords for the different dispatch methods, so I think this falls much more in line with the rest of the language.

Well, inventing new syntaxes for almost identical use cases is also a huge pile of accidental complexity.

3 Likes