CommonLounge Archive

Advanced Scope: Execution Context and Why it Matters

March 19, 2018

Introduction

In the last tutorial, we learnt how to use scope to get the most out of our scripts. Now we are going to analyze in more detail the process by which scopes are created. Why do we care? Well, because we need this knowledge to be able to understand other advanced topics like closures and IIFEs. A lot of the coolest and most complex things in JavaScript require that we really understand the inner workings of the language. And to do that, we are going to study a concept that is called the Execution Context.


Quick overview of execution contexts

An execution context is not a concrete thing, but rather an abstract concept that is very useful to describe some parts of the process by which JavaScript compiles and executes its code. “Compilation? I thought that JavaScript was an interpreted language!”, you could exclaim. Well… no. JavaScript does have a compilation phase, but we will talk about that later in this tutorial.

We could say that execution context is a fancier word for “scope”, and that it includes a lot more useful information. As with scopes, as soon as the browser loads the script, we enter the global execution context, and each time a function is called (even when the same function is called recursively), its own functional execution context is created.

We should remember that JavaScript runs in a single thread, which means that only one thing can happen at a time. As execution contexts are created inside of nested functions, they cannot be executed at the same time, and so they are added to what is called the Execution Context Stack to wait for their turn.


The Execution Context Stack

Consider this snippet of code:

function one() {
    //some code
  function two() {
        //some code
        function three() {
            //some code
            console.log("We are in function three!");
        }
    }
}

By the time execution gets to “We are in function three!” at line 10, we already have 4 execution contexts stacked:

4. function three's Execution Context
-------------------------------------
3. function two's Execution Context
-------------------------------------
2. function one's Execution Context
-------------------------------------
1. Global Execution Context
-------------------------------------

As you very well know, stacks are LIFO structures, which mean that the “Last In” is the “First Out”. Thus, after the 4 execution contexts have been created, the first one to be processed will be the one for function three(); the second one will be function two(); and so on.

Ok, that’s easy. But what exactly happens when we create an execution context?


Two Steps

The life of execution contexts is basically reduced to two steps:

  1. Creation Stage
  2. Code Execution Stage

After the two steps are finished, the execution context stops existing, and JavaScript’s interpreter (the one in charge of “reading” the code and doing stuff with it) continues with the next execution context in the stack. The Creation stage is closely related to compilation, and so, to understand it, we are going to take a peek into JavaScript’s compiler.


JavaScript’s Compiler

We don’t usually think of JavaScript as a compiled language, but rather as an interpreted one. But this is not because JavaScript does not have a compilation process. The big difference between JavaScript and other traditionally-compiled languages is that JavaScript compiles its code right before executing it. JavaScript’s compilation process includes the common steps that are usually done well in advance by other programming languages:

  1. Tokenizing / Lexing: Dividing a string of code into pieces (tokens) and assigning semantic meaning to them. For example, if the compiler encounters var some_variable = 1;, it would (in some cases) divide this snippet of code into var, some_variable, =, 1, and ;. In JavaScript each one of these tokens has a distinct meaning (keyword, variable name, assignment operator, integer, and semicolon for the above example). Perhaps in some other programming language, the compiler would separate these tokens in some other way (it could decide, for example, that whitespace also has meaning). Tokenizing and lexing are the same process; the difference has to do with the rules that they use to decide how to separate the code into tokens. JavaScript uses lexing, which means that it uses stateful parsing rules to fulfill its purpose.
  2. Parsing: Creating a data structure called AST (Abstract Syntax Tree) that represents the grammatical structure of the script. The AST is a tree with nodes and edges (you can learn more about trees here) that explicitly represent the relationships that exist between the tokens in our code.
  3. Code-Generation: Taking the AST and converting it into code that can be executed by the machine that is running the script.

The process includes other steps, of course, but these ones are the most relevant for our discussion. Basically we can say that JavaScript’s compilation is the process by which variables and functions are declared, lexical scopes are defined, and the this keywords are assigned. More specifically, the creation stage happens during compilation, and it can be divided into the following steps:

  1. Creation of the Activation Object
  2. Creation of the Arguments Object
  3. Scope assignment
  4. Variable instantiation
  5. Assignment of the this keyword

Let’s analyze this through an example:

var var1 = 1;
var var2 = 2;
function example(argument1, argument2) {
    var var3 = 3;
    function innerFunc() {
        var4 = 4;
        console.log(var1 + var2 + var3 + var4);
    }
    innerFunc();    
}
example(var1, var2); //10

When example() is called in line 15, we enter the execution context of said function and the Creation Stage begins. Let’s look at each step:

Creation of the Activation Object

If we imagine the execution context as a JSON object (remember that the execution context is just an abstract concept, but imagining it as a concrete object helps), by now we would have this:

exampleExecutionContext = {
     activationObject = {}   
}

The activation object is currently empty. We will fill it very soon.

Creation of the Arguments Object

