Intermediate return type in async functions and generators

I post this proposal with assumption that it’s not a duplication of any other proposal that I’ve overlooked somewhere in related threads, and that it’ll not make any negative impact on any further discussion about generators design. If that was wrong - just ignore or delete current thread.


Intermediate return type definition

Previously there was defined two return type options for async functions

  • Inner: async fn x() -> T { ... }
  • Outer: async fn x() -> impl Future<T> { ... }

And this proposal introduces third

  • Intermediate: async fn x() -> Poll<T> { ... }

Why we should use it instead

To understand that we must shift from current async/await/generators context and become more focused on ideas behind Reactive Extensions and implementation of coroutines in Kotlin.

Current concept just not allows to express all advantages of proposed syntax and it looks weird instead. Therefore, a proper concept would be revealed below and motivation for it would be described at the end of proposal where all things would become more obvious.

Where it begins

Initially I wanted to propose intermediate return type to:

  • Make @ (await proposal) more consistent with existed ? operator
  • Make async functions signatures consistent with Option, Result, Box, … returning functions
  • Make async functions more similar to Future::poll function and simplify desugaring

Introducing a new generators concept

I quickly realized that intermediate return type allows return Pending; in async functions which is unfortunate side-effect at first glance. But after thinking more about it I’ve found something really refreshing and promising: it should be possible to reuse return Pending; to suspend/resume coroutine, and return Ready(...); to finish it!

That has a lot more sense than any other sytnax:

  • The meaning of return word is perfectly suitable
  • Poll enum variants clearly and explicitly describes intention of code
  • async in function signature annotates the bidirectional return behavior inside
  • We don’t need yield construct anymore
  • It’s extensible enough to implement generators in exactly the same way

First thought about generators was:

generate fn fibonacci() -> Option<i32> {
    let (mut a, mut b) = (0, 1);
    loop {
        return Some(a);
        a = b;
        b = a + b;
    }
    None
}

Further extension

It was obvious that using a separate function modifiers for Future, Iterator, Stream generators would be completely redundant because they duplicates the information that’s also provided by intermediate return type. And I known that sufficiency of annotating function behavior by its return type was proven on practice by Reactive Extensions where Observable, Single, … in signatures is used for it.

Anyway, some uniform function modifier for generators would be still required because we should distinguish between them and regular functions. Again, in this case sufficiency of uniform modifier was proven on practice by Kotlin coroutines where suspend in function signatures is used for it.

So, I selected freed yield keyword for it:

/// Iterator
yield fn fibonacci() -> Option<i32> {
    let (mut a, mut b) = (1, 1);
    loop {
        return Some(a);
        a = b;
        b = a + b;
    }
    None
}

/// Future
yield fn request_user(self, user_id: String) -> Poll<Result<User>> {
    let url = format!("users/{}/profile", user_id);
    let user = self.request(url, Method::GET, None, true)@?
        .res.json::<UserResponse>()@?
        .user.into();

    Ready(Ok(user))
}

But what about Stream

It also was obvious that a different intermediate return type for Stream generators is required because current Poll signature would be ambiguous and as well not descriptive in this context.

I selected the following type instead:

enum Flow<T> {
    Next(T),
    Completed
}

/// Stream
yield fn download_images(self, max: usize) -> Flow<Result<Image>> {
    for i in 0..max {
        let url = format!("images/{}", i);
        let img = self.request(url, Method::GET, None, true)@?.decode()?;
        return Next(Ok(img));
    }
    Completed
}

A general concept definition

We can think that actually returned Iterator,Future,Stream trait and state machine to handle it are generated based on information from intermediate return type.

I can imagine that yield function parser may seek for some kind of proc macro specified as attribute on top of intermediate return type definition:

#[generator(path::to::proc_macro)]
enum Poll<T> {
    Ready(T),
    Pending
}

Summary

  • This may be the most consistent with all other Rust features proposal
  • The final result could be also the simplest to explain for end users
  • It may be extensible enough to allow implementing custom generators in future

Limitations

  • This proposal don’t addresses any low level concepts and is more focused on appearance instead
  • Some lint would be required to enforce termination return type variant at the end
  • The syntax is completely different than regular generate/async/await
  • Using enums for control flow may be surprising for some users
  • Naming may be not descriptive enough but could change
  • Overall, it’s more verbose than in previous concept

Prior art

  • Resolve await syntax thread was from where it originates from
  • Generators I blog post was helpful to identify problems, constrains, and to organize knowledge
  • Inner and outer return types comparison was very inspiring
  • RxJava and Kotlin coroutines provided a real-world experience
  • Many arguments from async/await threads contained a lot of relevant information
1 Like
/// Future
yield fn request_user(self, user_id: String) -> Poll<Result<User>>

My preferred formulation of this signatures is to keep the return value as the Complete value of the generator and only ever yield Poll::Pending

/// Future
yield fn request_user(self, user_id: String) -> Result<User> yields Poll<!>

this fits very well with extension to streams

/// Stream
yield fn request_users(self) -> () yields Poll<Result<User>> {

Except it’s still missing any way to support the Waker.

(I’m slowly working on a blog post about how nice the bidirectional mapping between generators with resume arguments, Future, Stream and maybe even Sink is, I wish it was a bit more complete to include as part of this discussion).

1 Like

I think that the following could be appended for my proposal:

  • Deferred instead of Poll
  • LocalWaker taken as function parameter under async alias (like self)
  • @ (or any other await) works when async is presented in scope
yield fn download_images(async, self, n: usize) -> Flow<Result<Image>> {
    for i in 0..n {
        let url = format!("images/{}", i);
        let img = self.request(url, Method::GET, None, true)@?.decode()?;
        return Next(Ok(img));
    }
    Completed
}

Another enhancemet that further improves consistency of this proposal:

  • No common Generator trait
  • Instead generators are called yield functions
  • Introduced convention to to use I type variable for iterated item and X for terminal
  • trait Sequence<Item=I, End=X> is introduced to compensate Generator functionality
    • Intermediate return type is Result<I, X>
    • It stops iteration after first Err value is occurred
    • It has Iterator combinators adopted for Result instead of Option
    • Its methods like take_while propagates last value as Err instead of consuming it
  • Future trait represents async Iterator with reversed intermediate return type
    • Therefore its associated type is renamed to End=X
  • Stream represents async Sequence
    • Flow is replaced with enum Pipeline<I, X> { Next(Deferred<I>), Last(X) }

With it all generator types can fit into the following table:

+———————+—————————————————————————+————————————————+
|       | Outer                   | Intermediate   |
|———————+—————————————————————————+————————————————|
|       |                         |                |
|       | Iterator<Item=I       > | Option  <I   > |
| Sync  |                         |                |
|       | Sequence<Item=I, End=X> | Result  <I, X> |
|       |                         |                |
|———————+—————————————————————————+————————————————|
|       |                         |                |
|       | Future  <        End=X> | Deferred<   X> |
| Async |                         |                |
|       | Stream  <Item=I, End=X> | Pipeline<I, X> |
|       |                         |                |
+———————+—————————————————————————+————————————————+

This provides a possible solution to ? problem:

  • ? from Iterator generator bubbles only None
  • ? from Sequence generator bubbles Err(x)
  • ? from Future generator bubbles Ready(x)
  • ? from Stream generator bubbles End(x)

And below impls would be added to std:

impl<I> Try for Deferred<I>  {
    type Ok = NotReady;
    type Error = I;
    fn into_result(self) -> Result<NotReady, I> {
        match self {
            Ready(i) => Err(i),
            Later => Ok(NotReady),
        }
    }
    fn from_ok   (_: NotReady) -> Self { Later    }
    fn from_error(i: I       ) -> Self { Ready(i) }
}
impl<I, X> Try for Pipeline<I, X>  {
    type Ok = I;
    type Error = X;
    fn into_result(self) -> Result<I, X> {
        match self {
            Next(i) => Ok(i),
            Last(x) => Err(x),
        }
    }
    fn from_ok   (i: I) -> Self { Next(i) }
    fn from_error(x: X) -> Self { Last(x) }
} 
impl <T> From<T> for Option<Box<T>> {
    fn from(t: T) -> Self {
        Some(Box::new(t))
    }
}

Below are examples of code:

/// Iterator
yield fn fibonacci() -> Option<i32> {
    let (mut a, mut b) = (0, 1);
    loop {
        return Some(a);
        a = b;
        b = a + b;
    }
    None
}
/// Sequence
yield fn count_symbols(data) -> Result<i32, Option<impl Error>> {
    let mut buffered_data = BufReader::new(data);
    let mut string = String::new();
    while buffered_data.read_line(&mut string)? != 0 {
        let symbols_count = string.matches(|c|c.is_alphanumeric()).count();
        return Ok(symbols_count);
        string.clear();
    }
    Err(None)
}
/// Future
yield fn request_user(async, self, id: String) -> Deferred<Result<User>> {
    let url = format!("users/{}/profile", id);
    let user = self.request(url, Method::GET, None, true)@?
        .res.json::<UserResponse>()@?
        .user
        .into();
    Ready(Ok(user))
}
/// Stream
yield fn save_images(async, self) -> Pipeline<Image, Option<Box<Error>> {
    loop {
        let img =self.request("/img", Method::GET, None, true)@?.decode()?;
        return Next(Ready(img));
    }
    Last(None)
}

source of second and source of third examples

Alongside, we should have a special loop construct to properly handle Sequence e.g.:

consume sequence() {
    Ok(x) => proceed(x),
    Err(Some(e)) => warning!("sequence completed with error: {}", e),
    Err(None) => info!("sequence completed successfully"),
}

It also may be a good idea to use the same loop to handle Stream:

consume@ stream() {
    Next(x) => proceed(x),
    Last(Some(err)) => warning!("stream completed with error: {}", err),
    Last(None) => info!("stream completed successfully"),
}

Using it for Iterator and Future shouldn’t be possible

Your list is overlapping, so it requires specialization to work. Also, why not call Sequence, Generator? They seem to have the same semantics.

I’d like if some abstraction would help to simplify it. However, I don’t think that currently it’s too complicated to become a problem. We just need to explain properly that:

  1. Inside of yield function ? always bubbles End type
  2. .into::Result<_, _>() is always pessimistic (results in Err variant)

Edit 1: Sorry, I’ve not understood you correctly from first time, there’s no type overlap. You probably was confused by mistake I made in fifth row where Err was written instead of Some

Edit 2: I realized that I made another mistake: having just From impls wouldn’t make ? to work. So, I replaced them with Try impls that are a proper solution in this case

You’re right that they have the same semantics.

My point against Generator are:

  1. It not fits into table aesthetically
  2. It has too generic name for its functionality
  3. Sequence better emphasizes that after Item comes End
  4. “Generator” would be a perfect term for things that generates state machine in yield functions
  5. We shouldn’t name trait as language feature that wouldn’t be related exclusively to it

I just realized that I made a mistake, there is no overlap in your traits so it should be fine.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.