JavaScript: The Klassic Way!
MirAli Mobasheri
Posted on January 21, 2024
Before there was the class keyword, there was the constructor function. And now that there are classes, the constructors are still there. Indeed the classes
are only a syntactic sugar for what constructor functions have been doing for a long time.
This post is about the Klassic JS, an era before the class
keyword existed. When developers used constructor
functions to implement Object-Oriented Behavior
. But this doesn't mean it's useless. All the things we learn about, in this article, are actually how JS still works under the hood.
We'll start by reviewing how special data types likes Functions and Objects are treated in JS. Then we'll progress through creating objects using Constructor Functions. And in the end, we'll learn how JS implements some advanced OOP patterns.
1. Functions And Objects
When learning about JS' OOP, objects are the things we'll be talking about. But functions are the ones we'll be using to build those things.
Let's have a quick look at how these two relate in JS.
1.1 Everyone Is A First-Class Citizen
Suppose you've a string variable named a
.
let a = 'Hello World!';
You can check how many characters it has using its length
property.
console.log(a.length) // 12
Now let's suppose you've an array, named b
. You can still check how many elements it has using the same property, length
.
let b = ['Hello World!']
console.log(b.length) // 1
console.log(b[0].length) // 12
What if we define a function and try to log its length
? Does it still work? Let's test it with an add function.
const add = function(a, b) {
return a + b;
}
console.log(add.length) // 2
The log command in the above code block, will output 2. It's how many arguments the function takes (a
and b
). This means that the function we defined has its own set of properties and methods. Like any other of the value types we checked.
This is a simple explanation of what Functions As First-Class Citizens
in JS means:
Functions in JS aren't treated like some subroutine to be called later. They're stored as an
object
. We can store them into variables, pass as arguments and return from function calls.
But, what we're most interested in, is how we can use them as Object Constructors
.
More on that later.
1.2 So Everything Is An Object?
Well, not everything. As a matter of the fact, the string variable, we defined (a
), is known as a primitive value. So, it's an exact value stored somewhere in memory and assigned to a variable's memory address.
But how does a.length
work, if it's a simple value rather than an object?
Any time you try to access a property or a method on a primitive value, JS will provide an Object Wrapper
for the value. It uses that wrapper to give you access to the methods and properties.
So, while everything is not an Object, many things are treated as one. This means objects are a core part of JS. That makes them important when interacting with the language's API.
2. Constructing Objects
In most Object-Oriented programming languages, blueprint
is a keyword in explaining OOP structures. But this isn't the case for JS.
In JavaScript, the keyword is prototyping
. This is how the language, shapes the objects's structure and behavior. There's a prototypal inheritance chain in the background. It specifies what properties and methods are accessible by an Object Instance
. This simplifies the process of sharing properties among different instances of an object.
We create these instances using constructor functions. So, before we get deep with the prototypes, let's see what a constructor is and how it works.
2.1. Functions Will Do The Job
Constructors are functions. You can define a function. Call it with a new
keyword, and congratulations! You've used a constructor.
But in order for a constructor to be a useful one, you should write a useful function. How's that? Let's look at some examples.
Do you remember the add
function? Let's recall the code block:
const add = function(a, b) {
return a + b;
}
This function simply takes two numbers and returns a new one. We can call it with a new
keyword, if we want to.
const adder = new add(1, 2)
But this line has no meaning. The adder is an empty Object. Of course not an entirely empty object. We'll get to that later. But in our case, this line is meaningless. In order to write a useful constructor function, we need to use the this
keyword.
By using this
we can access the instance of the new object that is created when the function is called with the new
keyword.
But first let's check what an instance is.
2.2 I have an instance, therefore I exist!
We're all Humans.
Indeed, human
is a species name. We all share some traits which differentiate us from other species and animals. We can walk on two legs, we have two eyes, and we can speak in different languages.
While all of us are humans, we're not the same human. Each of us are unique. Our birthday is different. Our parents are different. We're born in different locations, and we even have different names.
The difference is what we can call the identity
. You might be of the same type as the others, but you've got a different identity.
The constructor functions, will produce new Objects. These objects will share some traits, but they're not the same production. Each of them is located in a different memory address from the others.
So each instance of an object, is a working example of that object that is stored in a specific memory address.
Now we can finally try creating an object with a constructor function. And see where it goes.
2.3 new
We already learned the add
function was not usable. That's because calling it with the new
keyword returned an empty object. But where does this object come from, and why the hell does the new
keyword even work?
The new
keyword always creates an empty new Object. Why? So, it can give us access to that object, inside the function call.
Let's illustrate it through steps:
1 You call the function with the new keyword:
const adder = new add(1, 2)
2 Now when this code is getting interpreted, as the JS engine reaches the new
keyword, it creates an empty object:
*** INTERPRETER MEMORY
ADDRESS: 0x123456789
=> STORE NEW OBJECT REFERENCE
=> PROPERTIES: []
This is a very simple illustration of the memory allocation of the new object that is created. This object has no properties, which is shown as an empty array.
3 The function is called, and the new object is passed into it as the this
keyword.
*** INTERPRETER MEMORY
ADDRESS: 0x22222222
=> FUNCTION DEFINITION
=> CALL FUNCTION BODY
=> THIS = 0x123456789
... run the rest of function body
So, when trying to access the this
keyword inside the function block, it will give you an empty object.
console.log(this) // {}
this
is the object that new
created.
4 The interpreter runs your function. It evaluates everything and then assigns the value for the adder
variable. But what value? Will it be the sum of the two numbers? No. It will be the object which was passed to this. In case of our function, an empty object.
console.log(adder) // {}
Thus, an object is created on the air, and after the function is called, the same object is assigned to the variable.
But what good is this new object for us? The answer is, we can mutate this object. We can add properties to it, and shape what we need to be returned.
Finally, we're on track!
2.4 this
Is An Object
Objects in JS are mutable. This means, they don't have private or strict members. You can always change a member of an object. Or assign members and values to it.
To create a new object in our constructor function, we've to utilize this behavior. We're given an empty object via the this
keyword and then we can shape it as our needs.
So, let's change our add function to make it a useful constructor
.
const add = function(a, b) {
this.first_number = a;
this.second_number = b;
this.sum = a + b;
}
As you can see, we don't need extra steps to get access to the object stored in this
. We can start on the fly and try adding members to it.
We've added three members to the Object. The first_number
and second_number
properties store the passed arguments in the object. The sum
property is the result of adding them.
Next, we call the function with the new keyword:
const adder = new add(1, 2)
console.log(adder.sum) // 3
Voila! We've done the equivalent of creating a class using the class
keyword in ES6+. Indeed we've done what the class
keyword actually does under the hood.
Let's try creating another object using the add
constructor.
const adder2 = new add(2, 3)
console.log(adder2.sum) // 5
So, we did it. Now what?
3. JS OOP: Behind The Scenes
By this point, we have learned about many things. Including constructors
, new
and this
keywords, and how functions are objects.
In this section we'll dive deeper into the topics concerning how JavaScript thinks about these processes. In short, we want to grasp the concepts!
3.1 Prototypes (again)
I mentioned the prototype
earlier in this post. It defines how OOP behaviors like inheritance
and polymorphism
are implemented in JS.
We'll first have a look at how prototypes can be inspected and altered.
3.1.1 Defining Methods
All that our adder
objects have are two number properties with their sum as a third one. This is cool, but not a very useful thing. In a dynamic programming environment, functions are more useful.
What if adder.first_number
is updated by mutation, but adder.sum
stays the same value? We can change sum
into a function:
const add = function(a, b) {
this.first_number = a;
this.second_number = b;
this.sum = function() {
return this.first_number + this.second_number;
}
}
We can create a new instance and check its properties:
const adder = new add(1, 2);
console.log(adder);
This will output the following object:
{
"first_number": 1,
"second_number": 2,
"sum": f()
}
So, we can call adder.sum
whenever we want and have access to the latest sum of the two numbers in the adder:
console.log(adder.sum()); // 3
adder.first_number = 4;
console.log(adder.sum()); // 6
And as you can see, the sum
function has access to the latest value of first_number
. So, it will return correct values after first_number
is updated.
3.1.2 In Search Of A Better Solution
Do you remember when we talked about instances? In our previous examples each of adder1
and adder2
are instances of the add
function. Instance means they each have a new memory address assigned to them.
This also means every property and method that is a member of this object is stored in a new memory address specific to it. This includes the method definitions too. While this can seem reasonable, it isn't the most efficient approach.
Efficiency in this case is concerned with the fact that each time we create a new instance of add, we also utilize a new memory address for the sum method. This is because, as we learned through this post, function definitions are objects and first class citizens. So they will require to be redefined every time.
But what if there was a way we could define this method only once and access it in every instance? Because a function like sum
doesn't change every time a new instance of add is created.
The definition is always the same. The only thing that changes is the value of this
. The solution in this case is the object prototype
.
Let's travel in time and return our constructor function to the state when it didn't still have the sum function definition. We also remove the sum property.
const add = function(a, b) {
this.first_number = a;
this.second_number = b;
}
What do you think will happen if we call adder.sum()
? It surely throws an error indicating that sum is undefined
. But what will exactly happen, before JS throws the error? Does it only check the adder's instance to find sum? The answer is no. It will also look into adder.prototype
.
In the next section. We'll review the exact order of what happens under the hood.
3.1.3 The Prototypal Chain
When we call adder.sum()
on a version of it that doesn't have sum
function defined, JS will do a chain of searches before it returns to us and identifies sum
as undefined
.
First of all, keep this in mind:
Every object in JS has a link to another object, which is known as its prototype.
Then let's visualize how the JS engine looks for the sum method. For this, we can log the objects in our devtools console to check the output:
const adder = new add(3, 4);
console.log(adder)
This will output the following object:
{
first_number: 3,
second_number: 4
}
The above output displays the own properties
of the object. Own properties only exist on this instance of the object and have the highest priority in the prototypal chain. The JS engine will look into these properties and doesn't find sum
.
But it doesn't lose hope, and looks further, into another object. But which one?
The above output is what the console.log typically shows of an object which includes only the own properties
. Our object has another property which isn't listed here. It's the prototype which we can access and read in three ways:
By opening the browser devtools console and typing the above console.log command.
Using
Object.getPrototypeOf(adder)
.With
console.log(adder.[[Prototype]])
. This is the property key which stores the prototype object in an instance. Every object has this key.
We'll follow with the second approach as it's a clean one. We run the following statement:
console.log(Object.getPrototypeOf(adder))
Which results in:
{
constructor: f {}
}
Where does this prototype come from? As every function in JS is an object, every one of these functions has a prototype which initially has one important method. It's the constructor
we see in the above output. It's the add
function we've defined.
So, every instance of objects returned by a constructor can actually access its constructor function using instance.[[Prototype]].constructor
. But it can also access any other property or method set in prototype.
The JS engine will look into this prototype object and still doesn't find a property named sum
. So, what next? Does it print the error? No. The engine will continue searching. This time in adder.[[Prototype]].[[Prototype]]
.
But what is this second protoytpe?
Every constructor prototype, when created, has a link to the global Object
namespace of JS. Object is an object by itself. But it's used to create other objects and is the base prototype of all objects in JS.
Let's inspect a log of it:
const adderPrototype = Object.getPrototypeOf(adder)
console.log(Object.getPrototypeOf(adderPrototype)
Do you notice Object.getPrototypeOf
? Yes it's the same Object, whose prototype is available in every JS object. The output of the above command looks like the following:
{
constructor,
hasOwnProperty,
isPrototypeOf,
...
}
There are a lot of utility methods in this prototype, which we haven't included here. But they're not our concern in this article. What we want to know is what will happen if JS doesn't find the sum method here too?
Well, it will look into prototype of this object. You might think we'll keep this loop forever. But it will end right here. That's because Object.[[Prototype]].[[Prototype]]
is null
. Which means it isn't linked to any other object.
So every object is linked to another object, if its prototype is an object. If the prototype is set to null, it's safe to say that the object isn't linked to another one.
This is the end of the chain. JS will throw an error and the program exits.
But we've still got things to do!
3.1.4 Adding Members To The Prototype
Like how we altered the this
keyword inside the functions we can alter the prototypes too. To do so how we have different ways. For example as we have been using the Object.getPrototypeOf
for retrieving the prototype object we can use Object.setPrototypeOf
for updating it.
You might also be tempted to mutate the [[Prototype]]
object directly. You can do that too. You can set it to a completely different object, create your own chain of inheritances or even make it null, so the prototype won't affect your object.
This means it's a dirty but yet powerful concept. In this section we only want to learn how methods are added to the prototype so they're defined once and accessible in every instance of the object.
Indeed we saw the solution to this problem in the last section, when we were journeying through the prototype chain. You might have noticed when we checked prototype of the adder
we got an object that had an instance to the constructor
function. This is the best place for storing methods or static properties. Since it's only related to the constructor function and is available to every instance in the first level of prototypal chain.
We can mutate the prototype and add the sum method. It's done quit easily by add.prototype.sum = ...
. This is a convenient way to add methods to constructor functions in JS.
const add = function(a, b) {
this.first_number = a;
this.second_number = b;
}
add.prototype.sum = function() {
return this.first_number + this.second_number;
}
We can easily check that sum works:
const adder = new add(1,2);
console.log(adder.sum()); // 3
adder.first_number = 100;
console.log(adder.sum()); // 102
This was all that we needed to do. But there's one last thing to check out. The fact that sum works correctly in this example means that when JS finds a method in the prototypal chain of an inheriting object, the this
keyword inside that method always points to the inheriting object.
In our example, the inheriting
object is adder
and the method is in the prototype of the function add
's object. But this
doesn't point to the function's object. It points to the object, the search started from.
Over.
3.2 Inheritance and Polymorphism
So far, we can create objects from functions, and we can assign methods to them. We have even learned about the Prototypal chain. But did we go through all that hassle, only to learn how to define shared methods for our objects?
No. We can do more. Indeed prototypes make us able to customize our objects in more complex ways. These include inheriting and polymorphism. These are both advanced OOP concepts well-known in most of OOP languages.
First of all we want to learn what inheritance is.
3.2.1 The Problem: Create More Objects, Write Less Code.
The add
function does its job very great. And all our adders
work perfectly. But what if we wanted to create a type of adder objects which also keep a record of every time a sum was called and the values had been changed.
We can do it in many ways. We can create an absolutely new constructor function, which does the job for us. Or we can change the add function so it keeps the records. But what if we don't want the adders to be able to do more than what they are currently capable of?
Yet the add function shares some of the code we need for the new objects. So, the third way will be a combination of these two.
We don't rewrite the part of code that is shared among the objects. But we create new objects, which in addition to the initial properties can do more advanced things.
Prototypes come to our help. Again.
3.2.2 A New Function.
We start by creating a new constructor. We name it addRecord
. As it's the same as add with the difference it records the changes.
const addRecord = function(a, b) {
}
Our function's body, is empty, because we don't yet know how to handle it. First of all we know that we need to reuse the add
function. Maybe we can try calling the add function inside addRecord
? Let's try that:
const addRecord = function(a,b) {
const adder = new add(a,b) // But where do we put adder?
}
As you can see we can try creating a new instance of add using new. We even stored it in a variable. But there's a problem. The variable is not a part of our object's instance. Maybe we can try assigning the properties of adder to our this
object? But that's not clean and what if we had a hundred properties? Spreading? Seriously? No. These solutions only add references to the instance created by adder. This is not what we want.
Whatever approach we use, we're not going to get a clean object. While addRecord reuses add, it shouldn't treat add as a different object. Indeed it's an altered version of add. It should inherit the behaviors of add.
But how? JS has two interesting methods available for us. They're the call
and the apply
. Our saviors.
3.2.2 this
. Again.
Some years ago, when I was reading about the call
and apply
methods for the first time I had a serious question. While the definitions were clear and I exactly knew what they did, I still couldn't understand what their use case can be.
Well, it turned out they were mainly used in OOP patterns. And they do their job beautifully.
Here's how MDN defines the call
method:
The call() method of Function instances calls this function with a given
this
value and arguments provided individually.
The syntax looks like the following:
function.call(thisArg, arg1, arg2,... , argN)
What apply
does is identical to what call does. It actually doesn't do anything different. The only difference is in the syntax. Apply accepts the arguments as an array. it doesn't take them separately. here's the syntax:
function.apply(thisArg, [arg1, arg2,... , argN])
The thisArg
is what we're most interested in. It lets us do what we did with new with one difference. new
passed a new object as this
to add
. But with call
we can pass any object we want as this
. I think you can guess it now.
We already have a this
when addRecord
is called with new. Why not pass it to add
and let it add first_number
and second_number
properties to the instance created by addRecord
?
Okay, we'll do it!
const addRecord = function(a, b) {
add.call(this, a, b)
}
Then let's create an instance of addRecord and log its properties:
const addRecorder = new addRecord(1, 2);
console.log(addRecorder)
It will output the following object:
{
first_number: 1,
second_number: 2
}
But if we inspect its prototype we'll see, it only contains the constructor of addRecord
. There's no sign of the sum
function again. But don't worry! We don't need to look for it through the whole chain. We'll add it to the chain.
How? We manually link the prototype of addRecord
to add
.
// after we've defined addRecord
addRecord.prototype.prototype = add
This way, when we later call addRecorder.sum()
, it will be found down a deep chain of prototypes. (You can figure it out by yourself)
Mission accomplished.
3.2.3 Everything is a Prototype of a Prototype of a Prototype!
Directly setting a prototype's prototype to a new object, looks a very interesting example of how inheritances can be implemented and mutated in JS.
By now we've only written one line inside addRecord
and one line outside of it. And yet we've totally inherited every property and method of add
. The next step is to define the recording functionality.
For this purpose we add a new property to addRecord
object. It will be an array to later keep the records. The next step will be defining a function to save the result of a sum every time it's called.
We can try defining a new method which has a different name from sum. But that goes against what we're trying to accomplish. The reason that we defined the addRecord in the first place was that we wanted it to be identical to add. The only difference is that when sum
itself is called the result should be recorded.
For this, we can define a new method in addRecord
prototype and name it sum. It does the job of doing the sum, recording the result and returning it.
const addRecord = function(a, b) {
add.call(this, a, b);
this.records = [];
}
addRecord.prototype.prototype = add;
addRecord.prototype.sum = function() {
const result = this.first_number + this.second_number;
this.records.push([this.first_number, this.second_number, result]) // add an array which keeps records of current (a, b) and the sum
return result
}
This should be the complete code of addRecord. Now if we call addRecorder.sum()
it should return the result and also save an array of the current (a,b) values and the sum in the records array.
You might be asking yourself how is this sum method useful? Why not just rename it in some other way? Or why not just add recording behavior to the original add function?
The answer actually depends on the design of your application. In our case we want the developers using our code to have access to two different APIs. Both of them are kept really identical. They both take two arguments and they both have the same properties and methods. This will keep the implementation simple for them. Because they won't need to remember two different methods or property names.
This also keeps the behaviors separated. Maybe the add
object is used for some anonymous behaviors in which we never want any ways of keeping records. So we don't at all implement the behavior in it.
On the other hand the addRecord function is exactly built for this behavior. It doesn't need too much rewrite and it overrides the sum method, without breaking any functionalities accessible by add.prototype.
by the way, the thing we did by overriding the sum
method is called Polymorphism
. It means the function does different behaviors in different objects or situations. It was cool, wasn't it? :)
Here's the result:
const addRecorder = new addRecord(1, 2)
console.log(addRecorder.sum()) // 3
console.log(addRecorder.records) // [[1,2,3]]
addRecorder.first_number = 66;
console.log(addRecorder.sum()) // 68
console.log(addRecorder.records) // [[1,2,3], [66, 2, 68]]
4. The End
This has already been a long article. But I have tried to be as through as possible. Because, by learning all of these concepts you actually know what's happening under the hood every time you use the class
keyword. Everything we learned about Inheritance, Polymorphism and Prototypes is exactly how the extends
keyword of classes works.
Understanding the this
keyword and what value it keeps in different situations can come really handy. Besides those things, knowing how functions are more than just functions, can give you a new insight into how JavaScript is actually shaped.
JS can be really strange and crazy at times. But don't forget; it's the same tool that has evolved the web so far. And it's capable of evolving even more!
Thanks for reading this far. And I hope it has been useful to you. If you have any questions, or ideas about what I should write next, reach me in the comments section. I will be glad to answer you!
Posted on January 21, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 6, 2024