Primitives VS Objects in Practice
Let's start by creating a similar example to what we had in the last lecture to see this happening in practice. And just like in the previous lecture, let's start by mutating a primitive value.
let lastName = 'Jones';
/**
* Let's say this person gets married 💍
* and decided to change their last name.
*/
let oldLastname = lastName;
lastName = 'Davis';
If we now log both of them to the console,
console.log(lastName)
console.log(oldLastName)
We see that they are, in fact, different. Davis
is the new last name, and Jones
is this old last name that was copied on the second line of code. Everything works as we would expect in an intuitive way. Remember that it works this way because each primitive value will simply be saved into its own piece of memory in the stack.
Let's do the same thing with an object, which, as we already know, is a reference value because it is going to be stored in the heap, and the stack then just keeps a reference to the memory position at which the object is stored in the heap.
const melissa = {
firstName: 'Melissa',
lastName: 'Jones',
age: 27,
};
const marriedMelissa = melissa;
On the above highlighted line, we are copying the entire object. At least that's what it looks like, but behind the scenes, we are actually just copying the reference, which will then point to the same object.
Now as we change the last name on marriedMelissa, we already know that this will not give us the result that we expect.
marriedMelissa.lastName = 'Davis';
console.log('Before marriage: ', melissa);
console.log('After marriage: ', marriedMelissa);
We can see from the console that we get Davis
as the last name before the marriage and after the marriage. At this point, we already know why this happened. If you don't remember why it happened, when we attempted to copy the original melissa
object, it did not create a new object in the heap.
That is, marriedMelissa
is not a new object in the heap. It's simply just another variable in the stack that holds the reference to the original object. So, both variables melissa
, and marriedMelissa
simply point to the same memory address in the heap. And that's because, in the stack, they both hold the same memory address reference which therefore makes sense that if we change a property on marriedMelissa
, it will also change on melissa
itself.
This is also the reason why we can change properties in the marriedMelissa
object, which was declared using a const
(Things that we cannot change). However, what actually needs to be constant is the value in the stack. In the stack, the value only holds the reference, which we are not actually changing. The only thing that we are changing is the underlying object that is stored in the heap. And that is okay to change, that has nothing to do with const
or let
, all right? That's only about the value in the stack, but if we change something in the heap that has nothing to do with const
or let
.
Now, what we can't do is to assign a completely different object to marriedMelissa
. For example, the below code will not work.
marriedMelissa = {};
It does not work because this new object will be stored at a different position in memory, and therefore the reference to that position in memory will have to change here in this variable. And therefore, that does not work. Because that is in the stack, and since it is a constant, we cannot change that value in the stack.
If marriedMelissa
was declared with a let
, then it woud have worked. But since it's a constant, again, it is not allowed. So, as a conclusion, completely changing the object, i.e., assigning a new object is completely different than simply changing a property. It's a fundamental difference. So, please make sure to really understand this.
What if we actually really wanted to copy the object so that we could then change one of them without changing the other? Let me show you a way in which we can do that.
Let's create a new object called leslie
:
const leslie = {
firstName: 'Leslie',
lastName: 'Alexander',
age: 24,
};
If we really wanted to copy this leslie
object, we could use a function called Object.assign
. What this function does is to essentially merge two objects and then return a new one.
// Merging an empty object with leslie
Object.assign({}, leslie);
Doing the above will then create a completely new object where all the properties are really copied. So, the result of calling this function with the provided arguments will be a new object.
const marriedLeslie = Object.assign({}, leslie);
marriedLeslie.lastName = 'Walton'
console.log('Before marriage: ', leslie);
console.log('After marriage: ', marriedLeslie);
From the above result, we see that were able to preserve the original last name Alexander
after we changed the last name on the marriedLeslie
object. What this means is that marriedLeslie
is indeed a real copy of the original. So, all the properties were essentially copied from one object to the other. And so, behind the scenes, what this means is that a new object was in fact created in the heap, and marriedLeslie
is now pointing to that object. It has a reference to that new object.
However, there is still a problem because using this technique of Object.assign
only works on the first level. That is, if we have an object inside the leslie
object for example, then this inner object will still be the same. It will still point to the same place in memory. That's why we say that the Object.assign
method only creates a shallow copy and not a deep clone which is what we would like to have.
So, again, a shallow copy will only copy the properties in the first level, while a deep clone would copy everything. Let me illustrate this so that you can actually understand what I mean.
// An array is basically an object behind the scenes
const leslie = {
firstName: 'Leslie',
lastName: 'Alexander',
age: 24,
family: ['Dries', 'Tom']};
// Here were are now manipulation the array object
marriedLeslie.family.push('Courtney')
marriedLeslie.family.push('Lindsay')
console.log('Before marriage: ', leslie);
console.log('After marriage: ', marriedLeslie);
From the above result, we see that both objects now have a family with four members. The last name of course was preserved because that's in the first level and Object.assign
took care of copying that properly. And so that was not changed as we changed the last name in the copy (marriedLeslie
).
However, the family
object is deeply nested. Therefore, Object.assign
did not really, behind the scenes, copy it to the new object. So, in essence, both the objects, leslie
and marriedLeslie
have a property family
, which points at the same object in the memory heep, and that object is, of course, the array ['Dries', 'Tom']
. Hence, changing the array in one of them will also be changed in the other one.
Now, a deep clone is what we would need here, but it is not easy to achieve, and it would actually be beyond the scope of this lecture to learn how to create a deep clone. Usually we achieve that by using an external library like for example, Lodash, which is a library with a ton of helpful tools, one of which is for deep cloning. And actually we will do that in a later section so that you see how we can include an external library to do this kind of stuff.
With this, we finish this section about how JavaScript works behind the scenes. It was a long one with so many thing and so many new concepts to learn. Many of them were hard and probably confusing, but that's not a problem. That's actually part of learning, and even if you did not understand 100% of everything, you're still good to move on in the course to the next section.