Generator Support
TL;DR: Skip now has generators!
If you've ever used the yield
keyword in Python, Hack, Javascript, C#, etc., you know how handy generators can be. And now Skip has them!
While implementing async
/await
, I noticed that the coroutine support I was adding to the compiler would make generators easy to implement. So I took a brief detour and added them to the language too.
A few examples
Suppose you want to write a function that produces a stream of square numbers, but without actually allocating a container to hold them all. You can now write:
fun squares(count: Int): mutable Iterator<Int> {
foreach (i in range(0, count)) {
yield i * i
}
}
// Enabling users to write something like this:
foreach (n in squares(100)) { ... }
That’s a bit contrived, so let's look at a real example of how generators can improve Skip's standard library. Skip Iterators support chaining into pipelines, such as:
x.values()
.filter(a -> a.isValid())
.map(b -> b.name)
Previously, Iterator
's filter()
method created an instance of a custom FilterIterator
class that wraps its own value stream:
overridable mutable fun filter(p: T -> Bool): mutable Iterator<T> {
mutable FilterIterator(this, p)
}
By itself that looks fine, but of course it requires writing a separate FilterIterator
class, and that class does a bunch of messy stuff (don’t worry about the details):
mutable fun next(): Option<T> {
found: Option<T> = None();
while_loop(() ->
this.base.next() match {
| Some(x) ->
!this.p(x) ||
{
!found = Some(x);
false
}
| None() -> false
}
);
found
}
That code was written before Skip supported early return
, so it could be made more concise. But with generators, we can go much farther than incremental tweaking; in fact, the FilterIterator
class is now completely gone, leaving us with only this method on Iterator:
overridable mutable fun filter(p: T -> Bool): mutable Iterator<T> {
foreach (v in this) {
if (p(v)) yield v
}
}
I think you'll agree that's much easier to read!
Implementation details
Skip's generators are implemented in basically the same way that other languages do.
For each function containing a yield
, the compiler creates a custom Iterator
subclass whose next()
method holds a transformed version of the the user’s function. The original function is replaced with a stub that simply returns a new instance of that subclass.
The transformed next()
method weaves a simple state machine into the user's code, with states corresponding to "initial call", "done", or which specific yield
was executed most recently. On entry, next()
does a switch
on the state number field, resuming execution where it last left off.
Each yield x
saves that yield
's state number and any live local variables into fields, so they can be restored on the next call to next()
, then returns Some(x)
. When next()
is completely finished, it returns None()
.
The Skip compiler primarily targets LLVM, although there is also Peter Hallam’s Javascript back end, which transpiles to native Javascript generators. I looked into leveraging LLVM's coroutine support, but I couldn't use it because I need to control the coroutine suspend state in order to describe it to Skip's garbage collector. But even if I could solve that, the strategy of desugaring generators into normal Skip objects allows us to apply our standard mid-level optimizations to that code before handing it to LLVM, including potentially inlining all of the generator code and removing the heap allocation.
Next up, async/await!