Module: OOP and Prototypes

Inheritance

JavaScript Essentials: OOP and Prototypes - Inheritance

Inheritance is a fundamental concept in Object-Oriented Programming (OOP) that allows you to create new objects (child objects) based on existing objects (parent objects). This promotes code reusability and establishes a hierarchical relationship between objects. JavaScript implements inheritance primarily through prototypes.

Understanding Prototypes

Before diving into inheritance, it's crucial to understand prototypes. In JavaScript, every object has a prototype object associated with it.

  • Prototype Chain: When you try to access a property on an object, JavaScript first looks for that property directly on the object itself. If it doesn't find it, it looks at the object's prototype. If the property isn't found there, it looks at the prototype's prototype, and so on, until it reaches the end of the chain (which is null). This is called the prototype chain.
  • __proto__: The __proto__ property (though discouraged for direct manipulation in modern JavaScript) points to the object's prototype.
  • prototype property: Functions have a prototype property, which is an object. When a new object is created using the new keyword with that function, the new object's __proto__ is set to the function's prototype object.

Example:

function Animal(name) {
  this.name = name;
}

Animal.prototype.sayHello = function() {
  console.log("Hello, my name is " + this.name);
};

const dog = new Animal("Buddy");
dog.sayHello(); // Output: Hello, my name is Buddy

console.log(dog.__proto__ === Animal.prototype); // Output: true

In this example:

  • Animal.prototype is an object containing the sayHello method.
  • dog.__proto__ points to Animal.prototype.
  • When dog.sayHello() is called, JavaScript finds the sayHello method on dog.__proto__ (which is Animal.prototype).

Types of Inheritance in JavaScript

JavaScript offers several ways to achieve inheritance:

1. Prototypal Inheritance (Classical Prototypal Inheritance)

This is the most common and fundamental form of inheritance in JavaScript. It involves directly manipulating the prototype chain.

function Animal(name) {
  this.name = name;
}

Animal.prototype.sayHello = function() {
  console.log("Hello, my name is " + this.name);
};

function Dog(name, breed) {
  Animal.call(this, name); // Call the parent constructor
  this.breed = breed;
}

// Set up the prototype chain: Dog.prototype inherits from Animal.prototype
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // Reset the constructor property

Dog.prototype.bark = function() {
  console.log("Woof!");
};

const myDog = new Dog("Max", "Golden Retriever");
myDog.sayHello(); // Output: Hello, my name is Max (inherited from Animal)
myDog.bark();     // Output: Woof! (defined in Dog)
console.log(myDog instanceof Animal); // Output: true
console.log(myDog instanceof Dog); // Output: true

Explanation:

  • Animal.call(this, name): This calls the Animal constructor with the Dog object as this, effectively initializing the name property inherited from Animal. This is crucial for setting up the inherited properties.
  • Object.create(Animal.prototype): This creates a new object whose prototype is Animal.prototype. This establishes the inheritance link. It's preferred over directly assigning Dog.prototype = Animal.prototype because the latter would make all instances of Animal and Dog share the same prototype object, leading to unintended side effects.
  • Dog.prototype.constructor = Dog: After setting the prototype, the constructor property of Dog.prototype is reset to Dog. This is important for correctly identifying the constructor of objects created with new Dog().
  • instanceof: The instanceof operator checks if an object is an instance of a particular constructor.

2. ES6 Class Inheritance (Syntactic Sugar)

ES6 introduced the class keyword, providing a more familiar syntax for creating objects and implementing inheritance. However, it's important to remember that classes in JavaScript are still built on top of prototypes. They are syntactic sugar, making the code more readable and organized.

class Animal {
  constructor(name) {
    this.name = name;
  }

  sayHello() {
    console.log("Hello, my name is " + this.name);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // Call the parent constructor
    this.breed = breed;
  }

  bark() {
    console.log("Woof!");
  }
}

const myDog = new Dog("Luna", "Labrador");
myDog.sayHello(); // Output: Hello, my name is Luna
myDog.bark();     // Output: Woof!
console.log(myDog instanceof Animal); // Output: true
console.log(myDog instanceof Dog); // Output: true

Explanation:

  • extends Animal: This indicates that the Dog class inherits from the Animal class.
  • super(name): This calls the constructor of the parent class (Animal) with the provided arguments. It's essential to call super() before accessing this in the child class constructor.

3. Object.assign() (Less Common for Deep Inheritance)

Object.assign() can be used to copy properties from one object to another, including prototype properties. However, it's generally not recommended for complex inheritance scenarios as it performs a shallow copy.

const animal = {
  name: "Generic Animal",
  sayHello: function() {
    console.log("Hello, my name is " + this.name);
  }
};

const dog = Object.assign({}, animal, {
  breed: "Poodle",
  bark: function() {
    console.log("Woof!");
  }
});

dog.sayHello(); // Output: Hello, my name is Generic Animal
dog.bark();     // Output: Woof!

This approach creates a new object dog that inherits properties from animal and adds its own. However, it doesn't establish a true prototype chain like the other methods.

Key Considerations

  • Constructor Chaining: Always call the parent constructor (using Animal.call(this, ...) or super(...)) to initialize inherited properties correctly.
  • Prototype Chain Management: Be careful when manipulating the prototype chain. Incorrectly setting up the prototype can lead to unexpected behavior. Object.create() is generally preferred over direct assignment.
  • instanceof Operator: Use instanceof to check if an object is an instance of a particular constructor.
  • Method Overriding: Child classes can override methods inherited from parent classes by defining methods with the same name.
  • super Keyword (ES6 Classes): The super keyword allows you to call methods and access properties of the parent class within the child class.

In conclusion, understanding prototypes and how to leverage them is crucial for mastering inheritance in JavaScript. While ES6 classes provide a more convenient syntax, they are ultimately built on the foundation of prototypal inheritance. Choosing the right approach depends on the complexity of your inheritance hierarchy and your preference for code readability.