The arguments object is created as a property of the activation object. It is an array that contains the arguments that have been passed to the function, along with its corresponding length:

exampleExecutionContext = {
     activationObject = {
         arguments: [
             {argument: 1, length: 1},
             {argument: 2, length: 1}
         ]
     }   
}

Scope assignment

A scope is assigned to the current execution context. This is done by retrieving the scope of the function’s parent (in this case the global scope) and adding the current function’s scope to it. The concatenation of scopes is called the Scope Chain, and it is basically an array that includes all the scopes that have been accumulated until now, so that they are available to the current function. Each scope is represented as a Variable Object (VO). A variable object is basically an activation object that includes declared and assigned variables and functions. The variable object of the current function is added to the beginning of its parent’s scope chain.

exampleExecutionContext = {
     activationObject = {
         arguments: [
             {argument: 1, length: 1},
             {argument: 2, length: 1}
         ]
     },
     scopeChain: [example function VO, global VO]
}

Declaration of Functions and Variables

The activation object is transformed into the variable object (this is just a matter of changing the name, no “transformation” really happens). Functions and variables that exist inside of example() are declared inside of the VO. First, the functions are declared by writing down their name, a pointer to where the function is stored, and stating that they are indeed a function. Then, the variables are declared by writing down their name and assigning a temporary undefined to them.

exampleExecutionContext = {
     variableObject = {
         arguments: [
             {argument: 1, length: 1},
             {argument: 2, length: 1}
         ],
         innerFunc: pointer to innerFunc(),
         var3: undefined
     },
     scopeChain: [example function VO, global VO]
}

Assignment of the “this” keyword

The this keyword is assigned. We will explain more about this in a later tutorial. For now, let’s just assume that it is added to the execution context:

exampleExecutionContext = {
     variableObject = {
         arguments: [
             {argument: 1, length: 1},
             {argument: 2, length: 1}
         ],
         innerFunc: pointer to innerFunc(),
         var3: undefined
     },
     scopeChain: [example function VO, global VO],
     this: {...}
}

Compilation has now ended and the Creation Stage is done. By now, we should be aware of the following:

  1. Arguments have already been assigned, in contrast to functions and variables. This means that arguments’ values are available to the interpreter as soon as compilation is done.
  2. The scope chain includes the variables and functions that have been already assigned in the global scope, and the variables and functions that have been declared in the current function. BUT the scope chain doesn’t include the variables and functions declared in inner scopes, so var4 (inside innerfunc()) doesn’t exist yet.
  3. Functions are declared first, variables are declared second. This is very important because it can lead to some confusions that we will explore later.

Lexical Scope

Remember lexical scope? Well, with our current knowledge, it is possible to understand why it is called that way. Lexical scope refers to scopes that are created during lexing. As lexing is done during compilation and creation stage, this means that when the code is executed, all the scopes that are available have already been created. The compiler has already decided which identifier belongs to which scope, and there’s no way around it (or is there?). Scopes created as lexical scopes depend only on where they are located in the written code; the programmer decides how the scopes are going to be nested by writing functions inside other functions or “inside” the global scope.

By contrast, scopes that are known as dynamic scopes are defined by where the functions are executed, rather than where the functions are written. JavaScript doesn’t use dynamic scopes, so we won’t talk more about this topic.


Code Execution Stage

After compilation and creation stage, the code is executed. This means that the interpreter reads the code line by line and begins assigning values to variables. As soon as it encounters a function call, it stops executing the current function, “enters” the new function, and begins the creation of a new execution context.

And so, from our example:

var var1 = 1;
var var2 = 2;
function example(argument1, argument2) {
    var var3 = 3;
    function innerFunc() {
        var4 = 4;
        console.log(var1 + var2 + var3 + var4);
    }
    innerFunc();    
}
example(var1, var2); //10

At line 11 of execution stage, just before the call to innerFunc(), example’s execution context would look like this:

exampleExecutionContext = {
     variableObject = {
         arguments: [
             {argument: 1, length: 1},
             {argument: 2, length: 1}
         ],
         innerFunc: pointer to innerFunc(),
         var3: 3
     },
     scopeChain: [example function VO, global VO],
     this: {...}
}

As you can see, var3 has already been assigned its value. Then, innerFunc() would be called and a new execution context would be created.


So, why is this important?

Well, let’s get a little bit more practical, so that the importance of execution contexts becomes clear.


ReferenceError and TypeError

Remember that we encountered some of these errors in the last tutorial? Well, now we can explain why they happen.

ReferenceError

Let’s say we have this script:

var var2 = 2;
function first() {
    var var3 = 3;
    function second() {
        var var4 = 4;
        console.log(var4 + var3 + var2 + var1); //ReferenceError
        var var5 = 5;
    }
    second();
}
first();

When the interpreter gets to console.log() on line 7, the current execution context’s scope chain includes the following items. Note that the scopeChain here is an array of variable objects, as defined above.

