This small post is a heavily simplified way to think about Javascript scopes. Since we're not writing a compiler or interpreter, we don't have a particularly nice AST to work with. However, for learning purpose I think this helps. For further education, read Crafting Interpreters, Writing a Compiler in Go, or the excellent posts by Dmitry Soshnikov.
Repl.it found here
Github found here
Javascript closures were tremendously tricky for me to grasp. I had a coworker who tried to explain them as "a function that CLOSES OVER a value" which wasn't particularly illuminating, but I eventually began to get a feel for how scope worked. However, it wasn't until I wrote a compiler in Go that I began to better understand what was happening under the hood.
The common question of "write a function that only lets a function run once" is a great example.
const once = fn => {
let canRun = true
return (...args) => {
if (canRun) {
canRun = false
return fn.apply(null, ...args)
}
}
}
let fn = once(() => console.log('hello'));
fn()
fn()
This just works. Why? I'll let you read Dmitry's post on scope chain for a very detailed review of Javascript scope, but let's perhaps make it simple for ourselves.
A program has a global environment. An environment is simple a map of keys to values, where keys are variables and values are their assignments. An environment can also have an outer environment, that is, a function has an internal environment that allows it to have a locally scoped variable assignment. Environments are first-class objects that can have an infinitely long outer-chain.
In code, that might look something like
class Environment {
constructor(environment, store) {
this.outer = environment;
this.store = {};
}
get(name) {`
if (this.store[name]) return this.store[name];
if (this.outer) return this.outer.get(name);
return null;
}
set(name, value) {
return this.store[name] = value;
}
}
A store is the environment's inner mapping of keys to values. The outer
value refers to it's parent, for when we enter a function scope. Let's create a couple of helpers along the way.
const GLOBAL_ENV = new Environment();
function assign(env, name, value) {
env.set(name, value);
}
function read(env, name) {
env.get(name);
}
This will just allow us to test out our code in a nice, declarative fashion. Note that Environment.prototype.get
looks up the outer chain by calling itself on the next environment up. As long as there are parents, we can keep looking until we get nothing. We could model this as a stack (pushing and popping as we enter and leave scope) but this is a little easier.
Let's model our functions as a class. We want to be able to capture the function we plan on executing, the environment at the time of it's creation, and for ease let's also capture an array of strings representing the parameters the function needs. We'll later use that list to look up our parameter values at runtime. It'll look something like
class FunctionObject {
constructor(env, fn, parameters = []) {
this.fn = fn;
this.parameters = parameters;
// capture state of environment at creation time
this.env = env;
}
eval(env, paramValues) {
// assign an outer environment
// make sure to keep captured store values
let newEnv = new Environment(env, this.env.store);
for (let i = 0; i < paramValues.length; i++) {
// for every parameter we pass in to eval, assign the value to environment store
let param = this.parameters[i];
let val = paramValues[i];
newEnv.set(param, val);
}
// apply args from params to fn
// all our functions need an environment to read from, so we push that to the front of the list
// you can imagine some compiler step dynamically reading from environment
let allParamValues = this.parameters.map(param => newEnv.get(param));
let argsToCall = [newEnv].concat(allParamValues);
return this.fn.apply(newEnv, argsToCall);
}
}
Our FunctionObject
can be created and later evaluated with parameter values. When we eval
a function, we capture the environment at time of execution, then, make sure the closest lookup is the function's environment captured at creation time. You can then call something like
const env = GLOBAL_ENV
assign(env, 'x', 5);
const logX = (env) => console.log(env.get('x'));
const fnObj = new FunctionObject(env, logX, ['x']);
fnObj.eval(env, []); // logs 5
assign(env, 'x', 10)
fnObj.eval(env, []); // logs 10
You'll see that logX
takes an environment and reads from it. That's actually the role of the interpreter or compiler, to replace all variable reads with the lookup. Since we're not writing an interpreter, you can imagine that function is actually console.log(x)
that's later compiled into what we're writing.
What's really shown here is that we're able to NOT send an explicit value of x
in the parameter list to a function and it can be read from an environment.
Our program then is a series of scope entries, where we can effectively open and close scopes by creating functions to capture and throw away environments.
const GLOBAL_ENV = new Environment();
const functionScope = (env) => {
// create a local env, where it's outer scope is inherited
let localEnv = new Environment(env)
assign(localEnv, 'x', 1);
assign(localEnv, 'y', 2);
assign(localEnv, 'z', 100);
let sumFn = (env, x, y) => console.log('OUTER ', env.get('x') + env.get('y') + env.get('z'));
let sum = new FunctionObject(localEnv, sumFn, ['x', 'y', 'z']);
sum.eval(localEnv, [5, 6]) // expect 5 + 6 + 100 = 111
assign(localEnv, 'z', 200);
sum.eval(localEnv, [5, 6]) // expect 5 + 6 + 200 = 211
innerScope(localEnv);
// note that in innerScope, we overrode the value of 'z' but that's thrown away when we get back here
sum.eval(localEnv, [100, 100]) // expect 100 + 100 + 200 = 400
}
const innerScope = env => {
let localEnv = new Environment(env);
assign(localEnv, 'z', 300); //
let sumFn = (env, x, y) => console.log('INNER ', env.get('x') + env.get('y') + env.get('z'));
let sum = new FunctionObject(localEnv, sumFn, ['x', 'y', 'z']);
sum.eval(localEnv, [5, 6]) // expect 5 + 6 + 300 = 311
}
functionScope(GLOBAL_ENV)
This is a quick and dirty way to think about function scope! When we enter a new scope, we capture all the outside environment values and use those, and when we leave that scope we no longer consider that our environment.
This was a fun learning exercise, and certainly only touches a VERY high level of how the Javascript engine translates a variable lookup, but hopefully the model of "scopes are just environments that are thrown away when a function ends, and environments are just maps of variables to values" helps further understand why
const once = fn => {
let canRun = true
return (...args) => {
if (canRun) {
canRun = false
return fn.apply(null, ...args)
}
}
}
let fn = once(() => console.log('hello'));
fn()
fn()
actually works. Because the once
function captures a scope that the inner function has access to and can modify.
Top comments (2)
Thanks for the Post!
Just a quick tip, In JS you can easily debug the Closure value using 'console.dir' like so
Thanks so much for that reminder! Iām so used to logging everything that I forgot about the other console methods