-1

I'm trying to understand arrow functions in JavaScript and have a few questions regarding how they interact with ExecutionContext/environment and closures.

How I understand the model:

To the best of my understanding, the "specification" model in JS is that as code gets executed, a stack of ExecutionContexts is maintained (1, 6). I.e. at the beginning there's a ExecutionContext for global, when a function is called a new ExecutionContext is added for the time of its execution, and when it finishes, it's popped. I.e. it matches frames on callstack.

Assuming a little bit of simplification (ignore diff between global/function/eval & no let and const (i.e. variable environment), ExecutionContext consists of LexicalEnvironemnt, which in turn is made of three components:

  1. Environment record: mapping between variable/func symbols and objects they represent.
  2. Reference to outer environment: Ref to lexically outer ExecutionContext
  3. This binding: what this variable references. For unbound functions, this is set based on how the method is called (2)

When a function is called a new ExecutionContext is created for the duration of its execution (to track its variables as they change in Environment record, ...).

Normal functions

Normal function, within lexical scope:

For normal function, s.a. b() in example bellow, the creation of new ExecutionContext is relatively simple.

function a() {
 var myVar = 42;
 function b() {
   console.log(myVar)
   console.log(this)
 }
 b()
}

a()
  1. Environment record: It's always simple for all types, just scan the method, note all symbols, init to default.
  2. Reference to outer environment: We're running the method within its lexical outer scope, i.e. we can simply reference the EnvironmentContext that's currently (i.e. of a()) on execution stack (3). This gives us access to the outer lexical scope variable myVar.
  3. It's called normally, so we'd go with global binding for this, i.e. in browser a window.
Normal function, outside lexical scope:
function a() {
 let myVar = 42;
 function b() {
   console.log(myVar) // from closure
   console.log(myCVar) // will not be accessible, even if it will have lived in above frame (from c)
   console.log(this)
 }
 
 return b
}

function c(f) {
 let myVar = 48;
 let myCVar = 49;
 f()
}

returnedFun = a()
c(returnedFun)

In this case, when we run the method b (as f() within method c, after being returned from a), it is not so simple. 1) and 3) portions of the new ExecutionContext are still populated the same, but 2) has to be different.

At the point where b is returned from its lexical scope, i.e. from the function a, a closure must be created out of current ExecutionContext (the one for a() being executed, with myVar: 42 in environment record) and added to the returned function object b.

When the function object is executed in function c (f()), instead of wiring the newly created ExecutionContext's Reference to outer environment to the one on top of execution stack (i.e. the one for the currently executing c()), the closure of the function object f (returned function b) must be used instead.

I.e. the Reference to outer environment for the just being created ExecutionContext of just executed f() doesn't point to ExecutionContext of the function that's currently running (i.e. runtime outer scope; would be of c()) but to a captured closure of a no-longer-running lexically-outer-environment (a()).

This captured closure is visible as ?pseudo? property when console.dir of the returnedFun object (.[[Scopes]][0].myVar == 42).

Normal function, bound
let myObj = {asdf: 42}
function a() { console.write("tst");}
console.dir(a.bind(myObj))

Similarly, when bind is used explicitely - the args/this is added to the function object, visible as ?pseudo? property [[BoundThis]]. And it's used, when the function object is invoked and the corresponding ExecutionContext is created to populate its This binding.

Arrow functions

But what about arrow functions? To the best of my googling, a common way to explain them is that they don't get their own ExecutionContext (4, 5) and instead re-use the one of their lexical outer-scope; but how does that work, really?

Arrow functions, within lexical scope:
function a() {
 let myVar = 42;
 b = () => {
   var myBVar = 48;
 }
 
 b()
 console.log(myBVar) // not accessible -> run of b() must use copy of a's EC
}
a()

When the arrow function is executed in its lexical scope, it's - again - relatively straightforward. When function b() is executed, the current ExecutionContext (for a, which is b's lexical outer scope) is duplicated (needs to be to allow having just its own variables, otherwise during a() you could access myBVar) and used; including this binding (demonstated by explicit binding example bellow).

function a() {
  console.log(this)
  arrF = () => {
    console.log(this.myMyObjVar)
  }
  arrF() // when called duplicates current ExecutionContext (LexicalEnvironment + thisBinding), runs in it.
}

var myObj = {myMyObjVar: 42}
a.bind(myObj)()
Arrow functions, outside lexical scope

But what if the arrow function escapes its lexical scope? I.e. it needs to have closure created?

function a() {
  console.log(this)
  var asdf = 48;
  arrF = () => {
    console.log(this.myMyObjVar)
    console.log(asdf)
  }
  
  return arrF
}

var myObj = {myMyObjVar: 42}
aBound = a.bind(myObj)

returnedArrF = aBound()
returnedArrF()
console.dir(returnedArrF)

In this case, returnedArrF's closure needs to not only contain the Environment record of a()'s ExecutionContext (to provide normal closure access to variables from outer lexical scope (asdf)), i.e. what Chromium Devtools show us as [[Scopes]], but also to its This binding. I.e needs to save pretty much the whole ExecutionContext, to allow the excaped arrow function - when executed - to not need to have its own and reuse its outer lexical scope's one.

