Skip to content

02. 03. Polymorphism

idavidov13 edited this page May 3, 2024 · 1 revision

What is Polymorphism

Polymorphism is a fundamental concept in object-oriented programming (OOP) that allows objects of different classes to be treated as objects of a common superclass.

Polymorphism In TypeScript

It enables a single interface or method to work with different types of data, providing flexibility and reusability in the code. The three main types of polymorphism in OOP are:

  1. Subtype polymorphism (also known as inheritance or implementation polymorphism)
  2. Parametric polymorphism (also known as generics)
  3. Ad hoc polymorphism (also known as function overloading or operator overloading)

Subtype polymorphism (also known as inheritance or implementation polymorphism)

Parametric polymorphism (also known as generics) Ad hoc polymorphism (also known as function overloading or operator overloading) Subtype Polymorphism In the context of OOP, subtype polymorphism is the most commonly used form. It is achieved through inheritance, where a subclass inherits properties and methods from a superclass, and can also override or extend the inherited properties and methods.

Here's an example using TypeScript to demonstrate polymorphism through inheritance:

// Define a base class 'Shape' with a common method 'area'
abstract class Shape {
  abstract area(): number;
}

// Define a 'Rectangle' class that extends the base class 'Shape'
class Rectangle extends Shape {
  constructor(private width: number, private height: number) {
    super();
  }

  // Override the 'area' method for 'Rectangle'
  area(): number {
    return this.width * this.height;
  }
}

// Define a 'Circle' class that extends the base class 'Shape'
class Circle extends Shape {
  constructor(private radius: number) {
    super();
  }

  // Override the 'area' method for 'Circle'
  area(): number {
    return Math.PI * this.radius * this.radius;
  }
}

// Create instances of 'Rectangle' and 'Circle'
const rect: Shape = new Rectangle(5, 4);
const circle: Shape = new Circle(3);

// Define a function to use the common interface for calculating the total area of an array of shapes
function getTotalArea(shapes: Shape[]): number {
  return shapes.reduce((totalArea, shape) => totalArea + shape.area(), 0);
}

// Use the getTotalArea function with an array of different shape objects
const totalArea = getTotalArea([rect, circle]);
console.log(`Total area of shapes: ${totalArea}`);

In this example, we define an abstract class Shape with an abstract method area. The Rectangle and Circle classes extend Shape and override the area method with their respective implementations. We can then treat instances of Rectangle and Circle as instances of Shape and use the getTotalArea function to calculate the total area of an array of shapes. This demonstrates polymorphism in action, as different shape objects can be treated as instances of the common superclass Shape.

Parametric polymorphism

Parametric polymorphism, also known as generics, is a form of polymorphism in which a function, class, or interface can work with multiple types without depending on any specific concrete type. It allows the same code to work with different data types, providing flexibility, reusability, and type safety.

Generics in TypeScript are a way to implement parametric polymorphism. By using type parameters, you can create generic functions, classes, or interfaces that can work with multiple types while maintaining type information.

Here's an example using TypeScript to demonstrate parametric polymorphism through generics:

// Define a generic function 'identity' that returns the input value
function identity<T>(value: T): T {
  return value;
}

// Use the 'identity' function with different data types
const numIdentity: number = identity<number>(42);
const strIdentity: string = identity<string>("Hello, TypeScript!");

console.log(`Number identity: ${numIdentity}`);
console.log(`String identity: ${strIdentity}`);

// Define a generic class 'Pair' that can store two values of the same type
class Pair<T> {
  constructor(public first: T, public second: T) {}

  // Swap the first and second values
  swap(): void {
    const temp = this.first;
    this.first = this.second;
    this.second = temp;
  }
}

// Use the 'Pair' class with different data types
const numPair: Pair<number> = new Pair(1, 2);
const strPair: Pair<string> = new Pair("Alice", "Bob");

numPair.swap();
strPair.swap();

console.log(`Number pair after swap: (${numPair.first}, ${numPair.second})`);
console.log(`String pair after swap: (${strPair.first}, ${strPair.second})`);

In this example, we define a generic function identity that accepts a type parameter T and a value of type T, and returns the value unchanged. This function can be used with different data types, such as numbers and strings.

We also define a generic class Pair that accepts a type parameter T and can store two values of the same type. The Pair class has a swap method that swaps the values of its first and second properties. The Pair class can be used with different data types, such as numbers and strings, while maintaining type safety.

