JavaScript: The Klassic Way!

alimobasheri

MirAli Mobasheri

Posted on January 21, 2024

JavaScript: The Klassic Way!

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!';
Enter fullscreen mode Exit fullscreen mode

You can check how many characters it has using its length property.

console.log(a.length) // 12
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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: []

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

So, when trying to access the this keyword inside the function block, it will give you an empty object.

console.log(this) // {}
Enter fullscreen mode Exit fullscreen mode

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) // {}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }

}
Enter fullscreen mode Exit fullscreen mode

We can create a new instance and check its properties:

const adder = new add(1, 2);
console.log(adder);
Enter fullscreen mode Exit fullscreen mode

This will output the following object:

{
    "first_number": 1,
    "second_number": 2,
    "sum": f()
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

This will output the following object:

{
  first_number: 3,
  second_number: 4
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. By opening the browser devtools console and typing the above console.log command.

  2. Using Object.getPrototypeOf(adder).

  3. 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))
Enter fullscreen mode Exit fullscreen mode

Which results in:

{
  constructor: f {}
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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,
  ...
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) {

}
Enter fullscreen mode Exit fullscreen mode

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?
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Then let's create an instance of addRecord and log its properties:

const addRecorder = new addRecord(1, 2);
console.log(addRecorder)
Enter fullscreen mode Exit fullscreen mode

It will output the following object:

{
  first_number: 1,
  second_number: 2
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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]]
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
alimobasheri
MirAli Mobasheri

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