Python Polymorphism

Master the art of writing flexible, extensible code with polymorphism

🎭 What is Polymorphism?

Polymorphism allows objects of different types to be treated as instances of the same type through a common interface. It's the ability of different classes to be used interchangeably, even though each class implements the same interface differently.


# Example of built-in polymorphism in Python
numbers = [1, 2, 3]    # List
text = "Hello"         # String 
tuple_data = (1, 2)    # Tuple

# len() works polymorphically with different types
print(len(numbers))    # Output: 3
print(len(text))       # Output: 5  
print(len(tuple_data)) # Output: 2

# + operator works differently based on type
print(numbers + [4, 5])    # List concatenation: [1, 2, 3, 4, 5]
print(text + " World")     # String concatenation: "Hello World"
print(tuple_data + (3,))   # Tuple concatenation: (1, 2, 3)
                                    
One
Interface
Many
Forms
Flexible
Code

Understanding Polymorphism Theory

Polymorphism is one of the four pillars of object-oriented programming, alongside encapsulation, inheritance, and abstraction:

🧠 Core Concepts

Method Overriding

Child classes can provide specific implementations of methods defined in parent classes.

Duck Typing

Python's approach: "If it walks like a duck and quacks like a duck, it's a duck."

Interface Consistency

Different objects can be used interchangeably if they implement the same interface.

Runtime Binding

The specific method to call is determined at runtime based on the object's actual type.

🎯 Benefits of Polymorphism

  • Code Reusability: Write code once, use with multiple types
  • Flexibility: Easy to add new types without changing existing code
  • Maintainability: Changes to implementations don't affect client code
  • Extensibility: New classes can be added seamlessly
  • Abstraction: Hide implementation details behind common interfaces

Method Overriding - Basic Polymorphism

The simplest form of polymorphism occurs when child classes override parent class methods:

Basic Method Overriding
# Base class
class Animal:
    def __init__(self, name):
        self.name = name
    
    def make_sound(self):
        return "Some generic animal sound"
    
    def info(self):
        return f"{self.name} is an animal"

# Child classes with overridden methods
class Dog(Animal):
    def make_sound(self):
        return "Woof! Woof!"

class Cat(Animal):
    def make_sound(self):
        return "Meow!"

class Cow(Animal):
    def make_sound(self):
        return "Moo!"

# Using polymorphism
animals = [
    Dog("Buddy"),
    Cat("Whiskers"),
    Cow("Bessie")
]

# Same method call, different behaviors
for animal in animals:
    print(f"{animal.name}: {animal.make_sound()}")

# Output:
# Buddy: Woof! Woof!
# Whiskers: Meow!
# Bessie: Moo!

💡 Key Points

  • Same Interface: All animals have a make_sound() method
  • Different Implementations: Each animal makes a different sound
  • Runtime Decision: Python decides which method to call at runtime
  • Transparent Usage: Client code doesn't need to know the specific animal type

Duck Typing - Python's Polymorphism

Python uses duck typing, where the type of an object is determined by its behavior rather than its class hierarchy:

Duck Typing Example
# Different classes with same interface (no inheritance)
class Duck:
    def fly(self):
        return "Duck flying with wings"
    
    def swim(self):
        return "Duck swimming in water"

class Airplane:
    def fly(self):
        return "Airplane flying with engines"

class Fish:
    def swim(self):
        return "Fish swimming underwater"

class Boat:
    def swim(self):
        return "Boat floating on water"

# Functions that work with any object having the required method
def make_it_fly(flying_object):
    return flying_object.fly()

def make_it_swim(swimming_object):
    return swimming_object.swim()

# Duck typing in action
duck = Duck()
plane = Airplane()
fish = Fish()
boat = Boat()

# These work because objects have the required methods
print(make_it_fly(duck))    # Duck flying with wings
print(make_it_fly(plane))   # Airplane flying with engines

print(make_it_swim(duck))   # Duck swimming in water
print(make_it_swim(fish))   # Fish swimming underwater
print(make_it_swim(boat))   # Boat floating on water

# This would raise an AttributeError
# print(make_it_fly(fish))  # Fish doesn't have fly() method

🦆 Duck Typing Philosophy

"If it walks like a duck and it quacks like a duck, then it must be a duck."

In Python, you don't need to explicitly declare that objects implement a certain interface. If an object has the methods you need, you can use it regardless of its actual type.

Operator Overloading

Python allows you to define how operators work with your custom classes through special methods:

Operator Overloading
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        """Overload + operator"""
        return Vector(self.x + other.x, self.y + other.y)
    
    def __sub__(self, other):
        """Overload - operator"""
        return Vector(self.x - other.x, self.y - other.y)
    
    def __mul__(self, scalar):
        """Overload * operator for scalar multiplication"""
        return Vector(self.x * scalar, self.y * scalar)
    
    def __str__(self):
        """String representation"""
        return f"Vector({self.x}, {self.y})"
    
    def __eq__(self, other):
        """Overload == operator"""
        return self.x == other.x and self.y == other.y

# Using overloaded operators
v1 = Vector(2, 3)
v2 = Vector(1, 4)

# Addition
v3 = v1 + v2
print(v3)  # Vector(3, 7)

# Subtraction
v4 = v1 - v2
print(v4)  # Vector(1, -1)

# Scalar multiplication
v5 = v1 * 3
print(v5)  # Vector(6, 9)

# Equality comparison
print(v1 == v2)  # False
print(v1 == Vector(2, 3))  # True

