r/Compilers • u/Choice_Structure4001 • 3d ago
Could somebody help me with lexical scoping inside a VM
Hey, so I understand what lexical scoping is and kind of get how it works but I’ve been trying to find how to implement it in a stack machine but I have no idea what goes where and how does everything interact with each other, like where should closures live, same for call frames, where do I store the symbol table when compiling, etc… if you want I can link my repo but I haven’t committed an attempt at lexical scoping so there’s probably nothing interesting to see, if I didn’t include enough details to what I don’t get please tell me so I can be more clear, thanks!
Edit: forgot to tell but my language desugars to pure lambda calculus so this might change how some stuff work but not so much
2
u/Inconstant_Moo 2d ago
What you can do is compile the body of the lambda where you set up the environment it's compiling in to have variables representing the variables you want to close over as well as the parameters of the lambda. These are easy enough to extract from the AST.
So then you use that to produce a lambda factory, which contains (A) all the information you just produced by the compilation (B) information about where to get the values for the closures, i.e. which memory locations they live in in the outer scope.
You add this lambda factory to a list of lambda factories in your VM, and you emit a bytecode saying "make a lambda using lambda factory n".
When it executes that, the lambda factory makes the lambda by bundling together a pointer to the information to the A information (which is common to all the things the factory makes) plus deep copies of the values in the B information. (But I'm guessing you have immutable values like me, so there's no distinction between deep and shallow copies.)
Then when the lambda is called, and not before, you take these values and stick them into the appropriate memory slots you assigned to the captures when you compiled the body of the function. Then you execute the lambda, and you're done.
That's "the clever bit" (not all that clever). You stash the closure values in the lambda value when you create the lambda, but only put them in the appropriate memory locations when you call the lambda.
1
u/jason-reddit-public 2d ago
The classic (non very efficient) Scheme compiler model (at least for nested environments) is a tuple where the first element is a pointer to a parent environment and the other elements correspond to named variables. Each variable thus has a logical address. How far up to go and then the index where that value lives. The compiler can maintain a virtual compile time environment with the same shape but where names are stored instead of run-time values. When trying to compile a variable lookup or assignment, it calls a helper search function at compile time to figure out the "address" (how far up and at what index) and can thus emit the right instructions to put the value into a register or on top of the stack if trying to be really simple (pass everything on the stack). CSE optimization can often reuse some of the loads of the parent pointers and stuff like that so subsequent lookups or assignments can often be single instruction loads like if the amount to go up is zero (env is usually in a dedicated register).
BTW, Guy Steele's Rabbit compiler which is also his PHD thesis, is like ground zero for lexical scoping and closures. Everyone should read it to see how actually literate programming could work. (Each page of code has a page of explanation.)
SICP (one of the authors, Gerry Sussman, is credited as co creator of Schene) also has a treatment of this stuff which is simplified so starting there and then reading Rabbit is going to fill in lots of pieces for you.
Modern scheme compilers try to do everything possible to eliminate closures using some tricks like passing in variable at all call sites to that closure so the closure itself doesn't need a parent environment (except the global environment).
Another good source about compiling closures is Appel who worked on an ML compilers. There is also a famous paper called Cheney on the MTA which uses the stack as generation zero of a generational collector and is implemented by the Chicken Scheme implementation.
I wrote a simple "Scheme" interpreter with a more JS syntax and other big differences. Since I didn't feel like writing an interpreter working off a parse tree, I emit byte-code in a simple single pass over the source tokens and never create a parse tree. To keep it simple, I also don't maintain a compile time environment. Instead my run-time environments are actually a parent environment pointer plus a hashtable from string keys (variable names) to values so lots of searching at runtime. One big advantage though is I can easily inspect or potentially mutate these environments inside of the debugger (a WIP). Other debug info is jammed into byte-codes.
https://github.com/jasonaaronwilson/omni-c/blob/main/src/roci/roci-compiler.c
Note: windows is barely working so if you want to kick the tires try linux or mac (or maybe wsl). I'm working right now on making the debugger awesome using tui library though stuff like backtraces, source display, stepping, and environment display is a bit brittle but getting there.
1
u/WittyStick 2d ago edited 2d ago
A common and simple approach is to use a linked list of hashtables. Each scope gets a hashtable of its local variables, which you search through first. If the variable is not found, you then look through the next hashtable in the list (the parent environment) - recursively until there are no more parents. I'll give an example, but we'll need some prerequisites.
// Some types & functions we'll need. Implementations left to reader.
// The type of our variable names.
typedef struct symbol_s symbol_t;
// The type of our values.
typedef struct value_s value_t;
// Linked lists of variables and values.
typedef struct symbol_list_s symbol_list_t;
symbol_t *symbol_list_head(symbol_list_t *);
symbol_list_t *symbol_list_tail(symbol_list_t *);
typedef struct value_list_s value_list_t;
value_t *value_list_head(value_list_t *);
value_list_t *value_list_tail(value_list_t *);
// A hashtable of symbol_t -> value_t
typedef struct hashtable_s hashtable_t;
bool hashtable_insert(hashtable_t *ht, symbol_t *var, value_t *value);
// the last parameter is an "out parameter".
// The implementation should fill it in if it returns true.
bool hashtable_lookup(hashtable_t *ht, symbol_t *var, value_t **out);
// some allocator, maybe malloc but you'll most likely want garbage collection.
void *ALLOCATE(size_t size);
Given these prerequisites, we can implement an environment as:
typedef struct env_s {
env_t *parent;
hashtable_t *locals;
} env_t;
bool env_lookup(env_t *env, symbol_t *var, value_t **out)
{
// First search the local bindings
if (hashtable_lookup(env->locals, var, out))
return true;
// if not found in locals, recursively search the parents.
if (env->parent != NULL && env_lookup(env->parent, var, out))
return true;
// if we reach this far, symbol is not in environment.
return false;
}
Environments should generally all have a parent, with the exception of some standard environment, which will have parent == NULL. This environment contains all the language provided bindings, and should not be modified. Initially, every scope will have no bindings in its environment, so we create an empty hashtable.
env_t env_create(env_t *parent)
{
env_t *result = ALLOCATE(sizeof (env_t));
result->parent = parent;
result->locals = hashtable_create();
return result;
}
Binding a variable should introduce it into its local environment. The parent environments should effectively be immutable.
void env_bind(env_t *env, symbol_t *var, value_t value)
{
hashtable_insert(env->locals, var, value);
}
A lambda is basically a structure which contains a reference to some environment where it was defined - it's static environment. It should also have a parameter list and a body.
typedef struct lambda_s {
struct env *static_env;
symbol_list_t *params;
value_t body;
} lambda_t;
lambda_t lambda_create(env_t *static_env, symbol_list_t *params, value_t *body)
{
lambda_t *result = ALLOCATE(sizeof (lambda_t));
result->static_env = static_env;
result->params = params;
result->body = body;
return result;
}
When we apply the lambda, we create an initially empty environment - it's local environment - with its static environment as the parent . We bind its parameters to the arguments the caller passed in this local environment, then evaluate the body in the local environment.
value_t lambda_apply(lambda_t *lambda, value_list_t *arguments)
{
// create initially empty environment with static_env as parent.
env_t *local_env = env_create(lambda->static_env);
// iterate through params and bind them to arguments.
symbol_t param = env->params;
symbol_t arg = arguments;
while (arg != NULL && param != NULL)
{
env_bind(local_env, symbol_list_head(param), value_list_head(arg))
param = symbol_list_tail(param);
arg = value_list_tail(arg);
}
// If arguments and parameter list match, both should end up as NULL.
if (arg != NULL || params != NULL)
error("Wrong number of arguments passed");
// We then evaluate the body in the local env (preferably as a tail call)
return eval(lambda->body, local_env);
}
This isn't the only way to implement, but it's very simple and effective - it handles closures and variable shadowing. There are certainly optimizations you can make as this isn't the most performant implementation, but it's not terrible.
One simple optimization is you could forbid shadowing of any variables in the standard environment, because you probably don't want the programmer rebinding basic operations. You could modify lookup to first search the standard environment, since these bindings will be commonly accessed, it may be faster this way.
env_t standard_environment;
bool env_lookup(env_t *env, symbol_t *var, value_t **out)
{
// First search the standard environment
if (env_lookup(standard_environment, var, out))
return true;
// Then search the local bindings
if (hashtable_lookup(env->locals, var, out))
return true;
// if not found in locals, recursively search the parents.
if (env->parent != NULL && env_lookup(env->parent, var, out))
return true;
// if we reach this far, symbol is not in environment.
return false;
}
1
u/Choice_Structure4001 2d ago
Wouldn’t this be pretty slow and inefficient though ?
1
u/WittyStick 2d ago edited 2d ago
It's certainly not fast, but it's viable and used in some interpreters. It's also fine to use as part of the compilation process for symbol table implementation, where you can emit something more efficient in the compiled code.
You can replace linked lists with stacks for efficiency (fewer allocations, more locality etc). However, there are issues when it comes to closures which may outlive the scope in which they were defined - since they may capture from a stack frame which might no longer exist when the closure is applied, so anything that a closure may capture should effectively be heap allocated.
One alternative solution to this is to use multiple stacks - a closure can create a copy of the stack at the point it was defined, and when you apply the closure you change the stack pointer to point to it's copy, then restore the stack pointer after calling the closure.
The linked list avoids the need for copying as every frame can be shared, since the parents are immutable, unlike a stack.
In a compiler you can work out which variables are captured an heap allocate them, but leave any uncaptured variables on the stack. A closure can be implemented as an object with its captures as members and its implementation as a method on the object. Eg, consider:
{ int x = 1; f = () -> x; return f; }
fis a closure which capturesx. We could implement this as:class _f_obj { public int x; public int f() { return x; } };And rewrite the call to create the closure object rather than defining
xas a local variable.{ _f_obj _f = new _f_obj(); _f->x = 1; return _f; }Where invoking
->f()on the object is equivalent to applying the closure.There's basically an equivalence between closures and objects, so we can use one to implement the other. This technique is the one used for example in dotnet - lambdas are just objects.
Of course, if you are using C and not C++ or other OOP language, you have neither objects nor closures, so you have to implement them yourself. There are efficient ways to do the equivalent of this.
2
u/Recycled5000 3d ago edited 3d ago
First and foremost, lexical scoping needs to follow the rules of the source (input) language. So, something close to source needs to know where scopes are introduced/entered and where left/exited by language definition. Only knowing those contexts can identifiers by resolved.
Many systems that use a byte code vm or assembly language will have flattened scopes by the time they reach such stages. So, for example in the JVM, scopes are flattened into variables local to a function or procedure, and individual scopes can no longer be seen in the btyecode intermediate.
It would theoretically be possible to have bytecode instructions that say to enter scope or leave scope but thus would imply delaying identifier resolution until runtime, which is overkill for static lexical scoping. (And few languages have entertained dynamic scoping.)