Introduction
In previous lessons, we explored how to define and create objects using classes, hide internal fields, and implement inheritance. Classes were only introduced in ES6 (2015), before that numerous other JS features and design patterns had to be used to achieve what classes do with ease. It is important for a developer to be able to recognise older code that was used to mimic OOP functionality. You may be asked to work on an older system and should at least be able to recognise these older approaches.
Function Constructors
Function constructors were the most common way of creating objects with a consistent structure (what we did with classes in lesson 1). It is a function that sets fields and methods of an object. Let’s start recreating the Order class using a function constructor.
// function constructor
function Order(orderId, total, date) {
this.orderId = orderId,
this.total = total,
this.date = date
}
// create objects with function constructor
const myFirstOrder = new Order(1, 190, "10-30-2023");
const mySecondOrder = new Order(2, 70, "11-30-2023");
// use the objects as per usual
console.log(myFirstOrder.orderId);
console.log(mySecondOrder.orderId);
Seems straight forward, however, working with function constructors has a few catches that require some cumbersome workarounds. For example, when defining a method in a function constructor, a copy of that function is made for every object. Consider the code below:
// function constructor
function Order(orderId, total, date) {
this.orderId = orderId,
this.total = total,
this.date = date,
this.printReceipt = function () {
console.log(
`Receipt Id: ${this.orderId}, Date: ${this.total} Total: ${this.date}`
);
};
}
// create objects with function constructor
const myFirstOrder = new Order(1, 190, "10-30-2023");
const mySecondOrder = new Order(2, 70, "11-30-2023");
// log out both objects
console.log(myFirstOrder);
console.log(mySecondOrder);
// Output
// Order {
// orderId: 1,
// total: 190,
// date: '10-30-2023',
// printReceipt: [Function (anonymous)] <---------
// }
// Order {
// orderId: 2,
// total: 70,
// date: '11-30-2023',
// printReceipt: [Function (anonymous)] <---------
// }
Every new object created using this will have a copy of the function printReceipt
. This results in excess and unnecessary memory use, as only one copy of the function needs to be stored and referenced when invoked. To work around this we have to avoid creating methods in the function constructor and instead attach the method to what is known as a prototype.
Prototypes
All objects in JavaScript have an object property called a prototype, this can be seen in the previous example if we inspect the browser.
To bypass the above problem, functions can be attached to this prototype, where only one copy will be stored instead of being copied with each new object.
Updating Order
to do this results in the following code:
function Order(orderId, total, date) {
this.orderId = orderId,
this.total = total,
this.date = date;
// printReceipt removed
}
// attach to the prototype of Order
Order.prototype.printReceipt = function () {
console.log(
`Receipt Id: ${this.orderId}, Date: ${this.total} Total: ${this.date}`
);
};
const myFirstOrder = new Order(1, 190, "10-30-2023");
const mySecondOrder = new Order(2, 70, "11-30-2023");
console.log(myFirstOrder);
console.log(mySecondOrder);
Output in browser console
The printReceipt
function is now attached to the prototype of any Order
object and is shared rather than replicated on each object.
Inheritance with Function Constructors - Using the Call Function (Fields)
To inherit using a function constructor, we use the built in call
function on the parent function constructor inside the child function constructor. We pass in the child object using the this keyword as the first argument, then forward any values we want to set in the parent. This is very similar to using the super constructor in classes.
function Order(orderId, total, date) {
this.orderId = orderId,
this.total = total,
this.date = date
}
function CoffeeOrder(qty, orderId, total, date) {
Order.call(this, orderId, total, date);
this.qty = qty
}
const coffeeOrder1 = new CoffeeOrder(8,1,120,"10-30-2024")
console.log(coffeeOrder1)
//output
// CoffeeOrder { orderId: 1, total: 120, date: '10-30-2024', qty: 8 }
Essentially, Order.call(this, orderId, total, date);
takes the coffeeOrder object and attaches fields to it (orderId, total, date
) then the CoffeeOrder function constructor adds the ones specific to CoffeeOrder (qty
). In this way inheritance is achieved.
When inheriting methods, the same issue we looked at previously occurs. Functions are copied on each object.
function Order(orderId, total, date) {
this.orderId = orderId,
this.total = total,
this.date = date,
this.printReceipt = function () {
console.log(
`Receipt Id: ${this.orderId}, Date: ${this.total} Total: ${this.date}`
); // each coffee order will have a copy of printReceipt
};
}
function CoffeeOrder(qty, orderId, total, date) {
Order.call(this, orderId, total, date);
this.qty = qty ;
}
const myFirstCoffeeOrder = new CoffeeOrder(5,1, 190, "10-30-2023");
const mySecondCoffeeOrder = new CoffeeOrder(8,2, 70, "11-30-2023");
console.log(myFirstCoffeeOrder);
console.log(mySecondCoffeeOrder);
Each CoffeeOrder
object created will have a copy of printReceipt
.
Output in browser console
Inheritance with Function Constructors - Copying a Prototype (Methods)
We saw previously that to work around this problem we attach functions to the prototype instead. In order to inherit functions that have been attached to the prototype, the child needs to copy the prototype of the parent.
function Order(orderId, total, date) {
this.orderId = orderId,
this.total = total,
this.date = date;
}
Order.prototype.printReceipt = function () {
console.log(
`Receipt Id: ${this.orderId}, Date: ${this.total} Total: ${this.date}`
);
};
function CoffeeOrder(qty, orderId, total, date) {
Order.call(this, orderId, total, date);
this.qty = qty;
}
CoffeeOrder.prototype = Object.create(Order.prototype);
Any functions not inherited should be attached to the CoffeeOrder
(child) prototype.
function Order(orderId, total, date) {
this.orderId = orderId,
this.total = total,
this.date = date;
}
// attach to the prototype of Order
Order.prototype.printReceipt = function () {
console.log(
`Receipt Id: ${this.orderId}, Date: ${this.total} Total: ${this.date}`
);
};
function CoffeeOrder(qty, orderId, total, date) {
Order.call(this, orderId, total, date);
this.qty = qty;
}
CoffeeOrder.prototype = Object.create(Order.prototype);
CoffeeOrder.prototype.printCoffeeReceipt = function () {
// defines it's own methods (child)
console.log(
`Receipt Id: ${this.orderId}, Date: ${this.total} Total: ${this.date}`
);
};
const myFirstCoffeeOrder = new CoffeeOrder(5,1, 190, "10-30-2023");
const mySecondCoffeeOrder = new CoffeeOrder(8,2, 70, "11-30-2023");
console.log(myFirstCoffeeOrder);
console.log(mySecondCoffeeOrder);
myFirstCoffeeOrder.printCoffeeReceipt()
myFirstCoffeeOrder.printReceipt()
Information Hiding with Function Constructors
When it comes to information hiding, in order to make fields private when using function constructors we can use an IIFE and leverage closures to hide fields. ‘IIFE’ (pronounced iffy) stands for Immediately Invoked Function Expression. It is a function wrapped inside of a grouping operator that immediately executes.
It looks like this:
const bear = (function () {
let sound = "Grrrr";
return {
speak: function () {
return `The bear says ${sound}`;
},
setSound: function (newSound) {
sound = newSound;
},
};
})(); // IIFE
console.log(bear.sound); // => undefined
console.log(bear.setSound("Meow"));
console.log(bear.speak()); // => the bear says Meow
Everything that should be exposed from the IIFE is contained within the return {...}
statement. The sound
variable is scoped to the function and cannot be accessed outside the IIFE but methods defined within have access to it, essentially making `sound
private.
Another approach is the use the Revealing Module Pattern which leverages modules to hide fields. Here’s a simple example of implementing the Revealing Module Pattern in the context of a bank, where we keep the balance
value hidden, but expose public-facing getters (getBalance
) and setters (deposit
, withdraw
). We will put this code inside a file called bank.js
.
let balance = 0;
const deposit = () => {
balance += amount;
};
const withdraw = (amount) => {
balance -= amount;
};
const getBalance = () => {
return balance;
};
const bank = {
deposit,
withdraw,
getBalance,
};
export default bank;
Now we can access the bank
object and utilize the functionality attached to it from elsewhere in our project with an appropriate import
statement, for example:
import bank from "./bank.js";
const printBalance = () => {
console.log(`You now have $(bank.getBalance()) NOK in your account.`);
};
const handleDeposit = (amount) => {
bank.deposit(amount);
printBalance();
};
const handleWithdraw = (amount) => {
bank.withdraw(amount);
printBalance();
};
Because the balance field is not included in the exported bank object, there is no way to directly access it. However, the methods still have access to it in the module.
Polymorphism with Function Constructors
For polymorphism, we can simply apply the same approach used in classes. Override methods in the child function constructor by using the same method name.
function Order(orderId, total, date) {
this.orderId = orderId,
this.total = total,
this.date = date;
}
// attach to the prototype of Order
Order.prototype.printReceipt = function () {
console.log(
`Receipt Id: ${this.orderId}, Date: ${this.total} Total: ${this.date}`
);
};
function CoffeeOrder(qty, orderId, total, date) {
Order.call(this, orderId, total, date);
this.qty = qty;
}
CoffeeOrder.prototype = Object.create(Order.prototype);
CoffeeOrder.prototype.printCoffeeReceipt = function () {
// defines it's own methods (child)
console.log(
`Receipt Id: ${this.orderId}, Date: ${this.total} Total: ${this.date}`
);
};
// overrides printReceipt from parent
CoffeeOrder.prototype.printReceipt = function () {
console.log(
`Coffee Receipt Id: ${this.orderId}, Date: ${this.total} Total: ${this.date}`
);
};
const myFirstCoffeeOrder = new CoffeeOrder(5, 1, 190, "10-30-2023");
myFirstCoffeeOrder.printReceipt()
// output
// Coffee Receipt Id: 1, Date: 190 Total: 10-30-2023
This works because objects will first check their own prototype for a method, before checking prototypes attached from a parent as shown earlier using CoffeeOrder.prototype = Object.create(Order.prototype);
. This is called the prototype chain, illustrated below.
We can see this below in the console when logging the above myFirstCoffeeOrder
object.
When we call myFirstCoffeeOrder.printReceipt()
the prototype of the coffeeOrder is checked first (arrow 1 in the above image) and it found it there and invoked that version. If there was no printReceipt in that prototype it will look for other attached prototypes which may contain that method (arrow 2 in the above image). Therefore, if the coffeeOrder didn’t have a printReceipt method it would have invoked the one it inherited from Order.
Although you will be using classes rather than these function constructors, prototype workarounds and IIFE’s etc, it is important to remember that classes are built on function constructors. Classes simply provide an easier way to do it. When you use classes, the engines running the code will actually still result in code that uses function constructors and versions of the workarounds mentioned.
Lesson task
Goal
To be able to implement OOP mechanisms covered in the previous lessons without the use of classes.
Brief
Demonstrate using legacy code by creating private fields in a function constructor. Then demonstrate inheritance and polymorphism in another simple function constructor.
(optional) If you want to deeper understanding (level 2), replicate the final versions of the classes (Person and Employee) from the previous 3 lessons using legacy code.
Level 1 process
Information hiding
- Create a Function Constructor Called Animal:
- Define a function constructor that initializes name and sound.
- Attach a method makeSound to the prototype to log the sound.
- Create an Instance of Animal:
- Instantiate the Animal constructor with specific values.
- Invoke the makeSound method to test functionality.
- Add Private Fields Using an IIFE:
- Use an IIFE to encapsulate the Animal constructor.
- Define private variables for name and sound.
- Add getter and setter methods within the IIFE to access and modify these private variables.
- Verify the Getter and Setter Methods:
- Create a new Animal instance.
- Use the setter methods to change name and sound.
- Use the getter methods to log the updated name and sound, verifying the changes.
Inheritance
- Create a Function Constructor Called Vehicle:
- Define a function constructor Vehicle that takes make and model as arguments.
- Add a method getDetails to the prototype of Vehicle that logs the make and model.
- Create an Instance of Vehicle:
- Create a new instance of Vehicle with a specific make and model.
- Call the getDetails method on this instance.
- Create a Function Constructor Called Car that Inherits from Vehicle:
- Define a function constructor Car that inherits from Vehicle.
- Add a field numDoors to Car.
- Add a method getCarDetails to the Car prototype that logs the make, model, and numDoors.
- Override the getDetails method in Car to include the number of doors in the details.
- Demonstrate Inheritance and Polymorphism:
- Create instances of both Vehicle and Car.
- Use the getDetails method on both objects to demonstrate polymorphism.
Level 2 process (optional)
Replicate the previous lesson’s classes using only legacy code.
- Create a Function Constructor Called Person:
- Define a function constructor Person that takes firstName and lastName as arguments.
- Add a method introduce to the prototype of Person that logs “Hello, I am Ola Nordmann” if Ola is the firstName and Nordmann is the lastName.
- Create a new instance of Person and call the introduce method.
- Modify the Person Constructor to Make firstName and lastName Private:
- Use an Immediately Invoked Function Expression (IIFE) to encapsulate the Person constructor.
- Make firstName and lastName private variables within the IIFE.
- Add getter and setter methods for firstName and lastName within the IIFE.
- Verify the getters and setters by creating an instance of Person, updating the fields using the setters, and logging the values using the getters.
- Create a Function Constructor Called Employee that Extends Person:
- Define a function constructor Employee that inherits from Person.
- Add private fields jobTitle and salary to Employee.
- Add a method getJobDetails to the prototype of Employee that logs the job title and salary.
- Override the introduce method in the Employee prototype to include the job title in the greeting.
- Demonstrate polymorphism by creating instances of both Person and Employee, and invoking the introduce method on both objects.