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?
let name = 'Ben';
function logName() {
console.log(name);
}
function setName() {
let name = 'Myers';
logName();
}
setName();
#!/bin/bash
name=Ben
logName() {
echo $name
}
setName() {
local name=Myers
logName
}
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:
const LIMIT = 100;
function fizzBuzz() {
for (let i = 1; i <= LIMIT; i++) {
let output = '';
if (i % 3 === 0) {
output += 'Fizz';
}
if (i % 5 === 0) {
output += 'Buzz';
}
console.log(output || i);
}
}
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:
let name = 'Ben';
function setName() {
let name = 'Myers'; // creates local variable, doesn't override the global `name`
console.log(name); // logs "Myers"
}
setName();
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.
let name = 'Ben';
function logName() {
console.log(name);
}
function setName() {
let name = 'Myers';
logName();
}
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:
let name = 'Ben';
console.log(name);
#!/bin/bash
name=Ben
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.
let name = 'Ben';
function logName() {
console.log(name);
}
logName();
#!/bin/bash
name=Ben
logName() {
echo $name
}
logName
When the JavaScript engine reaches the name
reference in line 5, it asks,
- Where was this code written? Inside
logName
. - Has
logName
declared aname
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 aname
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.
let name = 'Ben';
function logName() {
console.log(name);
}
function setName() {
let name = 'Myers';
logName();
}
setName();
#!/bin/bash
name=Ben
logName() {
echo $name
}
setName() {
local name=Myers
logName
}
setName
The JavaScript engine asks,
- Where was this code written? Inside
logName
. - Has
logName
declared aname
? 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 aname
? No. - Where was
logName
called? InsidesetName
. - Has
setName
declared aname
? 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.
function createCounter() {
let counter = 0;
return function increment() {
counter++;
console.log(counter);
}
}
let incrementMyCounter = createCounter(); // returns the `increment` function
incrementMyCounter(); // logs "1"
incrementMyCounter(); // logs "2"
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?
function createCounter() {
let counter = 0;
return function increment() {
counter++;
console.log(counter);
}
}
let incrementFirstCounter = createCounter(); // returns the `increment` function
let incrementSecondCounter = createCounter(); // returns the `increment` function
incrementFirstCounter(); // logs "1"
incrementFirstCounter(); // logs "2"
incrementFirstCounter(); // logs "3"
incrementSecondCounter(); // logs "1"
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.
function createCounter() {
let counter = 0;
function increment() {
counter++;
console.log(counter);
}
function decrement() {
counter--;
console.log(counter);
}
return {
increment,
decrement
};
}
let counter = createCounter(); // returns an object, with `increment` and `decrement` methods
counter.increment(); // logs "1"
counter.increment(); // logs "2"
counter.increment(); // logs "3"
counter.decrement(); // logs "2"
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
, orfilter
. - 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.