In CPS basics, we used the Java8 lambda notation to illustrate CPS translation/execution
of the program as pseudo code.
Unfortunately we cannot actually model groovy-cps like that, because in Java we cannot write a function that
doesn't return. That pseudo code will very quickly results in StackOverflowError
.
So in groovy-cps, we use Next
, Continuation
, and Block
to model this.
In pseudo-code, a continuation was a lambda of the form (v)->{}
. In groovy-cps, Continuation
interface
represents a continuation. Here, instead of encapsulating the whole remaining program as single lambda,
it encapsulates just the tiny logic of "receive the return value of a call and decide what to do next",
then return "what to do next" as a Next
object
How does Next
captures "what to do next"? Next
is a pair of ...
Block
, which represents a program (say, a function call)Continuation
, which represents how to process the value obtained by evaluatingBlock
Thus the way the execution goes is like:
- a
Block
starts execution, which (very quickly) returns aNext
- We get
Block
andContinuation
from theNext
previous step produced - We start evaluating this new
Block
by giving it this newContinuation
, which (very quickly) returns aNext
- We get
Block
andContinuation
from theNext
previous step produced - We start evaluating this new
Block
by giving it this newContinuation
, which (very quickly) returns aNext
- ... repeat ...
Thus, the "interpreter" main loop is as follows. In groovy-cps, this code is actually in Next.run()
:
Next n = <<program to execute>>
while(true) {
n = <<evaluate n.block with n.continuation>>
}
Ths main loop is a little bit like CPU instruction cycle. Fetch next instruction from Program Counter, execute that one instruction, and repeat that forever. Each instruction is atomic.
We need one more thing to run programs, which is "environments". If you mentally picture how Java program gets executed for a moment, then an "environment" would be everything that's in the call stack — local variables, where the 'return' statement jumps to, and exception handlers.
For example, imagine a program println(x)
which loads a local variable x
and print that value.
To actually run this program, something needs to tell us that the value of local variable 'x' is, say, 42.
If the groovy-cps is a 8086 CPU, environment is Stack Pointer (SP).
In groovy-cps, Env
interface represents the environment.
Env
extends our simple model of Next
, Block
, and Continuation
above. Whenever we evaluate a Block
,
we need accompanying Env
, thus Next
includes Env
, and the Block.eval
method takes both Env
and
Continuation
. Our "interpreter" main loop is now as follows:
Next n = <<program to execute>>
while(true) {
n = <<evaluate n.block with n.continuation and n.env>>
}
If we stop the interpreter main loop in a middle of an execution, we can pause the execution of this program, regardless of what the program is doing at that moment. We can later come back to the loop again to resume the program from where we left off.
Next n = <<program to execute>>
// let's run a program for a bit
for (int i=0; i<100; i++) {
n = <<evaluate n.block with n.continuation and n.env>>
}
// let's take a break
makeSomeTea();
// let's run a program for some more
for (int i=0; i<100; i++) {
n = <<evaluate n.block with n.continuation and n.env>>
}
// let's take a break
bioBreak();
...
In practice it is usually more useful if a program being interpreted can request that the execution be suspended, instead of the code above where a program is paused after executing 100 instructions. This way the interpreted program can pause in a safe state, and it can also report a value to the code that's executing the interpreter loop.
Next n = << parse the following program & prepare the starting point into 'n'
int x = <<compute answer to life the universe and everything>>
// suspend the execution of the program by reporting 42
int r = suspend(x)
// when the interpreter loop resumes, the program gets going again from here
// by picking up the value we received from there
println(r);
>>
while(!interpreted_program_called_suspend()) {
n = <<evaluate n.block with n.continuation and n.env>>
}
int answer = <<value given to the suspend() method>>
// answer should be 42
// 'n' represents the paused interpreted program inside the suspend() call
// modify n.env so that when the program resumes suspend() returns a value we want
n.env.setReturnValueFromSuspendTo("Good job!")
// resume the program and now it will print "Good job!"
while(true) {
n = <<evaluate n.block with n.continuation and n.env>>
}
In actual groovy-cps, Object Continuable.suspend(Object)
plays the role of the pseudo "suspend" function above,
and Continuable.run()
is the interpreter loop. It resumes a previously paused program by supplying
the value to be returned from the suspend()
call, then the run()
method itself returns when
the next suspend()
call takes place. Those two functions are in the ying-and-yang relationship.
Ying's parameter value is Yang's return value, and vice versa.
groovy-cps takes this resumability one step further by making Next
serializable, which allows the program state
to live beyond a life of a JVM. This is the foundation of the resumability in Jenkins Pipeline.
Interpreted program often wants to call other functions, and how we want to run that function depends on whether that function is also interpreted or not.
Consider the following program being interpreted:
// this Comparable is interpreted
Comparable x = new Comparable() {
int compareTo(o) {
if (x > 42) return 1;
if (x == 42) return 0;
return -1;
}
};
// this comparable is from JDK & non-interpreted
Comparable y = new Integer(5);
def z = randomBoolean() ? x : y
z.compareTo(30);
If a random boolean picks x
, then z.compareTo(30)
call should be run in an interpreter loop in CPS,
for example so that the suspend()
call inside, if any, will correctly suspend the program.
But if a random boolean picks y
, then z.compareTo(30)
should run like JVM runs it normally and produce -1,
From the interpreter loop's perspective this entire call will happen in one step, and thus we cannot pause
the execution in-between, but that's OK because such function call will never contains suspend()
call to request
a pause.
At the call site of z.compareTo(30)
, how do we determine whether this method is interpreted?
We do this by actually invoking a function. If a method is non-interpreted, the method will execute normally
and returns a value (or throws an exception if something bad happens.) If a method is interpreted, however,
we generate this method in such a way that it immediately throws CpsCallableInvocation
. If the call site
receives this specific type of exception, then it knows that it just attempted to invoke an interpreted function,
and so it behaves accordingly. ContinuationGroup.methodCall()
is the part of the interpreter that acts as
a call site.