Polymorphism: One Interface, Many Behaviors
Polymorphism (from Greek "many forms") is the ability of objects from different classes to respond to the same message in different ways. It's one of the pillars of OOP that enables writing flexible, extensible, and maintainable code. With polymorphism, we write code that works with abstract interfaces, not concrete implementations.
💡 Key Concepts
- Subtype polymorphism: Treat objects of derived classes as objects of the base class
- Method overriding: Redefine behaviors in subclasses
- Duck typing: "If it walks like a duck and quacks like a duck, then it's a duck"
- Interfaces: Contracts that guarantee common behaviors
Subtype Polymorphism
Subtype polymorphism allows using an object of a derived class wherever an object of the base class is expected. This is made possible through inheritance.
class Animal {{ '{' }}
constructor(protected name: string) {{ '{' }}{{ '}' }}
makeSound(): void {{ '{' }}
console.log(`${{ '{' }}this.name{{ '}' }} makes a generic sound`);
{{ '}' }}
{{ '}' }}
class Dog extends Animal {{ '{' }}
// Override makeSound method
makeSound(): void {{ '{' }}
console.log(`${{ '{' }}this.name{{ '}' }} barks: Woof!`);
{{ '}' }}
{{ '}' }}
class Cat extends Animal {{ '{' }}
makeSound(): void {{ '{' }}
console.log(`${{ '{' }}this.name{{ '}' }} meows: Meow!`);
{{ '}' }}
{{ '}' }}
// Polymorphism in action: same method, different behaviors
function playWithAnimal(animal: Animal): void {{ '{' }}
animal.makeSound(); // We don't know which animal it is!
{{ '}' }}
const animals: Animal[] = [
new Dog("Rex"),
new Cat("Whiskers"),
new Dog("Buddy")
];
animals.forEach(animal => playWithAnimal(animal));
// Output:
// Rex barks: Woof!
// Whiskers meows: Meow!
// Buddy barks: Woof!
The playWithAnimal method accepts an Animal, but works correctly
with Dog and Cat because they are subtypes of Animal. The
correct method is called at runtime based on the actual type of the object.
Polymorphism with Interfaces
Interfaces define contracts that classes must respect. Completely different classes can implement the same interface, enabling polymorphism without inheritance.
interface Drawable {{ '{' }}
draw(): void;
getArea(): number;
{{ '}' }}
class Circle implements Drawable {{ '{' }}
constructor(private radius: number) {{ '{' }}{{ '}' }}
draw(): void {{ '{' }}
console.log(`Drawing a circle with radius ${{ '{' }}this.radius{{ '}' }}`);
{{ '}' }}
getArea(): number {{ '{' }}
return Math.PI * this.radius ** 2;
{{ '}' }}
{{ '}' }}
class Square implements Drawable {{ '{' }}
constructor(private side: number) {{ '{' }}{{ '}' }}
draw(): void {{ '{' }}
console.log(`Drawing a square with side ${{ '{' }}this.side{{ '}' }}`);
{{ '}' }}
getArea(): number {{ '{' }}
return this.side ** 2;
{{ '}' }}
{{ '}' }}
class Triangle implements Drawable {{ '{' }}
constructor(private base: number, private height: number) {{ '{' }}{{ '}' }}
draw(): void {{ '{' }}
console.log(`Drawing a triangle ${{ '{' }}this.base{{ '}' }}x${{ '{' }}this.height{{ '}' }}`);
{{ '}' }}
getArea(): number {{ '{' }}
return (this.base * this.height) / 2;
{{ '}' }}
{{ '}' }}
// Polymorphic function: works with any Drawable
function renderShape(shape: Drawable): void {{ '{' }}
shape.draw();
console.log(`Area: ${{ '{' }}shape.getArea().toFixed(2){{ '}' }}`);
{{ '}' }}
const shapes: Drawable[] = [
new Circle(5),
new Square(4),
new Triangle(6, 3)
];
shapes.forEach(renderShape);
// Output:
// Drawing a circle with radius 5
// Area: 78.54
// Drawing a square with side 4
// Area: 16.00
// Drawing a triangle 6x3
// Area: 9.00
Method Overriding and Super
Override allows subclasses to redefine inherited methods, while
super enables access to the parent class implementation.
class Logger {{ '{' }}
log(message: string): void {{ '{' }}
console.log(`[LOG] ${{ '{' }}message{{ '}' }}`);
{{ '}' }}
{{ '}' }}
class TimestampedLogger extends Logger {{ '{' }}
// Override with additional functionality
override log(message: string): void {{ '{' }}
const timestamp = new Date().toISOString();
// Call parent method and add timestamp
super.log(`[${{ '{' }}timestamp{{ '}' }}] ${{ '{' }}message{{ '}' }}`);
{{ '}' }}
{{ '}' }}
class ColoredLogger extends Logger {{ '{' }}
override log(message: string): void {{ '{' }}
// Completely different behavior
console.log(`\x1b[32m[LOG] ${{ '{' }}message{{ '}' }}\x1b[0m`);
{{ '}' }}
{{ '}' }}
function logMessage(logger: Logger, msg: string): void {{ '{' }}
logger.log(msg);
{{ '}' }}
const basicLogger = new Logger();
const timeLogger = new TimestampedLogger();
const colorLogger = new ColoredLogger();
logMessage(basicLogger, "Basic message");
// [LOG] Basic message
logMessage(timeLogger, "Message with timestamp");
// [LOG] [2024-12-26T10:30:00.000Z] Message with timestamp
logMessage(colorLogger, "Colored message");
// [LOG] Colored message (in green)
Duck Typing in TypeScript
TypeScript uses structural typing (duck typing): if an object has the required properties and methods, it's compatible with a type, even without explicitly implementing an interface.
interface Quackable {{ '{' }}
quack(): void;
{{ '}' }}
class Duck {{ '{' }}
quack(): void {{ '{' }}
console.log("Quack!");
{{ '}' }}
{{ '}' }}
class Person {{ '{' }}
name: string;
constructor(name: string) {{ '{' }}
this.name = name;
{{ '}' }}
// Person has a quack method, so it's compatible with Quackable!
quack(): void {{ '{' }}
console.log(`${{ '{' }}this.name{{ '}' }} imitates a duck: Quack!`);
{{ '}' }}
{{ '}' }}
// Accepts any object with quack() method
function makeItQuack(quacker: Quackable): void {{ '{' }}
quacker.quack();
{{ '}' }}
const duck = new Duck();
const person = new Person("Mario");
makeItQuack(duck); // "Quack!"
makeItQuack(person); // "Mario imitates a duck: Quack!"
// Even literal objects work!
makeItQuack({{ '{' }}
quack: () => console.log("Anonymous object: Quack!")
{{ '}' }});
// "Anonymous object: Quack!"
Parametric Polymorphism (Generics)
Generics allow writing code that works with different types while maintaining type safety. It's a form of compile-time polymorphism.
// Generic repository that works with any type
class Repository<T> {{ '{' }}
private items: T[] = [];
add(item: T): void {{ '{' }}
this.items.push(item);
{{ '}' }}
findById(id: number): T | undefined {{ '{' }}
return this.items[id];
{{ '}' }}
getAll(): T[] {{ '{' }}
return [...this.items];
{{ '}' }}
filter(predicate: (item: T) => boolean): T[] {{ '{' }}
return this.items.filter(predicate);
{{ '}' }}
{{ '}' }}
interface User {{ '{' }}
id: number;
name: string;
{{ '}' }}
interface Product {{ '{' }}
id: number;
title: string;
price: number;
{{ '}' }}
// Same code, different types
const userRepo = new Repository<User>();
userRepo.add({{ '{' }} id: 1, name: "Alice" {{ '}' }});
userRepo.add({{ '{' }} id: 2, name: "Bob" {{ '}' }});
const productRepo = new Repository<Product>();
productRepo.add({{ '{' }} id: 1, title: "Laptop", price: 999 {{ '}' }});
productRepo.add({{ '{' }} id: 2, title: "Mouse", price: 25 {{ '}' }});
// Type-safe: TypeScript knows that users is User[]
const users = userRepo.getAll();
const expensiveProducts = productRepo.filter(p => p.price > 500);
Polymorphism in Design Patterns
Polymorphism is fundamental in many design patterns. Let's see the Strategy Pattern:
// Common interface for all strategies
interface SortStrategy {{ '{' }}
sort(data: number[]): number[];
{{ '}' }}
class BubbleSort implements SortStrategy {{ '{' }}
sort(data: number[]): number[] {{ '{' }}
console.log("Sorting with Bubble Sort");
const arr = [...data];
// Bubble sort implementation (simplified)
for (let i = 0; i < arr.length; i++) {{ '{' }}
for (let j = 0; j < arr.length - i - 1; j++) {{ '{' }}
if (arr[j] > arr[j + 1]) {{ '{' }}
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
{{ '}' }}
{{ '}' }}
{{ '}' }}
return arr;
{{ '}' }}
{{ '}' }}
class QuickSort implements SortStrategy {{ '{' }}
sort(data: number[]): number[] {{ '{' }}
console.log("Sorting with Quick Sort");
// Quick sort implementation (simplified with native sort)
return [...data].sort((a, b) => a - b);
{{ '}' }}
{{ '}' }}
class DataSorter {{ '{' }}
private strategy!: SortStrategy;
// Change strategy at runtime (polymorphism!)
setStrategy(strategy: SortStrategy): void {{ '{' }}
this.strategy = strategy;
{{ '}' }}
sortData(data: number[]): number[] {{ '{' }}
if (!this.strategy) {{ '{' }}
throw new Error("Strategy not set");
{{ '}' }}
return this.strategy.sort(data);
{{ '}' }}
{{ '}' }}
const sorter = new DataSorter();
const data = [5, 2, 8, 1, 9];
sorter.setStrategy(new BubbleSort());
console.log(sorter.sortData(data));
// "Sorting with Bubble Sort"
// [1, 2, 5, 8, 9]
sorter.setStrategy(new QuickSort());
console.log(sorter.sortData(data));
// "Sorting with Quick Sort"
// [1, 2, 5, 8, 9]
Benefits of Polymorphism
✅ Advantages
- Extensibility: Add new classes without modifying existing code
- Maintainability: Cleaner and more organized code
- Reusability: Same functions for different types
- Testability: Mocks and stubs through interfaces
- Flexibility: Change behaviors at runtime
Conclusion
Polymorphism is the mechanism that makes OOP truly powerful. It allows us to write generic code that works with abstractions, not concrete implementations. Whether through inheritance, interfaces, duck typing, or generics, polymorphism makes software flexible, extensible, and maintainable.
Related deep dive: 05 - Principi SOLID: Le Basi dell\.
Related deep dive: 01 - Singleton e Factory Pattern: Creazione Oggetti Controllata.
🎯 Key Points
- Polymorphism = "same interface, different behaviors"
- Override redefines methods in subclasses
- Interfaces enable polymorphism without inheritance
- TypeScript uses structural typing (duck typing)
- Generics = type-safe compile-time polymorphism
Frequently asked questions
What is polymorphism in object-oriented programming?
Polymorphism is the ability of objects from different classes to respond to the same message in different ways.
How does subtype polymorphism work?
Subtype polymorphism allows using an object of a derived class wherever an object of the base class is expected, through inheritance.
What is the purpose of interfaces in polymorphism?
Interfaces define contracts that classes must respect, enabling polymorphism without inheritance and allowing completely different classes to implement the same interface.
How does TypeScript achieve polymorphism?
TypeScript uses structural typing (duck typing), which means if an object has the required properties and methods, it's compatible with a type, even without explicitly implementing an interface.
Commenti
Caricamento commenti...
Sign in to comment