scopeChain = [
    {
        nameOfScope: second,
        var4: 4,
        var5: undefined
    },
    {
        nameOfScope: first,
        second: pointer to second(),
        var3: 3
    },
    {
        nameOfScope: window,
        first: pointer to first(),
        var2: 2
    }
]

The interpreter tries to execute console.log(var4 + var3 + var2 + var1):

  • To be able to sum the variables, it first looks for var4. It starts by going to index 0 of the scope chain, which is second’s variable object (remember that the current scope chain is constructed by adding the current variable object to the beginning of the parent’s scope chain). It founds var4 and retrieves its value (4).
  • Then, it looks for var3. It doesn’t find it in index 0 of the scope chain, so it continues to index 1 (first’s variable object). var3 is over there, and so the interpreter retrieves its value (3).
  • Then, it looks for var2. It finds it in index 2 (global’s variable object) and retrieves its value (2).
  • Finally, it looks for var1. It is not in index 0, nor in index 1 or 2. So, the interpreter runs out of variable objects (scopes) and realizes that var1 was never declared. The result is a ReferenceError, that means that the interpreter couldn’t find a variable that is being used in the script without having been declared.

TypeError

A TypeError is different. Let’s say that we have this script:

first(); //TypeError
var first = function aFunction() {
    //some code
};

A TypeError occurs when we try to use an object in an unintended way. To understand this example, we have to remember that function declarations are not the same as functions expressions. A function declaration is the following:

function aFunction() {
  //some code
}

When the compiler encounters a function declaration, it writes down its name in the variable object, creates a pointer to where the function is stored, and states clearly that it is a function. And thus, this script would work perfectly:

aFunction(); //”It works!”
function aFunction() {
  console.log(“It works!”);
}

When execution stage begins and aFunction() is encountered, the interpreter looks for the identifier aFunction, finds out that it is a function, runs the function, executes the code inside it, and prints “It works” to the console.

But a function expression works in a different way:

var first = function aFunction() {
    //some code
};

When encountering this snippet of code, the compiler would only declare a variable named first and assign a value of undefined. The function that is being assigned to first will only be read by JavaScript’s interpreter in the execution stage. And so:

first(); //TypeError
var first = function aFunction() {
    //some code
};

When we try to run first(), the interpreter looks for first and finds it, but it realizes that it is not a function, but a variable with the value undefined. It can’t run a variable, and so it throws a Type Error.


Variables without the var keyword

What happens when we don’t add the var keyword to a variable?

function first() {
    var1 = 1;    
}
first();

Pretty simple. In compilation, the variable is not declared, because without the var keyword the compiler does not have a reason to pay attention to it. So, in execution stage, when the interpreter encounters var1 = 1, it starts looking for it. As it does not find it, then the variable is declared in the global scope (as we learned in the last tutorial), and it is assigned the value 1.

Why isn’t a ReferenceError thrown? Well, ReferenceErrors happen when the interpreter is trying to retrieve the value of a variable. In this example, we were trying to assign a value to an undeclared variable. When this happens, JavaScript simply creates the variable and goes on with the assignment.


Hoisting

Last but not least, let’s talk about the concept of hoisting. Remember the order in which functions and variables are declared? The compiler always starts with functions first and then continues with variables. This reordering of variable and function declarations is called hoisting. This means that when a function and a variable are declared with the same name, the function is always going to be declared first, and the variable will be ignored:

first(); //“I am indeed a function”
var first = 1;
function first() {
    console.log(“I am indeed a function”);
}

Even though the variable is written before the function, hoisting will make sure that the function is declared first. And thus, the call to first() works perfectly.

Similarly, when a function is declared first and a variable is then declared with the same name, the variable declaration is going to be ignored. By contrast, if two functions are declared with the same name, the second function to be declared will override the first declaration:

first(); //2
function first() {
  console.log(1);
}
function first() {
  console.log(2);
}

Conclusion

A logical conclusion of this tutorial should be that we must be very careful with the way in which we name things, and the order in which we write things. Consider this next snippet of code:

first(); //undefined, 2
function first() {
    console.log(var1);
    var var1 = 1;
    console.log(var1);
}
function first() {
    console.log(var1);
    var var1 = 2;
    console.log(var1);
}
var first = 1;

As a review, even though the variable first is declared after both functions, the compiler ignores it because we already have an identifier named first in the variable object. Nevertheless, the identifier that is already stored is a pointer to the second function, because the second declaration of the function first() overrides the first one.

Inside of the second function first(), we have a console.log(var1) that prints undefined. This is because the variable has already been declared in the variable object, but it still has an undefined value assigned to it (the default value). When the second console.log(var1) is called, the console prints 2, because var1 has already been assigned a 2 in the previous step.

And this is how execution contexts work! I hope you enjoyed this tutorial. Next, we are going to explore some ways to “cheat” JavaScript’s scope, by using eval(), with, try / catch, and let / const keywords. Continue your journey to become a JavaScript Grand Master!


© 2016-2022. All rights reserved.