In this chapter, we take a closer look at how the ECMAScript language specification handles variables.
An environment is the data structure that the ECMAScript specification uses to manage variables. It is a dictionary whose keys are variable names and whose values are the values of those variables. Each scope has its associated environment. Environments must be able to support the following phenomena related to variables:
We’ll use examples to illustrate how that is done for each phenomenon.
We’ll tackle recursion first. Consider the following code:
function f(x) {
return x * 2;
}
function g(y) {
const tmp = y + 1;
return f(tmp);
}
assert.equal(g(3), 8);
For each function call, you need fresh storage space for the variables (parameters and local variables) of the called function. This is managed via a stack of so-called execution contexts, which are references to environments (for the purpose of this chapter). Environments themselves are stored on the heap. That is necessary because they occasionally live on after execution has left their scopes (we’ll see that when exploring closures). Therefore, they themselves can’t be managed via a stack.
While executing the code, we make the following pauses:
function f(x) {
// Pause 3
return x * 2;
}
function g(y) {
const tmp = y + 1;
// Pause 2
return f(tmp);
}
// Pause 1
assert.equal(g(3), 8);
This is what happens:
Pause 1 – before calling g()
(fig. 1).
Pause 2 – while executing g()
(fig. 2).
Pause 3 – while executing f()
(fig. 3).
Remaining steps: Every time there is a return
, one execution context is removed from the stack.
We use the following code to explore how nested scopes are implemented via environments.
function f(x) {
function square() {
const result = x * x;
return result;
}
return square();
}
assert.equal(f(6), 36);
Here, we have three nested scopes: The top-level scope, the scope of f()
, and the scope of square()
. Observations:
Therefore, the environment of each scope points to the environment of the surrounding scope via a field called outer
. When we are looking up the value of a variable, we first search for its name in the current environment, then in the outer environment, then in the outer environment’s outer environment, etc. The whole chain of outer environments contains all variables that can currently be accessed (minus shadowed variables).
When you make a function call, you create a new environment. The outer environment of that environment is the environment in which the function was created. To help set up the field outer
of environments created via function calls, each function has an internal property named [[Scope]]
that points to its “birth environment”.
These are the pauses we are making while executing the code:
function f(x) {
function square() {
const result = x * x;
// Pause 3
return result;
}
// Pause 2
return square();
}
// Pause 1
assert.equal(f(6), 36);
This is what happens:
f()
(fig. 4).f()
(fig. 5).square()
(fig. 6).return
statements pop execution entries off the stack.To see how environments are used to implement closures, we are using the following example:
What is going on here? add()
is a function that returns a function. When we make the nested function call add(3)(1)
in line B, the first parameter is for add()
, the second parameter is for the function it returns. This works because the function created in line A does not lose the connection to its birth scope when it leaves that scope. The associated environment is kept alive by that connection and the function still has access to variable x
in that environment (x
is free inside the function).
This nested way of calling add()
has an advantage: if you only make the first function call, you get a version of add()
whose parameter x
is already filled in:
Converting a function with two parameters into two nested functions with one parameter each, is called currying. add()
is a curried function.
Only filling in some of the parameters of a function is called partial application (the function has not been fully applied yet). Method .bind()
of functions performs partial application. In the previous example, we can see that partial application is simple if a function is curried.
As we are executing the following code, we are making three pauses:
function add(x) {
return (y) => {
// Pause 3: plus2(5)
return x + y;
}; // Pause 1: add(2)
}
const plus2 = add(2);
// Pause 2
assert.equal(plus2(5), 7);
This is what happens: