-
Notifications
You must be signed in to change notification settings - Fork 16
Development Design Interpreter
Wollok is an interpreted language, that means that when you run the interpreter against a file it will read the text and execute the logic as it goes. So the text is directly "interpreted" and executed. There's no intermediate step like compiling to a bytecode, etc.
That was a simplification. Our interpreter doesn't "read text" directly. As we work on top of XText, that framework does many of the work for us. It identifies tokens, parsers, then links elements (for example a reference to a variable), and performs static validations. After all that, it will give us "objects". So our interpreter is basically a java program (written in xtend) that process those objects (instead of text).
A file will become an instance of WFile class. That will have elements, like WPackage, WClass, etc. etc. You can check the WollokDSL.xtext file for the full grammar. XText generates classes for each rule in the grammar.
For more info on this, start with the XText tutorials.
As a natural design decision we came up with a separation of concerns. We have a couple of classes which models a "generic" interpreter, and then we have decoupled the logic to interpret ("evaluate" from now on) each of the elements of the Wollok language.
We call that "generic" layer XInterpreter
The following diagram summarizes the main elements of this "mini-framework"
The main class and entry point is XInterpreter. This works like a black-box, you call "evaluate(EObject)" passing to it the WFile object (that came up from parsing the file), and it will "interpret" it and cause whatever effect the program does.
Notice that it is generic, since it uses EObject. EObject is the most generic class on EMF, fwk on which xtext is based on. So this means that this works for any language.
The XInterpreter implementation (because it is an interface) has the following responsibilities
- Holds the state of the execution in the form of a Stack
- Knows how to push and pop a new context into the stack and execute an specific evaluation logic (performOnStack)
- Notify the XDebugger about the step by step execution.
- Delegates the actual logic of evaluating an element into a Strategy (XInterpreterEvaluator)
The flow control of the interpreter depends on your implementation of the XIntepreterEvalutor. For example this is the implementation for the "if" statement in Wollok
def dispatch evaluate(WIfExpression it) {
val cond = condition.eval
// I18N !
if (!(cond.isWBoolean))
throw new WollokInterpreterException('''Expression in 'if' must evaluate to a boolean. Instead got: «cond» («cond?.class.name»)''', it)
if (wollokToJava(cond, Boolean) == Boolean.TRUE)
then.eval
else
^else?.eval
}
We notice here a couple of things
- The flow control meaning which statement/object needs to be evaluated next, is coded here based on the java/xtend if. That's one of the advantaged of creating the interpreted on top of an existing language.
- Many times evaluating an object involves evaluating some other objects, like in this case the "boolean condition".
- Everytime you must evaluate another object, make sure you pass it over to XInterpreter again!!. At the end it will come back here to the evaluator, but you need to make it go through the interpreter because he is in control. For example this will make the debugger work fine (more explained later)
The return in wollok is a special case because it breaks the current execution. This is currently implemented, probably not in the best way :P. When Wollok evaluates a WReturn object it will throw a special exception ReturnValueException. This exception is handled by XIntepreter which will pop the current context getting out of it.
The interpreter always has a current "state" modeled as an XStackTraceElement. That object in turn has two other objects:
- SourcecodeLocation: which has information about what's the point in the code (text) that defined this context. I.e.: a method, or a constructor, or a test, or a program, etc.
- EvaluationContext: this is the most interesting object. It has the state of this scope.
There's a direct relation between a XStackTraceElement and its EvaluationContext and the concept of "scope". The EvaluationContext can be thought as the "scope" of that point in the code.
Each element in the stack is basically what you see when you print an exception stack trace in wollok. Example
wollok.lang.MessageNotUnderstoodException: a AsustadorNato[nivelMotivacion=100, edad=25, puntosTerrorInnatos=200] does not understand cantina()
at wollok.lang.Object.messageNotUnderstood(name,parameters) [/wollok.wlk:151]
at wollok.example.monstersinc.Monster.scare(boy) [/wollok.wlk:151]
at [/workspace.wpgm:23]
the "at ..." part is basically printing the SourceCodeLocation in a pretty way.
The evaluation context has the available state at this stack position. The state is modelled as a "name-value". It doesn't mean that it's actually a map as implementation
We said that executing a method or constructor, involves a new EvaluationContext, what's that for ? Because as we all know from any programming language, methods and constructor have local variables and parameters which should only live as long as the method is running. Once the method returns, that state should be discarded.
So the two situations that we have in wollok for context are:
- executing a method
- executing a constructor
Now think about it in terms of scope. What's the scope within a method:
- local variables
- parameters
- instance variables: from the current class/object + inherited
- variables outside of the class/object: for example global objects. (This part is actually more complex because there are imports, and also anonymous objects can be declared within a method body, and its scope inherits the scope of the method :P, but lets keep that for later
So what we conclude here is that scopes are commonly "nested". The scope of a method is the scope of the object + its local parts (params + local vars)
And that's why we have modeled EvaluationContext with the following set of classes:
The MapBasedEvaluationContext is the most generic implementation which as its name tells it holds and resolves variables based on a map. This one is used for storing local variables as well as parameters (something funny here is that, in runtime wollok allows you to modify vals and parameters. The check that won't allow you to do that is actually static and not part of the interpreter
The WollokObject class is the runtime representation of all objects in Wollok. That class has a lot more code and responsibilities, but to simplify the design it also implements EvaluationContext. The idea here is that the object itself "is a scope" because it has its instances variables
This one is also a generic implementation for modeling the "chain of contexts". It has a list of other EvaluationContexts. Tries with the first one, if it doesn't resolve the variable, then it goes through the next one, and so it goes.
WollokNativeLobby and LazyWollokObject are special cases that will be probably deprecated or redesigned. So don't pay much attention. The native lobby is the "this" object of a wollok program. As programs are special constructions. We should probably need to model de wollok.lang.Program class and then change the interpreter so that when it finds a WProgram it will instantiate that class. In that way it will be just treated as a regular object.
Here is a sample code to understand graphically the evaluation contexts
class Bird {
var energy = 100
var age = 3
method fly(meters) {
val deltaEnergy = meters * 0.5
energy -= meters <<< executing HERE
}
}
program a {
val bird = new Bird()
val toFly = 24
bird.fly(amountToFly) <<< and coming from HERE
}
If you put a breakpoint in the line with the comment and analyse the evaluation context we will see something like this:
Remember that each stack element has its own evaluation context, so the stack of this program can be thought as the following diagram:
Although notice that in wollok each element of the stack, meaning each row there in the diagram is isolated. The scope of #1 cannot access the scope of #0. Meaning that from within the fly() method of the bird you cannot access the variables: "bird" and "toFly". Which is logical in OOP
So who is in charge of creating evaluation contexts ? Well not the XInterpreter. The intepreter knows how to push and pop them and to "run a piece of code in a new context by pushing, evaluating and popping". But it doesn't create evaluation context (just the initial :P)
So your XIntepreterEvaluator is in charge of creating evaluationcontext instances. Which makes sense because different languages have different scoping mechanisms.
In wollok calling a method creates a new evaluation context. This is done in WollokObject, inheriting the behavior from AbstractWollokCallable. This is a simplified version (only removes a couple of lines for handling native methods, which is not relevant here)
def WollokObject call(WMethodDeclaration method, WollokObject... parameters) {
val c = method.createEvaluationContext(parameters).then(receiver)
interpreter.performOnStack(method, c) [|
val WollokObject r = method.expression.eval as WollokObject
return if (method.expressionReturns)
r
else
theVoid
]
}
There you can see that it creates the evaluation context first with a new holding the parameters (map based) and the chains it with the receiver object (which is an EvaluationContext instance).
The code that creates the evaluation context:
def createEvaluationContext(WMethodDeclaration declaration, WollokObject... values) {
declaration.parameters.createMap(values).asEvaluationContext
}
def static asEvaluationContext(Map<String, WollokObject> values) { new MapBasedEvaluationContext(values) }
def static then(EvaluationContext inner, EvaluationContext outer) { new CompositeEvaluationContext(inner, outer) }
They are all extension methods.
From the interpreter point of view the debugger is just a listener interface. The interpreter needs to call it every time is going to evaluate an object. Before and after. This is important, and as an XInterpreterEvaluator developer you need to know that, you must write small code that evaluates only the given object, and if you need to evaluate an extra element, like a parameter when calling a function, YOU MUST GO THROUGH THE XINTERPRETER "eval()" method. If you don't go through it will be transparent for the debugger, for example.
Anyway, it is just an interface for the interpreter. We have two implementations as shown in the following diagram
The XDebuggerOff as the name says is the on used when debugger is off. This impl/listener won't do anything.
On the other hand the XDebuggerImpl handles the execution thread. It has two responsabilities. From one side, as we said the interpreter will be calling it just before and after evaluating an object. From the other side, this object will be available to the remote IDE (eclipse for example). The IDE will be calling (remotely and on a different thread) to give it instructions like, "pause". So this class is multithreaded. Upon a "pause()" event, it will kind of "remember" that it must pause on the next available ocassion. So when the intepreter calls it again with "aboutToEvaluate(obj)" it will stop that thread (pause it). And it won't return from the method, until it receives other instruction from the IDE, like "resume".
So, basically what happens here is that the interpreter calls the debuggerImpl, and this one ends up controlling the interpreter thread, pausing it for as long as needed.
The XDebuggerImpl also has the list of breakpoints and it evaluates each EObject about to be evaluated to see if it matches the breakpoint. In that case it will pause.