Lexical and Dynamic Scope

 · 

Ben
Ben Myers

Cover image courtesy of Lachlan Fearnley.

Introduction

Consider the following JavaScript and Bash snippets. Ask yourself: what value will the JavaScript code log? Why will it log that? With the exception of some slight syntax differences, the Bash snippet looks pretty similar to the JavaScript code. What will the Bash script log? Is it different from the JavaScript code? Why or why not?

JS
Line 1 
Line 2 let name = 'Ben';
Line 3 
Line 4 function logName() {
Line 5  console.log(name);
Line 6 }
Line 7 
Line 8 function setName() {
Line 9  let name = 'Myers';
Line 10  logName();
Line 11 }
Line 12 
Line 13 setName();
Bash
Line 1 #!/bin/bash
Line 2 name=Ben
Line 3 
Line 4 logName() {
Line 5  echo $name
Line 6 }
Line 7 
Line 8 setName() {
Line 9  local name=Myers
Line 10  logName
Line 11 }
Line 12 
Line 13 setName

Feel free to run both snippets for yourself. When we run the JavaScript snippet, it logs "Ben". The Bash code, however, echoes "Myers" instead.

Why are these two results different? To understand that, we'll need to understand scope.

What Is Scope?

Scope refers to which variables and functions are accessible at a given point during a program's execution. Languages can define different kinds of scopes. Depending on your language of choice, these scopes could include a global scope, block scopes for if-blocks and loops, function scopes that last until the end of the function invocation, and more. Once a scope reaches its end, variables and functions defined in that scope are no longer accessible.

Scopes nest like matryoshka dolls. We can have an if-block scope inside of a for-loop scope inside of a function scope inside the global scope, as in this JavaScript implementation of FizzBuzz:

Line 1 const LIMIT = 100;
Line 2 
Line 3 function fizzBuzz() {
Line 4  for (let i = 1; i <= LIMIT; i++) {
Line 5  let output = '';
Line 6 
Line 7  if (i % 3 === 0) {
Line 8  output += 'Fizz';
Line 9  }
Line 10 
Line 11  if (i % 5 === 0) {
Line 12  output += 'Buzz';
Line 13  }
Line 14 
Line 15  console.log(output || i);
Line 16  }
Line 17 }
Line 18 
Line 19 fizzBuzz();

As the above snippet shows, variables can cascade down to nested scopes. We can use the globally defined LIMIT variable in the for-loop, and we can access the for-loop's output variable inside both of those if-blocks.

This is the scope chain. When a program accesses a variable, the engine will first see whether the current scope has declared that variable. If it has not, then it checks the parent scope, and then its parent scope, and its parent scope, and so forth until it reaches the outermost scope.

This means you can locally define variables without messing with outer scopes' variables:

Line 1 let name = 'Ben';
Line 2 
Line 3 function setName() {
Line 4  let name = 'Myers'; // creates local variable, doesn't override the global `name`
Line 5  console.log(name); // logs "Myers"
Line 6 }
Line 7 
Line 8 setName();
Line 9 console.log(name); // still logs "Ben"

When the program reaches the console.log statement in line 5, the engine first checks whether name has been declared in setName's scope. When it finds the declaration in line 4, it runs with it. It is therefore totally unconcerned with any prior declarations of name, like the one on line 1.

Let's return to the JavaScript snippet from the introduction. Try to trace out its scopes.

Line 1 let name = 'Ben';
Line 2 
Line 3 function logName() {
Line 4  console.log(name);
Line 5 }
Line 6 
Line 7 function setName() {
Line 8  let name = 'Myers';
Line 9  logName();
Line 10 }
Line 11 
Line 12 setName();

You may come to a sticking point: the invocation of logName inside setName. Does invoking logName create a new scope nested inside the setName scope? Or is logName nested in the global scope where it was declared? Would such a distinction even make a difference?

Lexical Scope Versus Dynamic Scope

When the JavaScript and Bash engines reach a line of code that references a variable or a function, they ask different questions of the code. The JavaScript engine asks, "Where was this code declared? In other words, where was this written?" On the other hand, Bash asks, "When was this executed?"

Let's build up to our setName example, step by step, and see how JavaScript and Bash reached different results by asking these questions.

We'll start small:

JS
Line 1 
Line 2 let name = 'Ben';
Line 3, highlighted' console.log(name);
Bash
Line 1 #!/bin/bash
Line 2 name=Ben
Line 3, highlighted' echo $name