Curiously, the stored This binding doesn't seem to be surfaced as ?pseudo? property visible with console.dir, the same way as either bind'ed this or normal closure is.

What are my questions?

  1. Are the references to outer lexical context's ExecutionContext, specifically this binding for arrow functions, stored using similar mechanism (under similar model) as closure (think [[scopes]] as chrome dev tools show them) is?
  • If that's the case, why are both thisBinding created by bind(...) and normal closures visible in via Chrome devtools/console.dir, but arrow function's this binding isn't? Is it just implementation detail or is there some higher level reason?
  1. Why are there differences in how explicitely bind'ed functions and arrow functions look when being inspected (or is it just implementation detail and not something JS model mandates?)?
  2. Do I have the model right?
What is not my question / notes?

I understand that ExecutionContext etc. is just a specification "model" and not how individual VMs (V8, ...) implement JS. I also understand that Chromium devtools might show "pseudo" properties that don't really exist/are accessible on the objects (s.a. [[Scopes]]).

I'm also not interested in how arrow functions manifest, how to work with them (I think I have decent grasp; but if you think I missed something based on my examples - feel free to tell me).

Instead, I'm curious how the specification "model" maps to actual implementation. I hope it's clear from the questions .

Notes:

Things I tried to read to make sense of this:

Petrroll
  • 741
  • 7
  • 29
  • "What are my questions?" A sure sign that this question "Needs more focus" – Dexygen Aug 15 '22 at 12:10
  • 1
    "*a common way to explain them is that [arrow functions] don't get their own ExecutionContext*" - no, that's wrong, and not backed by the references you provide. Arrow functions don't get their own [[ThisBinding]] in the execution context that is created normally when calling them. – Bergi Aug 15 '22 at 12:27
  • 1
    "*differences in how explicitely bound functions and arrow functions look when being inspected*" - that's absolutely an implementation detail of the debugger you are using. It's not mandated by anything (though of course it is more or less informed by the mental model described in the specification). – Bergi Aug 15 '22 at 12:31
  • 2
    Btw your mental model of "*Normal function, within lexical scope*" is way too complicated. This is already covered by the general "normal function within any scope" case. The "*At the point where `b` is returned from its lexical scope, a closure must be created*" is wrong. The closure is already created right when the function is defined (created), for *every* function, regardless of what will happen to the function later. – Bergi Aug 15 '22 at 12:56

1 Answers1

0

This question is sprawling and there is a lot of confusion presented but I hope this post can provide some clarity. This is a community wiki post that can be edited by anyone. If you have follow-up questions, we are happy to help.

non-arrow functions

A function's lexical environment is defined at the time the function is created. A function is created when the evaluator encounters one during ordinary evaluation. This is true for non-arrow and arrow functions alike. However, all non-arrow functions have a dynamic context, this, that changes based on how a function is called or binded, called, or applyed -

function F(...args) {
  console.log(this.value, ...args)
}

value = "global"

const A = F.bind({value: "hello"}, 1, 2, 3)
const B = A.bind({value: "world"}, 4, 5, 6)

const o = {
  value: "object",
  method: F
}

F(7,8,9) // global 7 8 9
A(7,8,9) // hello 1 2 3 7 8 9
B(7,8,9) // hello 1 2 3 4 5 6 7 8 9
o.method(7,8,9) // object 7 8 9

arrow functions

What happens when we change F to an arrow function? Arrow functions have a lexical this which cannot be rebound. But why?

const F = (...args) => {
  console.log(this.value, ...args)
}

That is equivalent to the code below -

const LEXICAL_THIS = this
function F(...args) {
   console.log(LEXICAL_THIS.value, ...args)
}

Instead of having their own dynamic context, arrow functions inherit this from the environment they are created in. And because they have no dynamic context of their own, there exists no context to bind. Here's how it behaves -

const F = (...args) => {
  console.log(this.value, ...args)
}

value = "global"

const A = F.bind({value: "hello"}, 1, 2, 3)
const B = A.bind({value: "world"}, 4, 5, 6)

const o = {
  value: "object",
  method: F
}

F(7,8,9) // global 7 8 9
A(7,8,9) // global 1 2 3 7 8 9
B(7,8,9) // global 1 2 3 4 5 6 7 8 9
o.method(7,8,9) // global 7 8 9

nested functions

If you write a function inside of another function, the inner function is not created until the outer function is executed. Executing the outer function multiple times will recreate the inner function (and environment) multiple times -

const add = x => y =>
  x + y
  
const add3 = add(3)
const add6 = add(6)

console.log(add3(10), add3(20), add3(30)) // 13 23 33
console.log(add6(10), add6(20), add6(30)) // 16 26 36

Here's the same program using non-arrow functions -

function add(x) {
  return function(y) {
    return x + y
  }
}

const add3 = add(3)
const add6 = add(6)

console.log(add3(10), add3(20), add3(30)) // 13 23 33
console.log(add6(10), add6(20), add6(30)) // 16 26 36
Mulan
  • 129,518
  • 31
  • 228
  • 259