Generators Example

Generators in Vodka, like those provided by Python or Ruby, are functions that produce a sequence of values, yielding control to the caller/receiver each time a new value is generated. After the receiver has consumed the new value, the generator resumes after the yield statement. This way, sequential output from complex data structures can be produced in a forward manner, being consumed on the fly, without intermediate data-structures.

Due to the concurrent nature of the Vodka language, generators (and coroutines, as the implementation mechanism) are a natural fit. Instead of introducing a special yield keyword, we can yield control from any function, just by calling another function received as a parameter. Additional syntax, though, is provided on the consumer side, in order to facilitate generator comprehension. The code below shows a simple generator, and different syntax for printing the generated values to standard output. There is also a built-in syntax for generator literals, which allows the genTest generator to be written as [1,2,3].

new genTest;

def genTest(yield):
{
    yield(1);
    yield(2);
    yield(3);
    return();
}


# example usage

genTest(print);

genTest->foreach(print);

print x for x in genTest;

for x in genTest:
    print x;

Generators are especially powerful when combined through higher-order functions, and many of those that commonly appear in functional languages can be implemented straightforwardly. In the code below, we show a simple range operator that enables expressions like 3..7, and the higher-order function zip, which synchronizes multiple (in this case: two) source generators. Other generator combinators can be written accordingly, either from scratch or by composing existing ones with the help of generator comprehension statements (for ... in ...).

new `..`;
def `..`(x, y):
{
    new gen;
    def gen(yield):
    {
        new iter;
        def iter(n, false):
        {
            returnto.gen();
        }
        def iter(n, true):
        {
            yield(n);
            iter(n+1, n+1 <= y);
        }       
        iter(x, x <= y);
    }
    return gen;
}

new zip;
def zip(g1, g2):
{
    new gen;
    def gen(yield):
    {
        new r1;
        new r2;
        
        def r1(a1) & r2(a2):
        {
            yield(a1, a2);
            returnto.r1() &
            returnto.r2();
        }
        g1(r1) & g2(r2);
        return();
    }
    return gen;
}

map = fn gen, f: (f(x) for x in gen);

zipWith = fn (g1, g2), f: zip(g1, g2)->map(f);

cartesian = fn a, b: (x, y for x in a for y in b);

The code below shows some more example uses of generators. An important thing to remember is that generators are in fact functions (and thus first-class values), so generator comprehension expressions, too, can be treated as such.

# generators as values

oneToTen = 1..10;

oneFourNine = [1,4,9];

oddNumbers = i if i%2 == 1 for i in 1..10;


# reduction functions

sumOfSquares = sum(x*x for x in 1..10);

sameFringe = all((treeA->leaves(),treeB->leaves())->zipWith(`==`));


# printing square numbers: 1, 4, 9

for x, y in zip(1..3, 1..3):
    print(x * y);

(1..3, 1..3)->zipWith(`*`)->foreach(print);

[1,2,3]->map(fn x: x*x)->foreach(print);


# printing cartesian product: (1,1),(2,1),(1,2),(2,2),(1,3),(2,3)

for y in 1..3:
    for x in 1..2:
        print(x, y);

cartesian(1..2, 1..3)->foreach(print);

Another powerful abstraction are reduction operations (like sum, max or all) that are also common in functional programming languages. Together with the lazy nature of generators, they provide for a natural formulation and efficient implementation of solutions to non-trivial problems such as whether two trees have exactly the same inorder-sequence of leaves (known as the Samefringe-Problem) assuming in this example that the leave-generators produce a special end-of-fringe marker to guarantee termination.

Turning Generators Inside Out

By nature of their construction, generators operate in push mode, proactively producing values and handing them off to a receiver. In some cases, though, it is desirable to have pull access to a data source, e.g. when the pattern of consumption is non-regular as is the case in the example below. For those cases, we can define a generalized pull-handler that inverts the generator logic, providing an iterator-style next function for any given generator.

new pull;

def pull(gen):
{
    new yield;
    new next;
    
    def yield(n) & next(r):
    {
        returnto.yield(r) &
        returnto.next(n);
    }
    
    gen(yield) & return(next);
}

next = pull(1..100);

print next();

next();
next();

print next();

next();
next();
next();
next();

print next();

Along the same lines, with just a little more code, we can also build a fully updatable cursor that provides prev, update and delete functions that operate without stateful modification of the underlying generator, implementing a very basic instantiation of the Zipper approach.

Parallel Generators

Last but not least, we can build generators that yield values in parallel instead of in sequence, by issuing statements like yield(1) & yield(2) & yield(3); or using the shorthand syntax [1 & 2 & 3]. For the most part, higher-order functions need not be changed to accomodate for parallel generators. The functions map and zip, for example, carry over unmodified, as well as the for-comprehension syntax.