🔧 Common Magic Methods

__add__() - Addition (+)
__sub__() - Subtraction (-)
__mul__() - Multiplication (*)
__eq__() - Equality (==)
__lt__() - Less than (<)
__len__() - Length (len())

Abstract Base Classes (ABC)

Use ABC to define formal interfaces and ensure consistent implementation across classes:

Abstract Base Classes
from abc import ABC, abstractmethod

# Abstract base class
class Shape(ABC):
    @abstractmethod
    def area(self):
        """Calculate the area of the shape"""
        pass
    
    @abstractmethod
    def perimeter(self):
        """Calculate the perimeter of the shape"""
        pass
    
    # Concrete method (shared by all shapes)
    def description(self):
        return f"This is a {self.__class__.__name__} with area {self.area():.2f}"

# Concrete implementations
class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height
    
    def perimeter(self):
        return 2 * (self.width + self.height)

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        return 3.14159 * self.radius ** 2
    
    def perimeter(self):
        return 2 * 3.14159 * self.radius

# Using the shapes polymorphically
shapes = [
    Rectangle(5, 3),
    Circle(4),
    Rectangle(2, 8)
]

for shape in shapes:
    print(shape.description())
    print(f"Perimeter: {shape.perimeter():.2f}")
    print("-" * 30)

# This would raise TypeError: Can't instantiate abstract class
# shape = Shape()  # Error!

✅ ABC Benefits

  • Interface Enforcement: Ensures all subclasses implement required methods
  • Documentation: Clearly defines what methods a class must have
  • Early Error Detection: Catches missing implementations at class definition time
  • IDE Support: Better autocomplete and type checking

Modern Python: Protocols and Type Hints

Python 3.8+ introduced Protocols for structural typing, providing a more flexible approach to polymorphism:

Protocols and Type Hints
from typing import Protocol
from typing import List

# Define a protocol (interface)
class Drawable(Protocol):
    def draw(self) -> str:
        """Draw the object and return a string representation"""
        ...

# Classes that implicitly implement the protocol
class Circle:
    def __init__(self, radius: float):
        self.radius = radius
    
    def draw(self) -> str:
        return f"Drawing a circle with radius {self.radius}"

class Square:
    def __init__(self, side: float):
        self.side = side
    
    def draw(self) -> str:
        return f"Drawing a square with side {self.side}"

class Triangle:
    def __init__(self, base: float, height: float):
        self.base = base
        self.height = height
    
    def draw(self) -> str:
        return f"Drawing a triangle with base {self.base} and height {self.height}"

# Function that works with any Drawable object
def render_shapes(shapes: List[Drawable]) -> None:
    """Render a list of drawable shapes"""
    for shape in shapes:
        print(shape.draw())

# Usage with type safety
shapes: List[Drawable] = [
    Circle(5.0),
    Square(3.0),
    Triangle(4.0, 6.0)
]

render_shapes(shapes)

# Type checker will catch errors
# shapes.append("not a drawable")  # Type error!

🚀 Protocol Advantages

  • Structural Typing: No need for explicit inheritance
  • Type Safety: Static type checkers can verify correctness
  • Flexibility: Works with existing classes without modification
  • Documentation: Clearly defines expected interfaces

Real-World Applications

Here are practical examples where polymorphism provides significant benefits:

🎮 Game Development

class GameObject:
    def update(self):
        pass
    
    def render(self):
        pass

class Player(GameObject):
    def update(self):
        # Handle player input and movement
        pass

class Enemy(GameObject):
    def update(self):
        # AI behavior and movement
        pass

class Bullet(GameObject):
    def update(self):
        # Move bullet forward
        pass

# Game loop can handle all objects uniformly
game_objects = [Player(), Enemy(), Enemy(), Bullet()]

for obj in game_objects:
    obj.update()  # Each object behaves differently
    obj.render()  # Each object renders differently

💾 Data Processing

class DataProcessor:
    def process(self, data):
        pass

class CSVProcessor(DataProcessor):
    def process(self, data):
        # Process CSV data
        return "Processed CSV data"

class JSONProcessor(DataProcessor):
    def process(self, data):
        # Process JSON data
        return "Processed JSON data"

class XMLProcessor(DataProcessor):
    def process(self, data):
        # Process XML data
        return "Processed XML data"

# Client code doesn't need to know the specific processor type
def handle_data(processor: DataProcessor, data):
    return processor.process(data)

# Easy to add new processors without changing existing code

Polymorphism Best Practices

🎯

Design Principles

  • Design interfaces before implementations
  • Keep interfaces simple and focused
  • Use composition over inheritance when possible
  • Follow the Liskov Substitution Principle
🔧

Implementation Tips

  • Use type hints for better code documentation
  • Prefer Protocols over abstract base classes
  • Handle edge cases in polymorphic methods
  • Test all implementations thoroughly
⚠️

Common Pitfalls

  • Don't break the interface contract
  • Avoid deep inheritance hierarchies
  • Don't assume specific implementation details
  • Handle missing methods gracefully
📈

Performance Considerations

  • Method resolution has slight overhead
  • Use __slots__ for performance-critical classes
  • Consider caching for expensive operations
  • Profile polymorphic code in hot paths

🧠 Test Your Knowledge

Test your understanding of Python polymorphism:

Question 1: What is the main benefit of polymorphism?

Question 2: What is duck typing in Python?

Question 3: What does the @abstractmethod decorator do?