When the JavaScript engine reaches the name reference in line 3, it asks itself,

  • Okay, where was this code written? In the global scope.
  • Was a name variable declared in the global scope? Yes, on line 2.
  • I'll use that.

When the Bash engine reaches its name reference in line 3, it instead asks,

  • When was this code executed? In the global scope.
  • Was a name variable declared in the global scope? Yes, on line 2.
  • I'll use that.

In this case, both languages happened to reach the same answer by asking different questions. However, playing only in the global scope is uninteresting. Let's add some complexity with functions.

JS
Line 1 
Line 2 let name = 'Ben';
Line 3 
Line 4 function logName() {
Line 5, highlighted'  console.log(name);
Line 6 }
Line 7 
Line 8 logName();
Bash
Line 1 #!/bin/bash
Line 2 name=Ben
Line 3 
Line 4 logName() {
Line 5, highlighted'  echo $name
Line 6 }
Line 7 
Line 8 logName

When the JavaScript engine reaches the name reference in line 5, it asks,

  • Where was this code written? Inside logName.
  • Has logName declared a name variable? No.
  • Okay, where was logName declared? In the global scope.
  • Has the global scope declared a name? Yes, on line 2.
  • I'll use that.

Meanwhile, when Bash reaches the name reference in line 5, it asks,

  • Where was this code executed? Inside logName.
  • Has logName declared a name variable? No.
  • Okay, where did we call logName? In the global scope.
  • Has the global scope declared a name? Absolutely, on line 2.
  • I'll use that.

Once again, the two languages reach the same answer by asking different questions.

Try to map out the engines' thought process for our setName snippets when they reach the name reference on line 5.

JS
Line 1 
Line 2 let name = 'Ben';
Line 3 
Line 4 function logName() {
Line 5, highlighted'  console.log(name);
Line 6 }
Line 7 
Line 8 function setName() {
Line 9  let name = 'Myers';
Line 10  logName();
Line 11 }
Line 12 
Line 13 setName();
Bash
Line 1 #!/bin/bash
Line 2 name=Ben
Line 3 
Line 4 logName() {
Line 5, highlighted'  echo $name
Line 6 }
Line 7 
Line 8 setName() {
Line 9  local name=Myers
Line 10  logName
Line 11 }
Line 12 
Line 13 setName

The JavaScript engine asks,

  • Where was this code written? Inside logName.
  • Has logName declared a name? No.
  • Where was logName declared? In the global scope.
  • Has the global scope declared a name? Yes, on line 2.
  • I'll use that.

In other words, JavaScript's implementation of scope does not care at all that logName was invoked by setName.

Bash, meanwhile, asks,

  • Where was this code executed? Inside logName.
  • Has logName declared a name? No.
  • Where was logName called? Inside setName.
  • Has setName declared a name? Absolutely, on line 9.
  • I'll use that.

At long last, we see how similar-seeming code can produce wildly different results across the two languages.

JavaScript and other languages such as the C family and Python use lexical scope, also called static scope, which means that scope nests according to where functions and variables are declared. When they encounter a reference to a variable, lexically scoped languages ask "Where was this written? Where was that written?" and so forth until they find a variable declaration.

In lexically scoped languages, variable references are predictable. For instance, name didn't change what it referred to based on whether logName was invoked in the global scope or inside setName. This predictability comes at the cost of more required overhead, generally handled at compile time.

Bash, on the other hand, uses dynamic scope, where scope is nested based on the order of execution. In our snippets, the logName scope was nested inside setName's scope, where it was invoked. Dynamic scope is handled at runtime, and tends to require a little less overhead than lexical scope. It comes at a high cost of unpredictability—the same line of code in a function could refer to two different things depending on where the function was invoked, and subprograms could have the potential to unwittingly overwrite your variables. It's for this reason that the field has largely moved to lexically scoped languages.

Closures

In functional programming languages such as JavaScript, functions are first-class citizens, meaning they can be passed to and returned from other functions just as you would with any other value. Combine this with lexical scope, and you've got yourself a powerful tool.

A function's lexical environment is the set of all variables and functions that have been defined in the scope chain when the function is declared. A function can reference any variable or function in its lexical environment, regardless of where the function has been passed, imported, or invoked. This combination of a function and its lexical environment is called a closure. Because every function is declared in a scope, every function creates a closure.

Here's a quick example. The createCounter function declares a counter variable and an increment function. It returns that increment function, which is promptly stored in the incrementMyCounter variable.

