Power of Prototypal Inheritance in JavaScript
In the realm of JavaScript, understanding prototypal inheritance is paramount for developers seeking to master the language’s dynamic nature. In this blog, we will explore various aspects of prototypal inheritance, including its implementation using both ES6 and the hypothetical ES13, the new syntax introduced by classes, the advantages it offers over classical inheritance, inheritance with the prototype chain, different methods for creating and mutating prototype chains, and the crucial consideration of performance.
Prototypal Inheritance Basics:
Prototypal inheritance is a fundamental concept that influences how objects relate to each other and share behaviours. Let’s demystify the basics of prototypal inheritance and explore its nuances.
Properties and Prototypes
When you create an object in JavaScript, it inherently possesses properties. Consider a simple example:
const dog = {};
dog.speak = () => "bark!";
dog.speak(); // Output: bark!
Now, here’s the twist. When you check if dog
has a speak
property using hasOwnProperty
:
dog.hasOwnProperty('speak'); // Returns true
Surprisingly, even though we didn’t explicitly define hasOwnProperty
for dog
, it returns true. This mystery unravels when we inspect the prototype of dog
:
console.log(dog.__proto__); // Outputs: {}
Inheritance Through the Prototype Chain
Inheritance, in the context of prototypal inheritance, is like receiving traits from parents. Objects can inherit properties from their prototypes, creating a chain of relationships.
Consider the journey of accessing a property:
- We check the object itself.
- If not found, we move up to its prototype.
- This process repeats until the prototype becomes null, returning
undefined
if the property is not found.
In the case of dog
, hasOwnProperty
doesn't exist on the object, but dog
is an Object, and its prototype is the Object
object. Thus, the method is executed.
Moving Up the Chain
To understand this chain, let’s explore the prototype chain of different objects using the loggingProtoChain
function:
function loggingProtoChain(thing) {
// Logging properties of the object
console.log("the thing's properties: ", Object.getOwnPropertyNames(thing));
let thisProto = Object.getPrototypeOf(thing);
// Walking up the chain
while (thisProto !== null) {
console.log("prototype properties:", Object.getOwnPropertyNames(thisProto));
thisProto = Object.getPrototypeOf(thisProto);
}
console.log(thisProto);
}
// Example with an array
loggingProtoChain([]); // Outputs: Array -> Object -> null
// Example with a string
loggingProtoChain("fluffykins"); // Outputs: String -> Object -> null
Property Shadowing / Method Overriding
Objects can override methods from their prototypes. For instance:
dog.hasOwnProperty = () => false;
dog.hasOwnProperty("hasOwnProperty"); // Returns false
By doing this, we no longer need to traverse the prototype chain; the overridden method on the object itself is used.
Assigning Prototypes
Now, let’s explore ways to define prototypes:
a. Using new
Operator
function Animal(name, age, greeting) {
this.name = name || "anon";
this.age = age || 4;
this.greeting = greeting || "hello good sir";
}
Animal.prototype.greet = function () {
return this.greeting;
};
// Creating an object using the constructor
const cat = new Animal("kevin", 33, "meow");
cat.greet(); // Output: meow
b. Using Object.create()
const cat = {
greeting: () => "meow",
whereDoYouSeeYourself: function (years = 5) {
return `In ${years} years, I will be sitting on a windowsill watching squirrels.`;
},
};
const fluffykins = Object.create(cat);
fluffykins.whereDoYouSeeYourself();
// Output: In 5 years, I will be sitting on a windowsill watching squirrels.
c. Using Class Syntax
class Point {
constructor(pt) {
// Constructor logic
}
// Other methods and properties
}
class Line extends Point {
constructor(startPt, endPt) {
super();
// Additional constructor logic
}
// More methods and properties
}
Updating Prototypes
If we add to the prototype after creating objects, those objects still reflect the changes:
let duck = new Animal("kevin", 33, "quack");
duck.greet(); // Output: quack
Animal.prototype.sayBye = function () {
return "Farewell...";
};
duck.sayBye(); // Output: Farewell...
Prototypal Inheritance in ES6
In ES6, the introduction of the class
syntax provided a more familiar structure for developers coming from class-based languages. However, it's important to note that classes in JavaScript are essentially syntactic sugar over prototypal inheritance.
Example in ES6:
class Animal {
constructor(name) {
this.name = name;
}
makeSound() {
console.log("Generic animal sound");
}
}
class Dog extends Animal {
makeSound() {
console.log("Woof! Woof!");
}
}
const myDog = new Dog("Buddy");
myDog.makeSound(); // Output: Woof! Woof!
Advantages of Prototypal Inheritance over Classical Inheritance:
Prototypal Inheritance, a key concept in JavaScript, offers several advantages over Classical Inheritance. Let’s delve into each advantage with detailed explanations and practical examples:
Flexibility and Dynamism:
- Prototypal inheritance allows for dynamic changes to an object’s prototype chain during runtime, providing flexibility.
- Example:
const car = {
drive() {
console.log("Driving...");
},
};
const electricCar = Object.create(car);
electricCar.charge = function () {
console.log("Charging...");
};
electricCar.drive(); // Output: Driving...
electricCar.charge(); // Output: Charging...
Simplicity and Clarity:
- Prototypal inheritance promotes a simpler and clearer syntax for creating and extending objects.
- Example:
const vehicle = {
start() {
console.log("Starting...");
},
};
const car = Object.create(vehicle);
car.drive = function () {
console.log("Driving...");
};
car.start(); // Output: Starting...
car.drive(); // Output: Driving...
Object Instances, Not Classes:
- In prototypal inheritance, objects are instances themselves, making the code more straightforward and eliminating the need for distinct class definitions.
- Example:
const person = {
greet() {
console.log("Hello!");
},
};
const john = Object.create(person);
john.name = "John";
john.greet(); // Output: Hello!
Easy Prototyping and Composition:
- Prototypal inheritance facilitates easy prototyping and composition, allowing for the seamless creation of objects with shared behaviors.
- Example:
const swimmer = {
swim() {
console.log("Swimming...");
},
};
const diver = Object.create(swimmer);
diver.dive = function () {
console.log("Diving...");
};
diver.swim(); // Output: Swimming...
diver.dive(); // Output: Diving...
No Constructor Dependency:
- Unlike classical inheritance, prototypal inheritance doesn’t rely heavily on constructors, leading to cleaner code.
- Example:
const mammal = {
breathe() {
console.log("Breathing...");
},
};
const whale = Object.create(mammal);
whale.breathe(); // Output: Breathing...
These advantages showcase how prototypal inheritance aligns well with the dynamic and object-oriented nature of JavaScript. It encourages a more natural and expressive way of creating and extending objects, making the code more maintainable and easier to understand.
Inheritance with the Prototype Chain:
The prototype chain is the backbone of prototypal inheritance in JavaScript. Every object in JavaScript has a [[Prototype]]
link that connects it to another object, forming a chain. When a property is accessed on an object, JavaScript searches for that property in the object's own properties and continues up the prototype chain until the property is found or the end of the chain is reached.
Example:
const mammal = {
isWarmBlooded: true,
breathe() {
console.log("Inhale, exhale");
}
};
const dog = Object.create(mammal);
dog.bark = function () {
console.log("Woof! Woof!");
};
const poodle = Object.create(dog);
poodle.bark(); // Output: Woof! Woof!
poodle.breathe(); // Output: Inhale, exhale
Creating and Mutating Prototype Chains:
JavaScript provides multiple ways to create and mutate prototype chains. Let’s explore a few methods.
a. Using Object.create:
const parent = { property: "I am a parent" };
const child = Object.create(parent);
child.childProperty = "I am a child";
b. Using Constructor Functions:
function Animal(name) {
this.name = name;
}
Animal.prototype.makeSound = function () {
console.log("Generic animal sound");
};
function Dog(name) {
Animal.call(this, name);
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.makeSound = function () {
console.log("Woof! Woof!");
};
c. Using Object.setPrototypeOf:
const obj = { property: "I am an object" };
const anotherObj = { anotherProperty: "I am another object" };
Object.setPrototypeOf(obj, anotherObj);
Performance Considerations:
While prototypal inheritance is powerful, it’s crucial to consider performance implications. Accessing properties high up in the prototype chain can impact performance. Additionally, dynamically mutating prototype chains using methods like Object.setPrototypeOf
may result in suboptimal performance. Avoid monkey-patching existing prototypes and prefer overriding methods in derived classes for better encapsulation.
It’s recommended to design your inheritance structure thoughtfully, considering the balance between flexibility and performance.
Conclusion:
Prototypal inheritance lies at the core of JavaScript’s object-oriented paradigm. Its dynamic nature, coupled with various methods for implementation and manipulation, empowers developers to create flexible and efficient code. By understanding the advantages it offers over classical inheritance and optimizing for performance, developers can harness the full potential of prototypal inheritance in JavaScript.