Environment under the hood
Notes while going through chapter environment under the hood.
Consider the following simple piece of code:
function f(x) {
return x * 2;
}
function g(y) {
const tmp = y + 1;
return f(tmp);
}
assert.equal(g(3), 8);
As soon as we start executing the code we enter into the top-level scope. What does entering into a scope mean? It means an execution context is pushed to a stack in the memory. What is an execution context ? It's just a pointer to the environment? What's an environment? You can imagine environment as just a key-value data structure where keys are variable names and values are "values of those variables". So basically when we say "entering into a scope" we mean "a pointer pointing to a variable key-value store in the memory is pushed to the top of the stack"
Let's break down what happens when we start executing the code above:
-
We enter the top-level scope i.e an execution context is pushed on top of stack.
-
The context points to an environment in the memory which contains variable name-value pair. Right now, our environment contains variables
f
andg
, both of them being functions.
Loading svg
When we reach the last line (assert.equal(g(3), 8)
), we call g
.
This is what g function looks like:
function g(y) {
const tmp = y + 1;
return f(tmp);
}
Calling a function starts a new scope which means one more execution context is pushed onto the stack. This execution context would point to another environment with it's own variables. This second environment contains variables y
and tmp
.
Loading svg
The main thing to note here is that we can still access f
inside g ( when we return f(tmp)
) i.e somehow "environment 1" has access to the variable f
.
How's it possible?
To access f
, there needs to be a pointer in "environment 1" which points to "environment 0". This pointer is called the outer
. The outer
field of an environment points to the environment corresponding to surrounding scope.
Loading svg
When we access f
inside of g
, the search for the value of f first begins in the "environment 1", and then proceeds to "environment 0". Basically search for a variable begins in the environment pointed to by the execution context at top of stack and then proceeds to environments pointed to by outer
fields.
Just one question remains now: How does environment 1 know to point to environment 0 as soon as the execution of g
begins?
To achieve this, each function has an internal property called [[Scope]]
which points to it's birth environment i.e the environment where the function was first defined.
Loading svg
So every time a function is called, following things happen:
- Execution context is pushed on to the stack
- This execution context points to the corresponding environment in the memory where the environment contains variable name-value pairs.
- The outer field of this environment points at the environment pointed to by
[[Scope]]
internal poperty of this function.
With that in mind, let's try and build a similar diagram for another piece of code to really solidify the concepts
function f(x) {
function square() {
const result = x * x;
return result;
}
return square();
}
assert.equal(f(6), 36);
Loading svg
Copying
The usual method of copying objects by spreading in javascript has several edge cases
- The copied object doesn't have the same prototype
class Myclass{}
const original = new Myclass()
console.log(original instanceof Myclass)
let copy = {...original}
console.log(copy instanceof Myclass)
unless we specify it explicitly
copy = {...original, __proto__: Myclass.prototype}
console.log(copy instanceof Myclass)
- The copied object doesn't contain inherited properties of the original i.e only own properties are copied
const proto = {'a': 2}
const original = {'b':2, __proto__: proto}
const copy = {...original}
console.log(original.a)
console.log(copy.a)
- property attributes aren't copied
const original = Object.defineProperty({}, "a", {
value: 1,
writable: false,
enumerable: true,
configurable: false
})
const copy = {...original}
console.log(Object.getOwnPropertyDescriptors(copy))
Improved shallow copying
function intactShallowCopy(original) {
return Object.defineProperties({}, Object.getOwnPropertyDescriptors(original))
}
const original = Object.defineProperty({}, "a", {
value: 1,
writable: false,
enumerable: true,
configurable: false
})
original.b = {'c': 2}
const copy = intactShallowCopy(original)
console.log(Object.getOwnPropertyDescriptors(copy))
Deep copying
function deepCopy(original) {
if (Array.isArray(original)) {
const copy = [];
for (const [index, value] of original.entries()) {
copy[index] = deepCopy(value);
}
return copy;
} else if (typeof original === 'object' && original !== null) {
const copy = {};
for (const [key, value] of Object.entries(original)) {
copy[key] = deepCopy(value);
}
return copy;
} else {
// Primitive value: atomic, no need to copy
return original;
}
}
Objects under the hood
In javascript, objects are composed of two things:
- Internal slots - storage locations not accessible by javascript
- properties - think of them as key-value pairs but they are more than that
Read more on internal slots here
Properties
Properties are what's interesting. Consider the following object
const obj = {a: 1}
obj
has a property whose key is a
and value is 1
but under the hood, a property has some attributes too, which can be accessed using Object.getOwnPropertyDescriptor
console.log(Object.getOwnPropertyDescriptor(obj, 'a'))
The attributes are:
-
value - The value of the property.
-
writable (boolean) - whether the value of the property can be changed
-
configurable (boolean) - whether the property can be configured i.e whether we can change the values of attributes themselves. If false, we can't change the any attribute other than value, can only change writable from true to false
-
enumerable (boolean) - Whether some properties participate in operations like
Object.keys()
,Object.entries()
etc.
Let's try fiddling with these attributes to see these different behaviors
Object.defineProperty(obj, "a", {
writable: false,
configurable: true,
enumerable: true
})
obj.a = 2
//obj.a can't be changed if writable is false
console.log(obj.a)
//"a" won't be in the resulting array if enumerable is false
console.log(Object.keys(obj))
//The line below will throw an error if configurable is false
Object.defineProperty(obj, 'a', {
enumerable: true
})
console.log(Object.keys(obj))
Now consider the following object:
const obj = {
x: 1,
get a() {
return this.x
},
set a(value) {
this.x = value
}
}
console.log(obj.a)
obj.a = 2
console.log(obj.a)
console.log(Object.getOwnPropertyDescriptor(obj, 'a'))
In the obj
above, a
is an accessor property. The normal properties are called data properties. Accessor properties have two different attributes - get
and set
(both are functions) instead of value
and writable
.
Assignment vs definition
In previous section we say two ways of creating or changing properties on an object:
const obj = {}
//creation using assignment
obj.a = 1
//creation using definition
Object.defineProperty(obj, 'b', {
value: 2,
})
The important thing to keep in mind is that main purpose of assignment is making changes while the main purpose of definition is to create own properties.
To elaborate what this means consider the following code snippet
const proto = {
data: 1,
get x() {
return this.data
},
set x(value) {
this.data = value
}
}
const obj = {
__proto__ : proto
}
//assignment
obj.x = 2
console.log(Object.getOwnPropertyDescriptor(obj["__proto__"], 'x'))
//since we only have "assigned" x, we have just invoked the inherited setter for x.
console.log(Object.getOwnPropertyDescriptor(obj, 'x'))
//definition
Object.defineProperty(obj, 'x', {
value: 2
})
console.log(Object.getOwnPropertyDescriptor(obj["__proto__"], 'x'))
//since we used "defined" x, we have created an own property on obj
console.log(Object.getOwnPropertyDescriptor(obj, 'x'))
console.log(obj.data)
Now although assignment respects inherited setters it doesn't change prototype's properties
obj.data = 3
//this assignment creates an own property on obj instead of changing the data property of prototype
console.log(Object.getOwnPropertyDescriptors(obj))