Line 1 function createCounter() {
Line 2  let counter = 0;
Line 3 
Line 4  return function increment() {
Line 5  counter++;
Line 6  console.log(counter);
Line 7  }
Line 8 }
Line 9 
Line 10 let incrementMyCounter = createCounter(); // returns the `increment` function
Line 11 
Line 12 incrementMyCounter(); // logs "1"
Line 13 incrementMyCounter(); // logs "2"
Line 14 incrementMyCounter(); // logs "3"

The increment function (stored in incrementMyCounter) maintains a reference to the counter variable declared in line 2, and can continue to manipulate it even after createCounter is done executing. It does this, even though counter is not defined in the global scope—attempting to log counter in the global scope would give you an uncaught reference error.

What if we call createCounter twice? Will we get two separate increment functions that manipulate separate counter variables? Or will they both manipulate the same counter variable?

Line 1 function createCounter() {
Line 2  let counter = 0;
Line 3 
Line 4  return function increment() {
Line 5  counter++;
Line 6  console.log(counter);
Line 7  }
Line 8 }
Line 9 
Line 10 let incrementFirstCounter = createCounter(); // returns the `increment` function
Line 11 let incrementSecondCounter = createCounter(); // returns the `increment` function
Line 12 
Line 13 incrementFirstCounter(); // logs "1"
Line 14 incrementFirstCounter(); // logs "2"
Line 15 incrementFirstCounter(); // logs "3"
Line 16 
Line 17 incrementSecondCounter(); // logs "1"
Line 18 incrementSecondCounter(); // logs "2"

incrementFirstCounter and incrementSecondCounter are tracking and manipulating two separate counter variables from two separate createCounter invocations.

Let's do one more. What if createCounter returns two functions, both declared inside createCounter's scope? Because we can only return one value at a time, let's stick both of those functions in an object as methods, and return that object.

Line 1 function createCounter() {
Line 2  let counter = 0;
Line 3 
Line 4  function increment() {
Line 5  counter++;
Line 6  console.log(counter);
Line 7  }
Line 8 
Line 9  function decrement() {
Line 10  counter--;
Line 11  console.log(counter);
Line 12  }
Line 13 
Line 14  return {
Line 15  increment,
Line 16  decrement
Line 17  };
Line 18 }
Line 19 
Line 20 let counter = createCounter(); // returns an object, with `increment` and `decrement` methods
Line 21 
Line 22 counter.increment(); // logs "1"
Line 23 counter.increment(); // logs "2"
Line 24 counter.increment(); // logs "3"
Line 25 
Line 26 counter.decrement(); // logs "2"
Line 27 counter.decrement(); // logs "1"

Here, createCounter returns an object with the increment and decrement methods. Both of these methods are defined in the same lexical scope, so they have the same lexical environment. As a result, both of these functions are able to manipulate the same counter variable.

Functional programming is wild.

JavaScript developers take full advantage of closures as an innate feature of the language, often without thinking about it, whenever we...

  • Use an array utility like map, reduce, or filter.
  • Pass a callback to an asynchronous function
  • Import modules (👋 Hi, Node.js)
  • Do basically anything resembling functional programming

It's no surprise that, as a language, JavaScript is basically Oops! All Closures.

Conclusion

If you're reading this, you're likely a JavaScript developer, if I'm being honest. Barring that, you might write Python, or maybe Java, or perhaps some other lexically scoped language. You may not write a lick of Bash, let alone any other dynamically scoped language.

Nevertheless, scope is a part of your everyday work as a developer, whether you're conscious of it or not. Every reference lookup you write causes your language of choice's engine to ask itself where to find that variable. Whether your language of choice uses lexical scope or dynamic scope can radically change the result and, therefore, it will change how you write and interpret your code.

Personally, as a React developer, I can see two big ways that lexical scope impacts my day-to-day work. First, it enables me to write modularized code, which lets me think about how the code I'm writing solves the problem at hand without worrying about causing side effects in the rest of the program. Secondly, lexical scope enables closures, which make passing functions around useful. This lets me use functional programming techniques to solve problems quickly, intuitively, and robustly, and it can enable the same for you.


Ben Myers

Ben Myers is a human T-rex, software developer, accessibility advocate, and a passionate educator. He graduated from Oklahoma State University in 2018 with a bachelor's degree in Computer Science, and now works for USAA as a full-stack engineer. Check out his portfolio and connect with him on LinkedIn.