Strict and Type–Converting Comparisons: JS Under the Hood

Understand some of JavaScript’s quirks by looking at how the V8 engine works.

Image by Tumisu from Pixabay

If you’re not already familiar with the difference between ‘strict’ and ‘type–converting’ comparisons in JS — read about it here (before you continue reading).

Tip: Optimize teamwork by using the right tools for code-sharing

Use Bit to share, install and collaborate on individual JS modules/ UI components. Don’t lose any more time on configuring packages, managing multiple repositories or maintaining cumbersome monorepos.

JS data representation in V8

V8 represents all data types internally in an Object class. All data types are a subclass of the Object class.

Number

Boolean

String

Array

Object

A Number in JS let a = 90 is implemented in C++ as follows:

class JSNumber : Object {...} JSNumber* aJSNumber = new JSNumber()

aJSNumber->setNumber(90)

It is a subclass of the Object class (and the same goes for as all of the above data types.)

The Object is allocated in the Heap and has its memory addresses.

V8 would represent let a = [90] and let b = [89] as JSArray which is a subclass of the Object class (in C++). So basically, the above code would be implemented in C++ as follows:

JSArray aJSArray = new JSArray();

JSArray aJSArray.setElementAt(90, 0); JSArray bJSArray = new JSArray();

JSArray bJSArray.setElementAt(89, 0);

What happens when we use the push method to add value to an array?

a.push(100)

b.push(90)

The Array#push method is a mutating method. That means that the data of the array would change but not the memory address.

What is a mutation? In Biology, mutation is the change of DNA pairs in the genome. In Programming, it is the change of data in a complex data structure. In our example, the a array has 90 in its collection. When we call its push method, to add 100 to its collection, the data structure of a changes from the initial —

a = [

90

]

to-

|

|

v a = [

90,

100

]

while maintaining its reference in memory. The aJSArray , the a C++ translation, will still hold its allocation in the Heap.

That’s why the comparison below will resolve in true :

let a = [90]

let c = a

a.push(100)

l(c === a) true

c holds a reference to the aJSArray , the push method didn’t change the reference so c still points to a .

What happens if we use the concat method?

let a = [90]

let c = a

c = a.concat([100])

l(c === a) false

Originally c holds the reference to a but when we added to the array using the concat method the reference changed. The concat is a non-mutating method in Array . In C++ a new JSArray would be allocated and passed to c . If the aJSArray is allocated in the mem address 0xc64848, the new JSArray would be in another mem address.

The concat method copied the values of the a array and then appended the value 100 to it and created a new JSArray with the collection. The C++ equivalent will be like this:

elements = aJSArray->getElements()

JSArray* cJSArray = new JSArray()

cJSArray->setElements(elements)

cJSArray->setElement(100)

return cJSArray

See it allocated a new space for cJSArray, copied the elements of aJSArray, set the value 1000 and returned cJSArray. cJSArry and aJSArray would point to different memory addresses. That is why the JS === comparison of a and c resulted to false. === checks the mem pointers or addresses.

Pointers and References

In C++, we have what is called pointers. These point to the memory address of a variable.

How? All variables are allocated in the RAM memory either in the Stack or Heap.

In the Stack, elements are placed in an orderly form, using the FIFO principle. The Heap, elements are placed in a non-uniform way, the mem manager finds a space that will fit the variable then assigns the variable to it and returns the mem address.

All variables declared with the new keyword are placed in the Heap:

int* i = new int(90)

int* age = new int(100)

The * that precedes the int keyword tells the compiler that this is a pointer. To see where they are located in the heap we will do this:

cout << i; 0x002030234

Calling the variable i standalone prints its address

cout << *i; 90

Calling with the * prints its value 90, * is the dereferencing pointer.

What this means is that i is an integer pointer, it holds an address that points to the value 90. 0x002030234 is the address in the RAM heap where the integer 90 is allocated. i holds the address value 0x002030234 (not the integer value 90) that’s why cout << i; printed 0x002030234. To get the actual value held by the 0x002030234 mem address, we will prefix i with *. That's why cout << *i; yielded 90, the * tells the computer to return the value of the mem address 0x002030234.

The same thing happens with the age,

cout << age; 0x67364736 cout << *age; 100

if we do this:

i = age

i will hold the address value of age.

