Well, It's been a minute. I'd finished the interpreter a long time ago, but had put off blogging it because a thousand different things came up these past few months. Anyhow, it now takes on the ability to handle functions - user defined, native, and nested - and even deal with shadowing, wherein a variable in a local scope "eclipses" the same (existing) variable in an outer scope. We'll also enter proper compiler territory by creating a "resolver" phase.
What I built: Commits 3da669d, 930014e, 5adb148, and 9d534f3
What I understood:
1) Updating the Grammar:
- The function call takes the highest precedence here, above the unary rule.
- We also create an object (class) called
Callablethat helps resolve whether or not something can be used as a function. - Ex: A string isn't callable, while classes and functions (user-defined and native) are. Python does something really cool by allowing all its datatypes to be callable, because they're really objects that're being called.
- If the "callee" (entity calling the function) doesn't belong to
LoxCallable, Lox raises an error. - The parser also allows for currying or chained function calls through
(( "(" arguments? ")" )*, allowing forfunction(arg)(arg)(arg)
2) Arguments:
- We also check for arity, wherein the number of parameters in the function definition must equal the number of arguments passed in the call.
3) Native Functions:
- We create
clock()in Java, which Lox can access. - This is an anonymous class implementing
LoxCallable, instantiated and bound to a variable "clock" in the interpreter's globals environment. - It returns the system time in seconds, for us to perform benchmarks.
4) Function Declaration:
- Every time the interpreter encounters
fun, it creates a callable object out of it (throughLoxCallable) and binds it to the function's name in a new environment created. - In this local environment, the function's arguments are bound to the parameters mentioned in the declaration.
- Every time the interpreter encounters a function call, a new environment is created. This is to allow for recursion, which wouldn't be possible if an environment existed only for declarations.
- Once the function's body is completed, this local environment is destroyed and the execution environment returns to the state before the call.
5) Return:
- The interpreter looks for a return statement followed by an optional expression.
- If no return is mentioned,
nilis implicitly returned. - When
returnis encountered, the interpreter must immediately jump out of all its (current) environments and revert to the original function call site. - Instead of a complex stack to do so, we use a simple exception class called
Return, which evaluates the return value (if given) and wraps it, finally returning it as the function's result. - This is kept inside a try-catch block, that looks for the return "exception", which if not found, causes the full function to be parsed and
nilreturned implicitly.
6) Scoping:
- So far, Lox was able to support dynamic scoping wherein a called function's scope was always set to the global environment, and not the environment it was defined in.
- This hindered the called function's access to variables in the environment it was defined in, once the environment ended (when the function finished executing).
- Basically, a live reference to a mutable scope was being captured, when it should have captured the lexical environment it was defined in, instead of deferring name resolution to the call site.
- To explain this with an example:
fun getName(name) {
var person = name;
fun greet() {
print “Hello, “ + person + “!”;
}
return greet;
}
var momo = getName("Momo”);
var chutney = getName("Chutney”);
momo(); //Should give “Hello, Momo!” ; Instead, it gives an error: “Undefined variable!”
chutney(); //Should give “Hello, Chutney!”
- This is because once
getName("Momo") finishes, the variableperson` is destroyed, as the function's scope ended. When
momo()is called, it tries to findpersonin its parent scope, which has now become the global scope (which doesn't contain the variableperson) → So error!We deal with this issue of dynamic scoping, by using lexical scoping.
Here, when a function is first declared, we close over and capture the environment surrounding the function (inner function holds a live reference to the outer one, which the garbage collector can't destroy despite the completion of the function's execution).
When the function is called, this captured environment becomes the call's parent instead of
globals.
7) Resolver:
- Implementing closures through lexical scoping gave rise to another issue.
- Because a closure holds a live reference to a "mutable" map representing the current scope, its captured view of the current scope can change based on code that runs later in the same block.
- This shouldn't be the case. Because Lox has lexical/static scope, a variable's usage must always "resolve" to the same declaration throughout the program's execution.
- Even a redeclaration/redefinition of the variable later in the code shouldn't influence behaviour written with the original/declared value in mind.
- Let's understand this through an example:
var a = "one";
{
fun showA() {
print a;
}
showA();
var a = "two";
showA();
}
- In this code, the variable
abelongs to the global scope, with its value being "one." - The function
showAcreates a closure that captures the block/environment it is declared in. - When
showA()is called,ais first searched for in the block, and when not found, is searched for in the environment above (i.e., global). Hence, the output forprint ais "one." - But,
ais redeclared to have the value "two" in the same block (i.e., in the same environment captured byshowA()) So, when
showA()is called after the redeclaration, because the scope was mutable, the output is "two" when it should've been "one" again.The resolver, added after the parser and before the interpreter, thus uses a concept called "Persistent Data Structures" wherein the original data structure can't be tampered with directly.
Any change to an existing structure creates a newer structure that contains the original information along with the modification.
To give a parallel, this is how blockchain works. If you're familiar with it, you must know that every time a block is modified in a ledger, a new block is created referencing the older one's hash, while also containing the change.
When the resolver enters a block and sees a variable being "used" (after its declaration), it searches upward from the function's scope.
a
Scope 0 (the function's scope):is not founda
Scope 1 (block scope):is not found (asvar a = "two"hasn't been defined yet in the resolver's static pass)a
Scope 2 (global scope):is found
- Because the variable is found 2 hops away from the innermost scope (of its use), the resolver annotates that "use" or node with the number 2.
- When the interpreter encounters
print a, instead of searching for the variable, it just uses the given number of hops to jump to the declaration.
What's next: We're going to keep it Classy! 💅
(Hehe, classes and inheritance, in case you didn't get it)
Musings:
I’ve had the opportunity to witness some of the most wonderful and bewildering of things these past few months. Feels like I saw the full spectrum of life, much like Shakespeare’s seven stages of man. In a way, it really humbled me. For the longest time, I was afraid of letting go… of my beliefs, interests, and even some dreams, because I thought I’d lose my very identity. Now, I’ve learnt that much like functions, it’s okay to have a template of non-negotiables, upon which we can let life keep adding her own “implementations.” (I absolutely HAD to use that analogy, pleeease excuse me). Change and unpredictability don’t seem so daunting anymore, and maybe (like 0.05%) are good too. I suppose c’est la vie.




Top comments (0)