5

What is the recommended approach in JavaScript to passing around generators that include filtering + mapping logic?

Somehow, JavaScript generators are missing such fundamental things as filter and map operands, similar to arrays, to be able to create a generator that includes that logic, without having to run the iteration first.

My head-on approach was to implement custom functions that apply the logic:

function * filter(g, cb) {
    let a;
    do {
        a = g.next();
        if (!a.done && cb(a.value)) {
            yield a.value;
        }
    } while (!a.done);
    return a.value;
}

function * map(g, cb) {
    let a;
    do {
        a = g.next();
        if (!a.done) {
            yield cb(a.value);
        }
    } while (!a.done);
    return a.value;
}

But this creates a callback hell. I want to simply chain a generator, like a regular array:

// create a filtered & re-mapped generator, without running it:
const gen = myGenerator().filter(a => a > 0).map(b => ({value: b})); 

// pass generator into a function that will run it:
processGenerator(gen);

Is there a way to extend generators to automatically have access to such basic functions?

As an extra, if somebody wants to weight in on why such fundamental things aren't part of the generators implementation, that'll be awesome! I would think that filtering and mapping are the two most essential things one needs for sequences.

UPDATE

This all ended with me writing my own iter-ops library :)

vitaly-t
  • 24,279
  • 15
  • 116
  • 138
  • 3
    Looks like there is a proposal https://github.com/tc39/proposal-iterator-helpers currently on stage 2 – skyboyer Jul 03 '21 at 17:17
  • There are also links to some npm packages that might implement some of desired functionality – skyboyer Jul 03 '21 at 17:19
  • @skyboyer That looks interesting. I wonder though, if there is any polyfill to use it today, and not years from now :) – vitaly-t Jul 03 '21 at 17:20
  • 1
    Does this answer your question? [How to extend the Generator class?](https://stackoverflow.com/questions/47534156/how-to-extend-the-generator-class) – Patrik Valkovič Jul 03 '21 at 18:20
  • 1
    Following question is dealing with the same issue: https://stackoverflow.com/questions/47534156/how-to-extend-the-generator-class – Patrik Valkovič Jul 03 '21 at 18:20
  • I saw that question before I asked here. It has a more narrow focus, while the two answers are no good. I tried the accepted answer, it doesn't really work. And the author's answer - that's for Iterators strictly. – vitaly-t Jul 03 '21 at 18:31
  • Do you care about generators (and their `.next(value)`, `.return()` and `.throw()` methods and return values) specifically, or only about iterators in general? – Bergi Jul 03 '21 at 20:55
  • @vitaly-t FYI https://stackoverflow.com/questions/68254834/turning-rxjs-observable-into-an-asynchronous-iterable – loop Jul 05 '21 at 10:53

3 Answers3

5

An alternative to your solution would be to use the for...of loop instead of a do...while.

I would also prefer the filter and map functions to consume and produce a generator function like below :

function filter(gen, predicate) {
  return function*() {
    for (let e of gen()) {
       if (predicate(e)) {
         yield e; 
       }
     }
  }
}

function map(gen, fn) {
  return function*() {
    for (let e of gen()) {
      yield fn(e);
    }
  }
}

function generatorWrapper(gen) {
  return {
    call: () => gen(),
    filter: predicate => generatorWrapper(filter(gen, predicate)),
    map: fn => generatorWrapper(map(gen, fn))
  };
}

function* generator() {
  yield 1;
  yield 2;
  yield 3;
}

const it = generatorWrapper(generator)
  .filter(x => x > 1)
  .map(x => x * 2)
  .call();

for (let e of it) {
  console.log(e);
}
Olivier Boissé
  • 15,834
  • 6
  • 38
  • 56
  • 1
    It may improve implementation of the custom operands, but it does not solve the chaining problem, which is what the question all about. Your solution still relies on nested callbacks, just in a different way. – vitaly-t Jul 03 '21 at 18:08
  • 1
    As `filter` and `map` are not provided by default, you would need to use a wrapper if you want to chain the operations. I updated my answer – Olivier Boissé Jul 03 '21 at 18:19
  • We can wrap a generator in many different ways, and those will all be work-arounds, rather than proper solutions. You then have to remember to wrap every call for generators. – vitaly-t Jul 03 '21 at 18:48
  • b.t.w. your implementations of `map` and `filter` are not quite complete, they also must return the last value, pass the sequence, because this is how the default generator works. Otherwise, your custom handlers lose the return value. – vitaly-t Jul 03 '21 at 20:06
  • "*I prefer to consume and produce a generator function*" - I don't see any advantage there. But if you do this, you should pass through arguments to the generator function. – Bergi Jul 03 '21 at 20:57
  • @vitaly-t Passing values to `.next()` doesn't really work for `filter`. If you wanted to use this functionality, you'd need to specify explicitly how it should work. And it gets even more complicated for `flatMap`. – Bergi Jul 03 '21 at 20:58
  • @OlivierBoissé I implemented my own approach to extending existing iterators, so they can be used in place of the original ones, which makes things easier, as I no longer need to wrap them into anything. See my answer. – vitaly-t Jul 03 '21 at 21:32
  • `I no longer need to wrap them into anything`, with your solution, you need to call `Iterable.extend(test)` so it's quite the same as using a `wrapper` ? This [solution](https://stackoverflow.com/a/47534339/5521607) might be a better one – Olivier Boissé Jul 04 '21 at 09:43
1

I may have figured out a proper solution to this...

I created class Iterable, which extends on this answer:

class Iterable {
    constructor(generator) {
        this[Symbol.iterator] = generator;
    }

    static extend(generator, cc) {
        // cc - optional calling context, when generator is a class method;
        return function () {
            return new Iterable(generator.bind(cc ?? this, ...arguments));
        }
    }
}

Iterable.prototype.filter = function (predicate) {
    let iterable = this;
    return new Iterable(function* () {
        for (let value of iterable)
            if (predicate(value)) {
                yield value;
            }
    });
};

Iterable.prototype.map = function (cb) {
    let iterable = this;
    return new Iterable(function* () {
        for (let value of iterable) {
            yield cb(value);
        }
    });
};

Now we can take an existing generator function, like this:

function* test(value1, value2) {
    yield value1;
    yield value2;
}

and turn it into an extended iterator:

const extTest = Iterable.extend(test);

and then use it in place of the original generator:

const i = extTest(111, 222).filter(f => f > 0).map(m => ({value: m}));

This now works correctly:

const values = [...i];
//=> [ { value: 111 }, { value: 222 } ]

UPDATE

In the end of all this, I wrote my own iter-ops library.

vitaly-t
  • 24,279
  • 15
  • 116
  • 138
  • I think you should better distinguish between iterators, generators and generator functions. `test` is not an iterator (not even iterable). – Bergi Jul 03 '21 at 21:34
  • It is well distinguished now. I was still refactoring my answer when you posted it. – vitaly-t Jul 03 '21 at 21:35
  • Nothing in the code changed since I posted my comment. I'm nitpicking on calling a `.bind()` method on iterators - they only have `.next()` (and optionally `.return`/`.throw`) – Bergi Jul 03 '21 at 21:37
  • That was renamed into `generator`, because that's what it is. – vitaly-t Jul 03 '21 at 21:40
  • Just now, yes. But no, it's not a generator - it's a generator *function*. – Bergi Jul 03 '21 at 21:41
  • Those terms are mostly interchangeable :) Good enough for an argument name anyhow :) – vitaly-t Jul 03 '21 at 21:42
  • Btw I think you should `.bind(this, ...arguments)`, not use the function itself as the this value. – Bergi Jul 03 '21 at 21:44
  • That's not right. The context must be the generator itself, not the wrap function context. No? :) – vitaly-t Jul 03 '21 at 21:46
  • @Bergi Anyway, I made method `extend` better, after some testing here ;) – vitaly-t Jul 03 '21 at 21:58
  • Oh, I had not heard about IxJS. Thanks for the tip. :) – loop Jul 05 '21 at 12:23
  • I think my thing to turn a RxJS Observable into an asynchronous iterable is working now. Now you can first convert any iterable into an Observable with RxJS from(), then do the manipulations within pipe(), then convert the resulting Observable back into an iterable and then iterate through it with "for await". https://stackoverflow.com/questions/68254834/turning-rxjs-observable-into-an-asynchronous-iterable – loop Jul 06 '21 at 10:51
1

How about a pipelining function that will take the original iterable and yield values through pipelined decorators?

const pipe = function* (iterable, decorators) {
  // First build the pipeline by iterating over the decorators
  // and applying them in sequence.
  for(const decorator of decorators) {
    iterable = decorator(iterable)
  }

  // Then yield the values of the composed iterable.
  for(const value of iterable) {
    yield value;
  }
};

const filter = predicate =>
  function* (iterable) {
    for (const value of iterable) {
      if (predicate(value)) {
        yield value;
      }
    }
  };

const map = cb =>
  function* (iterable) {
    for (const value of iterable) {
      yield cb(value);
    }
  };

const mergeMap = cb =>
  function* (iterable) {
    for (const value of iterable) {
      for (const mapped of cb(value)) {
        yield mapped;
      }
    }
  };

const take = n =>
  function* (iterable) {
    for (const value of iterable) {
      if (!n--) {
        break;
      }
      yield value;
    }
  };

function* test(value1, value2) {
  yield value1;
  yield value2;
}

function* infinite() {
  for (;;) yield Math.random();
}

for (const value of pipe(test(111, 222), [
  filter(f => f > 0),
  map(m => ({ value: m }))
])) {
  console.log(value);
}

for (const value of pipe(infinite(), [
  take(5),
  mergeMap(v => [v, { timesTwo: v * 2 }])
])) {
  console.log(value);
}
.as-console-wrapper {
  max-height: 100% !important;
}
loop
  • 825
  • 6
  • 15