cout << i;

will give:

0x67364736

See i original value has been overwritten. If we dereference it (i) it will give the value pointed to by 0x67364736:

cout << *i; 100

The == equality operator will check the value the pointers hold, if we run this:

cout << (i == age);

It will print true:

true

because i holds 0x67364736 and age holds 0x67364736, so they are equal.

Strict Equal source in V8

The implementation of the strict equal operator can be found in https://github.com/v8/src/runtime/runtime-operators.cc

RUNTIME_FUNCTION(Runtime_StrictEqual) {

SealHandleScope scope(isolate);

DCHECK_EQ(2, args.length());

CONVERT_ARG_CHECKED(Object, x, 0);

CONVERT_ARG_CHECKED(Object, y, 1);

return isolate->heap()->ToBoolean(x->StrictEquals(y));

}

See the code converts LHS and RHS to Object, then calls the StrictEquals method on the x Object object passing in the RHS y. Let's go the source of Object#StrictEquals : https://github.com/v8/src/objects.cc

bool Object::StrictEquals(Object* that) {

if (this->IsNumber()) {

if (!that->IsNumber()) return false;

return NumberEquals(this, that);

} else if (this->IsString()) {

if (!that->IsString()) return false;

return String::cast(this)->Equals(String::cast(that));

} else if (this->IsBigInt()) {

if (!that->IsBigInt()) return false;

return BigInt::EqualToBigInt(BigInt::cast(this), BigInt::cast(that));

}

return this == that;

}

See the algorithm follows the Strict Equal ECMA specification, the specification we saw, above is its conversion to C++ code.

The if statements run if the source is either a Number or String. At the bottom is where the Object comparison occurs, see it compares the values using the == operator.

Our prev example:

let a = [90]

let b = [100]

They will be represented like below in C++:

// let a = [90] JSArray* aJSArray = new JSArray();

aJSArray->setElementAt(90, 0); // let b = [100]

JSArray* bJSArray = new JSArray();

bJSArray->setElementAt(100, 0);

The aJSArray is the C++ equiv of JS a and bJSArray is the C++ equiv of JS b.

aJSArray and bArray are allocated in diff mem. addresses on the heap. aJSArray may hold 0xeffffff and bJSArray will be located in 0xcffffff. aJSArray and bJSArray are pointers to the values 90 and 100 respectively

=== comparison of a and b

l(a === b)

would be compiled to below in C++:

aJSArray.StrictEquals(bJSArray)

aJSArray and bJSArray are non-Number-String Object so the method Object::StrictEquals will execute this:

bool Object::StrictEquals(Object* that) {

...

return this == that;

}

so above would be this:

aJSArray == bArray

Because aJSArray holds 0xeffffff and bJSArray holds 0xeffffff, both are not equal, so false will be returned. In our Node terminal false will be printed.

See, we have confirmed that === compares objects using their references, their memory references. All data types in JS are allocated in the heap but when referred to in JS, v8 will dereference the primitives and return the memory address value but return the objects without dereferencing them.

Deep Equality Check and Shallow Equality Check

Shallow equality check is the reference check on objects to compare for mem. addresses change as we saw in the above section using the strict equality operator === . React/Angular uses shallow equality check to know when the state object changes to know when to perform change detection or re-render.

Deep equality is a value check, which involves the comparison of properties in the two objects. Selectors use deep equality check to check for value changes in an object’s properties.

Let’s say we have two objects:

let obj1 = {

name: "nnamdi"

} let obj2 = {

name: "chidume"

}

Shallow checking the two objects is uses using the strict equality operator on the objects:

obj1 === obj2 false

It should log false because their references are different.

Deep checking would involve checking the property name :

const keys = Object.keys(obj1) for(let i=0; i < keys.length; i++) {

if(obj1[keys[i]] !== obj2[keys[i]]) {

return false

}

}

return true

See it iterates through the obj1 properties and compares with the obj2 property, if one is different it returns false, stating they are not equal. This is how the reselect library memoizes our state object.

Conclusion

This was deep. We saw and even looked into the source in V8 how the reference equality operator === works.

The == operator works on the type but === works on the reference the value of their value memory address.

If you have any question regarding this or anything I should add, correct or remove, feel free to comment, email or DM me.

Thanks !!!

Learn More