Closure
After delving into the article and exploring other resources on closures, I feel compelled to jot down my thoughts here to solidify my understanding. The design proposal for closure is primarily derived from the article to which I refer.
1 Terminology
Some terms are described here before we proceed to the next section.
A declaration is used to inform the compiler of the existence of an entity. This entity can be a variable, function, class etc. The name of the identifier in the declaration identifies that entity.
Due to space limitations, we focus on the variable entity as an example in this section. BTW, in languages with first-class functions, functions are treated like ordinary variables with a function type.
A variable is an abstract storage location which stores a specific type of data (such as integer, float, string, etc.) typically referred to as value. Variable name is commonly used to access that value. This is achived eventually by compiler translating variable name to the corresponding data’s location.
1
2
3
4
5
6
7
8
9
10
11
int x = 1; // A variable named **x** is declared.
int get() // A function named `get` is declared.
{
return x; // A use of x
}
int main() // A function named `main` is declared.
{
int x = 2; // A variable named **x** is declared.
get();
return x; // A use of x
}
In the above case, the use of x appears at line 4 and line 10 respectively. Which entity declared does x in each use refer to? In other words, how do we correctly resolve/bind the name to an entity? Scope rules are used to determined whether a name binding in a particular part of a program is valid or not.
Scope has two categories:lexical/static scope and dynamic scope.
- Lexical/static scope means a name binding can be determined at edit/compile time by the program text only and is typicalliy known as early binding. Under static scope, x at line 4 generally references to the entity declared at line 1 and x at line 10 references to the entity declared at line 8.
- Dynamic scope means a name binding can only be determined at runtime and is known as late binding. To acheive that, a name lookup upward to the call stack is often needed. Under dynamic scope, x at line 4 and line 10 generally references to the same entity declared at line 8.
A local variable is a variable declared within a given local scope such as function scope and block scope. It creates during the execution of that scope and is visible until the execution flow leave that scope.
A automatic variable is a local variable which is preserved on the call stack and thus features being allocated/created and deallocated/destroyed automatically when the scope is entered and exited. In most languages, automatic variable and local variable are used interchangeably.
The static local variable in C languages is an exception. A static local variable is visible only during each exection of that scope but has an application-wide lifetime.
Under the call-by-value semantics, a function parameter is a new local variable declared in the function scope with initial value copied from a corresponding argument.
Free variable and bound variable are concepts originated from mathematics and extended into the computer programming. The exact term definition is as follows:
The term free variable refers to variables used in a function that are neither local variables nor parameters of that function. The term non-local variable is often a synonym in this context.
In article, they are defined as follows:
Bound variables are declared within the scope. They are parameters and local variables.
Free variables are declared externally. They are also called non-local variables.
In the subsequent sections of this article:
- A scope is lexical/static.
- A function call is with call-by-value semantics.
- A parameter thereby is a local variable.
- A local variable is synonymous with automatic variable.
- A function is treated as an ordinary variable.
2 Closure Concept
1
2
3
4
5
6
7
8
9
10
11
function outer() {
let cnt = 0;
function inner() {
return ++cnt;
}
cnt = 2;
return inner;
}
let inn = outer();
console.log(inn());
console.log(inn());
A regular/plain function operates strictly within its lexical scope and cannot access local variables in enclosing functions.
If we consider inner
as a regular/plain function, an error will occur during compilation. This is because local variable cnt
is declared and defined within the lexical scope of outer
function rather than that of inner
function.
A function featuring this capability of accessing to local variables outside its own lexical scope is referred to as closure. We refer to such local variable as closed-over or captured variables. For instance, the cnt
variable.
In wikipeida, closure is describled as follows:
A closure is a record storing a function together with an environment.The environment is a mapping associating each free variable of the function (variables that are used locally, but defined in an enclosing scope) with the value or reference to which the name was bound when the closure was created.
In another book, it is defined as follows:
A closure is a function plus a connection to the variables that exist at its “birth place”.
In the following sections, a function is treated as a closure at runtime even it never access free variables.
3 Closure Design
A local variable typically cannot outlive the immediately enclosing scope. In the program text, the variable cnt
arises at line 2 and available to use until the closing curly at line 8. At runtime, local variables are typically allocated on the stack. Once the outer
function execution finishes, cnt
’s stack memory will be automatically reclaimed through the process of stack pointer adjustment.
So, how does the inner
closure at runtime still retain valid access to cnt
even the outer
function execution finishes?
To provide a comprehensive solution to this question, let’s explore a broader scenario:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function outer() {
let cnt = 0;
function middle()
{
function inner() {
return ++cnt;
}
return inner;
}
cnt = 2;
function dec()
{
return --cnt;
}
dec();
return middle;
}
let mid = outer();
let inn = mid();
console.log(inn());
console.log(inn());
outer
function is declared at line 1 and called at line 21. During the call to outer
, three local variables cnt
, middle
and dec
come into being. According to the program text, cnt
is captured by inner
and dec
.
As we mentioned above, cnt
is valid only during the execution of outer
. dec
closure is created at line 13 and at that time, cnt
is still available. We can preserve cnt
into dec
closure and then the cnt
will be always available for dec
closure no matter when it is called in the future.
However, middle
is never called during the execution of outer
, leading to inner
yet-to-be-created. The question arises: how does inner
access a valid cnt
when it is created by the call to middle
somewhere in the future. It naturally comes to mind that preserving cnt
into inner
’s immediatedly enclosing function middle
which even never use it. Data preserved into a closure’s immediately enclosing function must be available at least until the closure is created. Therefore, function middle
thus will be created as a closure used to preserve the variable cnt
captured by its nested function inner
.
One question arises: should we preserve the value of the captured variable or the reference to that variable? The choice between preserving value or reference depends on the semantics regulated by the language specification.
If a closure stores the value of a captured variable, then it means the closure contains the value copy of that variable. Any operations performed on the variable within the closure will not impact the value of original variable declared in the enclosing function. Additionally, these operations will also not affect the value of the same variable captured in others closures if any.
If a closure stores reference to a captured variable, it can be understood as that variable’s storage location. This implies that at any given time, the value to which the variable name is bound has only one instance and no other copies in the memory, and any changes made to that variable either in the enclosing function or in other closures will be reflected to that singular instance and visible to all uses of that variable.
In this article, we will focus on discussing the latter case.
1
2
3
4
5
6
function outer() {
let cnt = 0;
cnt = 2;
return cnt;
}
If outer
contains only above three lines of code, it’s ok to allocate cnt
on the stack and deallocate it automatically once the outer execution finishes. In this example below, however closure middle
and dec
will preserve the reference to cnt
and the reference preserved should be valid even after the outer exectuion finishes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function outer() {
let cnt = 0;
function middle()
{
function inner() {
return ++cnt;
}
return inner;
}
cnt = 2;
function dec()
{
return --cnt;
}
dec();
return middle;
}
let mid = outer();
let inn = mid();
console.log(inn());
console.log(inn());
PS: To facilitate viewing, the example shown before is copied here.
One approach is to create every declared local variable directly on the heap and preserve the reference to it into middle
and dec
. The approach sounds promising but it has one significant shortcoming: performance degradation. It triggers dynamic allocation on the heap for each local variable at their birthplace which comes at a non-negligible price.
Furthermore, whereas all uses of cnt
in the scope of outer
were previously implemented by directly accessing its value on the stack with an index calculated at compile time. By this approach, we need first to obtain its reference on the stack with an index calculated at compile time. And then retrieving its value by dereferencing it. This obviously requires more operations compared with the old way.
cnt
is captured by closure middle
and dec
which may access cnt
outside its enclosing function. Moving cnt
from stack to heap thereby is inevitable but we can minimize the performance degradation by delaying the moving until the cnt
is about to go out of the scope,i.e., at the end of its enclosing function outer
. When middle
and dec
are created in outer
, we preserve the reference to cnt
on the stack into these two closures. We move cnt
to heap and update the preserved reference in these these closures merely before the execution of outer
ends. Then, the way code accessing the captured value in the enclosing scope will remain unchanged, i.e. getting the value from stack directly.
By the way, there is an optimisation opportunity: we can determine whether the closures will outlive their immediately enclosing function at compile time by performing escape analysis. If all closures that capture the cnt
have the same liftspan as cnt
, then there is no need to move cnt
at the end of outer
. In this example, dec
has the same lifespan as the cnt
but middle
does not. middle
escapes the enclosing function outer
by being returned. Therefore we need to move cnt
from stack to heap but only update the preserved reference in the middle
closure.
Due to the constraints of space limitations and one-pass parser’s simplicity, we do not implement such analysis and at runtime we treat all functions as closures. In this article, we adopt the way to move a captured variable at the end of its immediately enclosing function and update preserved references in corresponding closures.
4 Closure Implementation
Compiler and Runtime should conspire together to make the above mechanism eventually happen. Let’s dive into that example again. To facilitate viewing, the example shown before is copied here.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function outer() {
let cnt = 0;
function middle()
{
function inner() {
return ++cnt;
}
return inner;
}
cnt = 2;
function dec()
{
return --cnt;
}
dec();
return middle;
}
let mid = outer();
let inn = mid();
console.log(inn());
console.log(inn());
At line 1, a global variable outer
is created with type of closure. outer
references no captured variables.
And then outer
is called at line 21. Let’s take a closer look at the outer
function. Local variables cnt
and middle
is created at line 2 and line 3 respectively.
At compile time, compiler easily know how many variables the middle
closure need to capture. In this example, only 1
. At line 3, the code emitted by compiler will create a closure object for middle
on the heap. It contains an array with length of 1
which is also allocated on the heap. The array is used to build connection to captured variables for the closure middle
.
And then the compiler need to emit code to initialize the array to make sure each element in that array references to right variable. The emitted code create an object on the heap at runtime and set its location to the array element indexed at 0
. The object represents a captured variable shortly called UpValue and will be filled with the location of that captured variable. It serves as a middle layer providing indirection access to the genuine entity of that variable. middle
closure accesses cnt
through this object no matter where it is moved in the future. At this moment, the captured variable cnt
is still available on the stack and compiler knows its exact stack location because the location at runtime and compile time matches. The compiler thereby emits the code to get the stack location and set it into the UpValue object.
At this moment, the memory will looks like this below. Current Runtime Status
By the way, inner
closure will be created at line 5 each time middle
is called. And then the code emitted by compiler will initialize that array owned by inner
. It will copy the location of that UpValue object not from stack but from first element of middle
’s array to the first element of inner
’s array. We will get to it later.
At line 11 cnt = 2;
, the access to cnt
will remain the same as that to other local variables not captured. i.e. compiler emits the code to assign 2 to cnt
pointed by fp
plus index 1
. The outcome sees below.
Closure dec
is created at line 13. It captures the cnt
as well. If we conform to these steps we describe above, it will also create an UpValue object and form the status below.
It looks redundant. We maintain two UpValue objects which refer to the same entity. To avoid this, we need to check if there already exists an UpValue object refer to the same entity or not. If yes, we just use that one instead of creating a new one to populate the up_value_array. The ideal way looks like this below.
So how to check that? if the cnt
also contain a reference to the UpValue object, then we can easily check that at runtime. But it will make the Value structure cumbersome. In this article, a vm global variable openUpValues
is created and used to thread all the UpValue objects which still refer to entity on the stack. And each time a new UpValue is to be created to reference a value on the stack, the UpValue objects threaded by openUpValues
will be checked. If a UpValue object contains the location of that value on the stack, then it is our target UpValue object and there is no need to create a new one. The check process should be also applied to the middle
creation. To support this idea, we need to add extra field named UpValue* next. By the way, we must recognize that it will incur extra overhead. In most case, it is negligible.
To facilitate viewing, the example shown before is copied here.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function outer() {
let cnt = 0;
function middle()
{
function inner() {
return ++cnt;
}
return inner;
}
cnt = 2;
function dec()
{
return --cnt;
}
dec();
return middle;
}
let mid = outer();
let inn = mid();
console.log(inn());
console.log(inn());
Then at line 17, the dec is called and the value of cnt
is decresed by 1. Current situation is as follows.
The call to outer will return at line 18 and the cnt
on the stack will become invalid. As we said in section Closure Design, we need to move the cnt
from the stack to the heap to extend its lifespan. One way is to allocate the Value on the heap and copy the value of cnt
on the stack to it and then set the location into corresponding UpValue object. Another way is to add a field of Value into the UpValue object directly and we move the cnt
on the stack into that field directly. We select the latter and status is as follows.
When the execution reaches the line 22, mid
is called. and at line 5, the inner
closure is created. The compiler know the cnt used in inner comes from the first element of the middle
closure’s up_values_array. So it will emit code at compile time to copy that first element into inner’s up_values_array at runtime.
Well, we roughly go through the runtime behavior and compiler behavior for closure.