fasy icon indicating copy to clipboard operation
fasy copied to clipboard

Feature: support async-generators (`async function*`)

Open getify opened this issue 6 years ago • 6 comments

Async-generators are stage3, so fairly likely to land in JS. Seems like fasy should support them eventually, probably sooner than later.

[UPDATE]: They landed in ES2018.

For example:

FP.serial.map(async function *mapper(v){
    if (await lookup(v)) v = yield something(v);
    return v;
});

Implementation should be fairly straightforward, in _runner(..).

Note: I don't think the for-await-of loop will work to run the async-generator, because that construct doesn't let us send a "next value" back in after each iteration. But I think we can do a for-of loop with await inside it, to get almost the same syntax-sugar but have the capability to drive the iterator as we need to. We'll look for Symbol.asyncIterator to know that we need to take this path.

However, before proceeding, we need to decide:

  • [ ] if the async-generator yields out a promise, do we wait on it and resume with its resolution, like we do in the normal generator-runner pattern?
  • [ ] if the async-generator has a final return value, is that indeed the overall completion value of that function's promise (in _runner(..))? Usually, returnd values from a generator would be thrown away
  • [ ] if the async-generator does not have a return value (or if it's return or return undefined), should we assume the last yielded value (or its resolution, if it was a promise) as the overall completion of that function's promise (in _runner(..))?

getify avatar Oct 27 '17 18:10 getify

I like the sound of that, I'd be keen to have a go at implementing something - I've been working on a related thing myself called itrabble and am looking forward to async generators being available for building in that capability.

As for those points to consider, I would (in my very inexperienced experience) expect

  • [x] yes
  • [x] I'd suggest yes so that the promise's return value (if there is one) is able to be used subsequently
  • [ ] I'm not so sure about this last one, but in order to be consistent with the above I'd expect it to return a promise over the value of whatever gets returned (or not) from its final calling. I may well be wrong about how this works, I'm just going from my understanding of the current async/await usage. Does that make sense?

desnor avatar Oct 30 '17 02:10 desnor

I'm still getting up to speed on JavaScript generators and fasy itself, but here are my thoughts at the moment:

  • [x] Waiting on yielded promises? I would assume "Yes", as that would make it consistent with how fasy handles promises normally (at least as far as I understand it at this point).
  • [ ] Keep returned values? Seeing as normal generators throw away return values, my gut feeling is "no", otherwise you start to depart from the standard expectation of how the generator would function. One question I have though is whether one should wait on a returned promise, even if you ultimately throw the result away after it resolves. I'm leaning towards "yes".
  • [ ] Handling generators with no return? If the answer to #2 is "no", this would naturally follow suit and be "no" as well.

TheSench avatar Apr 13 '18 13:04 TheSench

Upon further reflection, I think a bit differently from how I thought earlier.

I think these answers come down to, would/should someone using an async function * generator with fasy be treating it more like an async function or like a function*?

  1. in async generators you're supposed to await promises, not yield them. If an async generator is used, the ability to yield from them seems mis-guided (with fasy, specifically) and thus shouldn't be covered up with magic. Therefore, we shouldn't do the magical wait-on-promise there.

    We have two other options then:

    • any value that is yielded, even a promise, is just sent back in to the generator immediately.
    • any value that is yielded is collected into a list of values, which is eventually the final result of that function. IOW, if yield 1, yield 2, and yield 3 all run, then the final result of that function is a list of [1,2,3]. This idea is consistent with thinking more about it as a generator, which produces multiple values via yield.
  2. But to the initial classification question above, when using fasy, I think async function* is more async function than function *. As such, a return value is the generally expected way to give a result value from an async function. So, any returned value, even the default undefined, is the result.

    But how then does this rule mix with the idea of multiple yield results being collected into a list? My inclination is, if we affirm this rule (2), it would be incompatible with that idea, and therefore that one would be eliminated.

  3. This one would suggest a way to allow a compromise for that previous question, instead of forcing it to be eliminated -- a list of yielded values, if any, can be overridden as the final result, but only by an explicit non-undefined value being otherwise returned.

    That could work, but I no longer think this sort of magic is a good idea. I think this rule should be no.

getify avatar Sep 20 '18 22:09 getify

@getify My two cents on point one: if an async generator yields a promise, it is automagically awaited:

async function * PromiseYielder() {
    var i = 0;
    while(true) {
        yield new Promise(ok => ok(i++));
    }
};

;(async () => {
    const ait = PromiseYielder();

    (await ait.next()).value; // 0 not Promise<0>
    (await ait.next()).value; // 1 not Promise<1>
})();

jfet97 avatar Jun 19 '19 07:06 jfet97

The more I've thought about this, the more I think we have to treat async generators as async functions that happen to also have a yield, which if used behaves the same as await.

The earlier thinking I had was that it being a generator offered an interesting opportunity to support lazy iteration protocols, but fundamentally fasy is about eager iterations. A different set of functions should be provided for lazy iteration.

getify avatar Jun 20 '19 18:06 getify

@getify of course generators are the fulcrum of the lazy iteration in JavaScript (although they are often not used in this way), and that is the main reason I cannot make equivalent yield with await.

We could argue about the fact that both pause the execution of a function and both let multiple values be inserted into it and extracted from it. But is thanks to yield that the control of the execution could be "gifted" to other entities in the code, enabling the above-mentioned lazy iteration. This is not what await is for: considering the async/await pattern as the union of promises, generators and functions like your _runner, is easy to see that the control is taken by runner functions that restart the paused function as soon as possible. This is eager iteration.

Moreover, even if it is not so important, the yield keyword has less precedence than the await operator:

// not valid js
function * gen() {
    yield 1 + yield 2;
}
// valid js
async function af() {
    await 1 + await 2;
} 

On the other hand, coming back to async function *s, we could not ignore the fact that a yielded promise is awaited as the await keyword was used. This make sense because if it were not so, we would have ended up too close to nested promises (or promise of a promise if you prefer): the promise always returned by the async iteration and the promise inside the value field.

So, what about your question? I've not spent much time with the internals of fasy, but of course is evident that is based on eager iteration. And change its nature to support lazy iteration for async generators feels wrong. Even because fasy already ignore the fact that also sync generators could be lazy iterated, you simply handle them with _runner.

Considering the eager iteration fact, I think that fasy should handle a case like the following:

var users = [ "bzmau", "getify", "frankz" ];

FA.serial.map(
    async function *getOrders(username){
        var user = await lookupUser( username );
        return lookupOrders( user.id );
    },
    users
)
.then( userOrders => console.log( userOrders ) );

or:

var users = [ "bzmau", "getify", "frankz" ];

FA.serial.map(
    async function *getOrders(username){
        var user = yield lookupUser( username );
        return lookupOrders( user.id );
    },
    users
)
.then( userOrders => console.log( userOrders ) );

as:

var users = [ "bzmau", "getify", "frankz" ];

FA.serial.map(
    function *getOrders(username){
        var user = yield lookupUser( username );
        return lookupOrders( user.id );
    },
    users
)
.then( userOrders => console.log( userOrders ) );

In other words: an async generator is a generator that happen to also have an await keyword. IMO you should "run" an async generator like a generator, feeding it with any value that is async yielded (here it is the eager iteration) and taking the returned value as the result.

This is what I expect to see, this is what seems more logical considering the eager nature of fasy.

jfet97 avatar Jun 21 '19 07:06 jfet97