By using generics, we create reusable and flexible code that can work with different types without depending on any specific concrete type. This is an example of parametric polymorphism in TypeScript.

The deposit and withdraw methods also enforce some basic validation to ensure that the internal state of the object remains consistent (e.g., not allowing negative amounts or withdrawals that exceed the current balance). This is an example of encapsulation in action, as it helps maintain data integrity and makes the code more robust.

Polymorphism By Example, A Deeper Dive

In the realm of JavaScript and TypeScript, polymorphism can be observed in several libraries and frameworks. One such library is Express.js, a minimalist web framework for Node.js. The beauty of polymorphism in Express.js can be particularly appreciated in the use of middleware functions.

The Magic of Middleware

Middleware functions are functions that have access to the request object, the response object, and the next function in the application’s request-response cycle. Middleware functions can perform a variety of tasks, including executing any code, making changes to the request and response objects, ending the request-response cycle, and invoking the next middleware in the stack.

Understanding Request, Response, and NextFunction

In Express.js with TypeScript, Request, Response, and NextFunction are interfaces provided by the Express.js type definitions.

  • Request: Represents the HTTP request, with properties for the request query string, parameters, body, HTTP headers, and more.
  • Response: Represents the HTTP response that an Express app sends when it gets an HTTP request. It has methods to send the HTTP response, like res.send(), res.json(), res.sendFile(), and more.
  • NextFunction: Represents the next function, which you call to pass control to the next middleware function.

Express.js Middleware: A Playground for Polymorphism

The use of middleware in Express.js is a prime example of polymorphism in action. Middleware functions, despite their internal differences, all adhere to the same interface. They accept Request, Response, and NextFunction parameters, which means they can be used interchangeably in an Express.js application's middleware stack.

Let's look at a simple example:

import express, { Request, Response, NextFunction } from "express";

const app = express();

const middleware1 = (req: Request, res: Response, next: NextFunction) => {
  console.log("Middleware 1");
  next();
};

const middleware2 = (req: Request, res: Response, next: NextFunction) => {
  console.log("Middleware 2");
  next();
};

app.use(middleware1);
app.use(middleware2);

app.listen(3000, () => {
  console.log("Server started");
});

In this code snippet, middleware1 and middleware2 are different functions but can both be used as middleware in the Express.js application because they follow the same interface. This is a manifestation of polymorphism, as different middleware functions can be used interchangeably in the application's middleware stack.

The Power of Polymorphism

Polymorphism is a powerful concept that promotes flexibility and consistency. With polymorphism, you can handle different objects in a similar manner, allowing for more general and abstract implementations. This ability to "change forms" brings several benefits. image

  1. Code Reusability: Polymorphism promotes code reusability by allowing you to write code that can work with objects of different types. Instead of writing separate functions for each object type, you can write a single function that takes an object of a super type. This function can then be used with any subtype of that super type. For instance, in an Express.js application, a single middleware function can process various types of HTTP requests (GET, POST, DELETE, etc.), enhancing the reusability of the code.

  2. Interface Consistency: By using polymorphism, you can maintain a consistent interface across different types of objects. This consistency makes it easier to understand and use the system. In the context of Express.js, all middleware functions use the same interface (i.e., they take the same parameters: Request, Response, and NextFunction). This consistency simplifies the process of adding new middleware functions to the application, as developers can predict the interface they need to adhere to.

  3. Flexibility: Polymorphism provides flexibility in programming by enabling you to introduce new object types that adhere to a specific interface without modifying the overall system. In Express.js, for example, you can effortlessly add new middleware functions to perform new tasks. As long as the new functions follow the standard middleware interface, the existing system does not need to be changed. This flexibility is crucial for growing and evolving applications, as it allows for the seamless integration of new features and enhancements.

  4. Robustness: Polymorphism makes your application more robust by allowing it to handle a wider variety of situations. For example, middleware functions in Express.js can perform a wide range of tasks, including logging, request validation, authentication, and more.

  5. Scalability: As your application grows, you may need to introduce new types of objects. With polymorphism, you can easily introduce new types without having to rewrite existing functions or methods.

  6. Reduced Complexity: Polymorphism can help to reduce complexity in large codebases. By treating different objects as instances of a common super type, you can write more straightforward code that's easier to understand and maintain.

By understanding and utilizing polymorphism, you can reap these benefits and write code that is more flexible, robust, scalable, and easier to maintain. The Express.js middleware system is a great example of how polymorphism can be put into practice in a real-world application.

Clone this wiki locally