Python OOP Concepts Polymorphism Tutorial
Intermediate Level Many Forms

Python Polymorphism: Complete Guide with Examples

Master Python polymorphism - the ability to take many forms. Learn method overriding, operator overloading, duck typing with practical examples.

Method Overriding

Same method, different behavior

Operator Overloading

Custom operator behavior

Duck Typing

If it walks like a duck...

30+ Examples

Practical polymorphism patterns

What is Polymorphism?

Polymorphism (from Greek: "poly" = many, "morph" = forms) allows objects of different classes to be treated as objects of a common super class. In Python, polymorphism enables the same interface to be used for different data types.

Types of Polymorphism
  • Method Overriding: Same method name in parent/child
  • Operator Overloading: Same operator for different types
  • Duck Typing: Focus on behavior, not type
  • Runtime Polymorphism: Method resolution at runtime
Key Benefits
  • Code reusability and flexibility
  • Simplified interface for different objects
  • Extensible and maintainable code
  • Dynamic method resolution
  • Cleaner, more readable code

Real-world Analogy

Think of a "start" method. A Car's start() ignites the engine. A Computer's start() boots the OS. A Meeting's start() begins the discussion. Same method name, different implementations!

draw()

Circle.draw()

Draws a circle

Rectangle.draw()

Draws a rectangle

Triangle.draw()

Draws a triangle

Same draw() method, different shapes!

basic_polymorphism.py
# Basic Polymorphism Example
class Animal:
    def speak(self):
        raise NotImplementedError("Subclass must implement abstract method")

class Dog(Animal):
    def speak(self):
        return "Woof!"

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

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

# Polymorphic behavior
animals = [Dog(), Cat(), Cow()]

print("=== Animal Sounds (Polymorphism) ===")
for animal in animals:
    # Same method call, different behaviors
    print(f"{animal.__class__.__name__}: {animal.speak()}")

# Output:
# Dog: Woof!
# Cat: Meow!
# Cow: Moo!

# Another example with shapes
class Shape:
    def area(self):
        raise NotImplementedError("Subclass must implement area()")

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        import math
        return math.pi * self.radius ** 2

class Triangle(Shape):
    def __init__(self, base, height):
        self.base = base
        self.height = height
    
    def area(self):
        return 0.5 * self.base * self.height

# Polymorphic collection of shapes
print("\n=== Shape Areas (Polymorphism) ===")
shapes = [
    Rectangle(4, 5),
    Circle(3),
    Triangle(6, 4)
]

for shape in shapes:
    # Same method call, different calculations
    print(f"{shape.__class__.__name__} area: {shape.area():.2f}")

# Output:
# Rectangle area: 20.00
# Circle area: 28.27
# Triangle area: 12.00

Method Overriding

Method overriding occurs when a child class provides a specific implementation of a method that is already defined in its parent class. The overridden method in the child class has the same name and parameters.

method_overriding.py
# =============================================
# EXAMPLE 1: Basic Method Overriding
# =============================================
class Vehicle:
    """Base Vehicle class"""
    
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model
        self.speed = 0
    
    def start(self):
        """Start the vehicle"""
        return f"{self.brand} {self.model} starting..."
    
    def accelerate(self, increment):
        """Accelerate the vehicle"""
        self.speed += increment
        return f"{self.brand} {self.model} accelerating to {self.speed} km/h"
    
    def stop(self):
        """Stop the vehicle"""
        self.speed = 0
        return f"{self.brand} {self.model} stopping"
    
    def get_info(self):
        """Get vehicle information"""
        return f"{self.brand} {self.model}"

class Car(Vehicle):
    """Car class overriding Vehicle methods"""
    
    def __init__(self, brand, model, doors):
        super().__init__(brand, model)
        self.doors = doors
        self.is_engine_on = False
    
    # Overriding start method
    def start(self):
        """Car-specific start implementation"""
        self.is_engine_on = True
        return f"🚗 {self.brand} {self.model}: Engine started with key ignition"
    
    # Overriding accelerate method
    def accelerate(self, increment):
        """Car-specific acceleration"""
        if not self.is_engine_on:
            return "Cannot accelerate. Engine is off!"
        
        self.speed += increment
        if self.speed > 120:
            return f"⚠️ {self.brand} {self.model}: Speed limit exceeded ({self.speed} km/h)"
        return f"🚗 {self.brand} {self.model}: Accelerating to {self.speed} km/h"
    
    # Overriding get_info method
    def get_info(self):
        """Extended info with car details"""
        base_info = super().get_info()  # Call parent method
        return f"{base_info} ({self.doors} doors)"

class Bicycle(Vehicle):
    """Bicycle class overriding Vehicle methods"""
    
    def __init__(self, brand, model, gear_count):
        super().__init__(brand, model)
        self.gear_count = gear_count
        self.current_gear = 1
    
    # Overriding start method
    def start(self):
        """Bicycle-specific start implementation"""
        return f"🚲 {self.brand} {self.model}: Started pedaling"
    
    # Overriding accelerate method
    def accelerate(self, increment):
        """Bicycle-specific acceleration"""
        self.speed += increment
        if self.speed > 30:
            return f"🚲 {self.brand} {self.model}: Pedaling fast at {self.speed} km/h"
        return f"🚲 {self.brand} {self.model}: Cruising at {self.speed} km/h"
    
    # New method specific to Bicycle
    def change_gear(self, gear):
        """Bicycle-specific method"""
        if 1 <= gear <= self.gear_count:
            self.current_gear = gear
            return f"Changed to gear {gear}"
        return "Invalid gear"

class Airplane(Vehicle):
    """Airplane class overriding Vehicle methods"""
    
    def __init__(self, brand, model, wingspan):
        super().__init__(brand, model)
        self.wingspan = wingspan
        self.altitude = 0
    
    # Overriding start method
    def start(self):
        """Airplane-specific start implementation"""
        return f"✈️ {self.brand} {self.model}: Starting engines and taxiing"
    
    # Overriding accelerate method
    def accelerate(self, increment):
        """Airplane-specific acceleration"""
        self.speed += increment
        
        # Takeoff logic
        if self.speed >= 250 and self.altitude == 0:
            self.altitude = 1000
            return f"✈️ {self.brand} {self.model}: Took off! Speed: {self.speed} km/h, Altitude: {self.altitude} m"
        
        if self.altitude > 0:
            return f"✈️ {self.brand} {self.model}: Flying at {self.speed} km/h, Altitude: {self.altitude} m"
        
        return f"✈️ {self.brand} {self.model}: Taxiing at {self.speed} km/h"
    
    # New method specific to Airplane
    def climb(self, feet):
        """Airplane-specific method"""
        self.altitude += feet
        return f"Climbing to {self.altitude} feet"

print("=== Method Overriding Examples ===")

# Create different vehicles
car = Car("Toyota", "Camry", 4)
bicycle = Bicycle("Giant", "Escape 3", 21)
airplane = Airplane("Boeing", "747", 68)

# Polymorphic collection
vehicles = [car, bicycle, airplane]

print("\n=== Starting Vehicles ===")
for vehicle in vehicles:
    # Same method call, different implementations
    print(vehicle.start())

print("\n=== Accelerating Vehicles ===")
for vehicle in vehicles:
    # Same method call, different behaviors
    print(vehicle.accelerate(50))
    print(vehicle.accelerate(100))

print("\n=== Getting Vehicle Info ===")
for vehicle in vehicles:
    # Some override get_info, others use parent version
    print(vehicle.get_info())

print("\n=== Vehicle-specific Methods ===")
# Calling methods specific to certain vehicle types
print(bicycle.change_gear(5))  # Only Bicycle has this
print(airplane.climb(5000))    # Only Airplane has this

# =============================================
# EXAMPLE 2: Banking System with Method Overriding
# =============================================
class BankAccount:
    """Base bank account class"""
    
    def __init__(self, account_holder, balance=0):
        self.account_holder = account_holder
        self.balance = balance
    
    def deposit(self, amount):
        """Deposit money"""
        if amount > 0:
            self.balance += amount
            return f"Deposited ${amount:.2f}"
        return "Invalid deposit amount"
    
    def withdraw(self, amount):
        """Withdraw money - generic implementation"""
        if amount > 0 and amount <= self.balance:
            self.balance -= amount
            return f"Withdrew ${amount:.2f}"
        return "Insufficient funds or invalid amount"
    
    def calculate_interest(self):
        """Calculate interest - to be overridden"""
        return 0
    
    def __str__(self):
        return f"{self.account_holder}'s Account: ${self.balance:.2f}"

class SavingsAccount(BankAccount):
    """Savings account with interest"""
    
    INTEREST_RATE = 0.03  # 3% annual
    
    # Overriding calculate_interest
    def calculate_interest(self):
        """Calculate interest for savings account"""
        interest = self.balance * self.INTEREST_RATE
        self.balance += interest
        return f"Interest added: ${interest:.2f}"
    
    # Overriding withdraw with restrictions
    def withdraw(self, amount):
        """Savings account withdrawal with limit"""
        if amount > 1000:
            return "Cannot withdraw more than $1000 from savings"
        return super().withdraw(amount)

class CheckingAccount(BankAccount):
    """Checking account with overdraft"""
    
    OVERDRAFT_LIMIT = 500
    
    # Overriding withdraw to allow overdraft
    def withdraw(self, amount):
        """Checking account with overdraft protection"""
        if amount > 0 and amount <= (self.balance + self.OVERDRAFT_LIMIT):
            self.balance -= amount
            if self.balance < 0:
                return f"Withdrew ${amount:.2f} (Overdraft: ${-self.balance:.2f})"
            return f"Withdrew ${amount:.2f}"
        return f"Cannot withdraw ${amount:.2f}. Exceeds limit"

class FixedDepositAccount(BankAccount):
    """Fixed deposit with penalties for early withdrawal"""
    
    def __init__(self, account_holder, balance, duration_months):
        super().__init__(account_holder, balance)
        self.duration_months = duration_months
        self.months_passed = 0
    
    # Overriding withdraw with penalty
    def withdraw(self, amount):
        """Fixed deposit withdrawal with penalty"""
        if self.months_passed < self.duration_months:
            penalty = amount * 0.05  # 5% penalty
            actual_withdrawal = amount - penalty
            self.balance -= amount
            return f"Withdrew ${amount:.2f} (Penalty: ${penalty:.2f}, Received: ${actual_withdrawal:.2f})"
        return super().withdraw(amount)
    
    def pass_month(self):
        """Simulate month passing"""
        self.months_passed += 1

print("\n" + "="*60)
print("=== Banking System with Method Overriding ===")
print("="*60)

# Create different accounts
savings = SavingsAccount("Alice", 5000)
checking = CheckingAccount("Bob", 2000)
fixed_deposit = FixedDepositAccount("Charlie", 10000, 12)

accounts = [savings, checking, fixed_deposit]

# Demonstrate polymorphic behavior
print("\n=== Initial Account Status ===")
for account in accounts:
    print(account)

print("\n=== Depositing $1000 ===")
for account in accounts:
    print(f"{account.account_holder}: {account.deposit(1000)}")

print("\n=== Withdrawing $1500 (Different Behaviors) ===")
for account in accounts:
    result = account.withdraw(1500)
    print(f"{account.account_holder}: {result}")

print("\n=== Calculating Interest (Savings Only) ===")
for account in accounts:
    if hasattr(account, 'calculate_interest'):
        result = account.calculate_interest()
        print(f"{account.account_holder}: {result}")

print("\n=== Trying Overdraft (Checking Only) ===")
print(f"Bob: {checking.withdraw(3000)}")  # Should use overdraft

print("\n=== Early Withdrawal Penalty (Fixed Deposit) ===")
print(f"Charlie: {fixed_deposit.withdraw(2000)}")  # Should apply penalty

print("\n=== Final Account Status ===")
for account in accounts:
    print(account)

# =============================================
# EXAMPLE 3: Employee System with Method Overriding
# =============================================
class Employee:
    """Base employee class"""
    
    def __init__(self, name, employee_id):
        self.name = name
        self.employee_id = employee_id
    
    def calculate_salary(self, hours_worked):
        """Calculate salary - base implementation"""
        return hours_worked * 20  # $20 per hour default
    
    def get_role_description(self):
        """Get role description"""
        return "Generic Employee"
    
    def work(self):
        """Perform work"""
        return f"{self.name} is working"

class Developer(Employee):
    """Developer employee"""
    
    def calculate_salary(self, hours_worked):
        """Developer-specific salary calculation"""
        base_salary = hours_worked * 50  # $50 per hour
        overtime = max(0, hours_worked - 160) * 75  # $75 overtime
        return base_salary + overtime
    
    def get_role_description(self):
        """Developer role description"""
        return "Software Developer"
    
    def work(self):
        """Developer work"""
        return f"{self.name} is writing code"

class Manager(Employee):
    """Manager employee"""
    
    def __init__(self, name, employee_id, team_size):
        super().__init__(name, employee_id)
        self.team_size = team_size
    
    def calculate_salary(self, hours_worked):
        """Manager-specific salary calculation"""
        base_salary = 5000  # Fixed monthly
        team_bonus = self.team_size * 100
        return base_salary + team_bonus
    
    def get_role_description(self):
        """Manager role description"""
        return f"Manager of {self.team_size} people"
    
    def work(self):
        """Manager work"""
        return f"{self.name} is managing the team"

class Salesperson(Employee):
    """Sales employee"""
    
    def __init__(self, name, employee_id, commission_rate):
        super().__init__(name, employee_id)
        self.commission_rate = commission_rate
        self.sales = 0
    
    def calculate_salary(self, hours_worked):
        """Salesperson salary with commission"""
        base_salary = hours_worked * 30
        commission = self.sales * self.commission_rate
        return base_salary + commission
    
    def make_sale(self, amount):
        """Record a sale"""
        self.sales += amount
        return f"Sale recorded: ${amount}"
    
    def get_role_description(self):
        """Salesperson role description"""
        return f"Salesperson ({self.commission_rate*100}% commission)"
    
    def work(self):
        """Salesperson work"""
        return f"{self.name} is making sales calls"

print("\n" + "="*60)
print("=== Employee System with Method Overriding ===")
print("="*60)

# Create employees
dev = Developer("Alice", "DEV001")
manager = Manager("Bob", "MGR001", 5)
sales = Salesperson("Charlie", "SAL001", 0.1)

employees = [dev, manager, sales]

# Record sales for salesperson
sales.make_sale(5000)
sales.make_sale(3000)

print("\n=== Employee Information ===")
for emp in employees:
    print(f"{emp.name} ({emp.employee_id}): {emp.get_role_description()}")

print("\n=== What Employees Do ===")
for emp in employees:
    print(emp.work())

print("\n=== Salary Calculation (160 hours) ===")
for emp in employees:
    salary = emp.calculate_salary(160)
    print(f"{emp.name}: ${salary:.2f}")

print("\n=== Special Methods ===")
# Salesperson-specific method
if hasattr(sales, 'make_sale'):
    print(sales.make_sale(2000))
    print(f"Updated salary: ${sales.calculate_salary(160):.2f}")
Super() Function: Use super() to call the parent class method from the overridden method in the child class. This allows you to extend parent functionality rather than completely replace it.

Operator Overloading

Operator overloading allows the same operator to have different meanings based on the context. Python uses special methods (dunder methods) that begin and end with double underscores to implement operator overloading.

Operator Method Purpose Example
+ __add__(self, other) Addition obj1 + obj2
- __sub__(self, other) Subtraction obj1 - obj2
* __mul__(self, other) Multiplication obj1 * obj2
/ __truediv__(self, other) Division obj1 / obj2
== __eq__(self, other) Equality obj1 == obj2
< __lt__(self, other) Less than obj1 < obj2
str() __str__(self) String representation print(obj)
len() __len__(self) Length len(obj)
operator_overloading.py
# =============================================
# EXAMPLE 1: Vector Math with Operator Overloading
# =============================================
class Vector:
    """Vector class demonstrating operator overloading"""
    
    def __init__(self, x, y, z=0):
        self.x = x
        self.y = y
        self.z = z
    
    # Addition: vector + vector
    def __add__(self, other):
        if isinstance(other, Vector):
            return Vector(self.x + other.x, 
                         self.y + other.y, 
                         self.z + other.z)
        raise TypeError("Can only add Vector to Vector")
    
    # Subtraction: vector - vector
    def __sub__(self, other):
        if isinstance(other, Vector):
            return Vector(self.x - other.x,
                         self.y - other.y,
                         self.z - other.z)
        raise TypeError("Can only subtract Vector from Vector")
    
    # Multiplication: vector * scalar OR scalar * vector
    def __mul__(self, other):
        if isinstance(other, (int, float)):
            return Vector(self.x * other,
                         self.y * other,
                         self.z * other)
        raise TypeError("Can only multiply Vector by scalar")
    
    # Reverse multiplication: scalar * vector
    def __rmul__(self, other):
        return self.__mul__(other)
    
    # Division: vector / scalar
    def __truediv__(self, other):
        if isinstance(other, (int, float)):
            if other == 0:
                raise ZeroDivisionError("Cannot divide vector by zero")
            return Vector(self.x / other,
                         self.y / other,
                         self.z / other)
        raise TypeError("Can only divide Vector by scalar")
    
    # Dot product: vector @ vector
    def __matmul__(self, other):
        if isinstance(other, Vector):
            return (self.x * other.x + 
                   self.y * other.y + 
                   self.z * other.z)
        raise TypeError("Can only compute dot product with Vector")
    
    # Equality: vector == vector
    def __eq__(self, other):
        if isinstance(other, Vector):
            return (self.x == other.x and 
                   self.y == other.y and 
                   self.z == other.z)
        return False
    
    # Less than: vector < vector (compare magnitude)
    def __lt__(self, other):
        if isinstance(other, Vector):
            return self.magnitude() < other.magnitude()
        raise TypeError("Can only compare Vector with Vector")
    
    # Greater than: vector > vector
    def __gt__(self, other):
        if isinstance(other, Vector):
            return self.magnitude() > other.magnitude()
        raise TypeError("Can only compare Vector with Vector")
    
    # String representation: str(vector)
    def __str__(self):
        return f"Vector({self.x}, {self.y}, {self.z})"
    
    # Representation: repr(vector)
    def __repr__(self):
        return f"Vector({self.x}, {self.y}, {self.z})"
    
    # Length: len(vector) - returns dimension
    def __len__(self):
        return 3  # 3D vector
    
    # Contains: value in vector
    def __contains__(self, item):
        return item in (self.x, self.y, self.z)
    
    # Get item: vector[index]
    def __getitem__(self, index):
        if index == 0:
            return self.x
        elif index == 1:
            return self.y
        elif index == 2:
            return self.z
        raise IndexError("Vector index out of range")
    
    # Set item: vector[index] = value
    def __setitem__(self, index, value):
        if index == 0:
            self.x = value
        elif index == 1:
            self.y = value
        elif index == 2:
            self.z = value
        else:
            raise IndexError("Vector index out of range")
    
    # Negation: -vector
    def __neg__(self):
        return Vector(-self.x, -self.y, -self.z)
    
    # Absolute value: abs(vector) - returns magnitude
    def __abs__(self):
        return self.magnitude()
    
    # Helper method
    def magnitude(self):
        """Calculate vector magnitude"""
        import math
        return math.sqrt(self.x**2 + self.y**2 + self.z**2)
    
    def normalize(self):
        """Return normalized vector"""
        mag = self.magnitude()
        if mag == 0:
            return Vector(0, 0, 0)
        return self / mag

print("=== Vector Operator Overloading ===")
v1 = Vector(1, 2, 3)
v2 = Vector(4, 5, 6)

print(f"v1 = {v1}")
print(f"v2 = {v2}")

print(f"\n=== Arithmetic Operations ===")
print(f"v1 + v2 = {v1 + v2}")
print(f"v2 - v1 = {v2 - v1}")
print(f"v1 * 2 = {v1 * 2}")
print(f"3 * v1 = {3 * v1}")  # Uses __rmul__
print(f"v1 / 2 = {v1 / 2}")
print(f"-v1 = {-v1}")
print(f"abs(v1) = {abs(v1):.2f}")

print(f"\n=== Comparison Operations ===")
print(f"v1 == v2: {v1 == v2}")
print(f"v1 < v2: {v1 < v2}")
print(f"v1 > v2: {v1 > v2}")

print(f"\n=== Dot Product ===")
print(f"v1 @ v2 = {v1 @ v2}")

print(f"\n=== Other Operations ===")
print(f"len(v1) = {len(v1)}")
print(f"2 in v1: {2 in v1}")
print(f"v1[0] = {v1[0]}")
print(f"str(v1) = {str(v1)}")
print(f"repr(v1) = {repr(v1)}")

print(f"\n=== Vector Methods ===")
print(f"v1.magnitude() = {v1.magnitude():.2f}")
print(f"v1.normalize() = {v1.normalize()}")

# =============================================
# EXAMPLE 2: Complex Numbers with Operator Overloading
# =============================================
class ComplexNumber:
    """Complex number with operator overloading"""
    
    def __init__(self, real, imag):
        self.real = real
        self.imag = imag
    
    def __add__(self, other):
        if isinstance(other, ComplexNumber):
            return ComplexNumber(self.real + other.real,
                               self.imag + other.imag)
        elif isinstance(other, (int, float)):
            return ComplexNumber(self.real + other, self.imag)
        raise TypeError("Unsupported operand type")
    
    def __sub__(self, other):
        if isinstance(other, ComplexNumber):
            return ComplexNumber(self.real - other.real,
                               self.imag - other.imag)
        elif isinstance(other, (int, float)):
            return ComplexNumber(self.real - other, self.imag)
        raise TypeError("Unsupported operand type")
    
    def __mul__(self, other):
        if isinstance(other, ComplexNumber):
            # (a+bi)(c+di) = (ac-bd) + (ad+bc)i
            real = self.real * other.real - self.imag * other.imag
            imag = self.real * other.imag + self.imag * other.real
            return ComplexNumber(real, imag)
        elif isinstance(other, (int, float)):
            return ComplexNumber(self.real * other, self.imag * other)
        raise TypeError("Unsupported operand type")
    
    def __truediv__(self, other):
        if isinstance(other, ComplexNumber):
            # (a+bi)/(c+di) = ((ac+bd)/(c²+d²)) + ((bc-ad)/(c²+d²))i
            denominator = other.real**2 + other.imag**2
            if denominator == 0:
                raise ZeroDivisionError("Division by zero complex number")
            real = (self.real * other.real + self.imag * other.imag) / denominator
            imag = (self.imag * other.real - self.real * other.imag) / denominator
            return ComplexNumber(real, imag)
        elif isinstance(other, (int, float)):
            if other == 0:
                raise ZeroDivisionError("Division by zero")
            return ComplexNumber(self.real / other, self.imag / other)
        raise TypeError("Unsupported operand type")
    
    def __eq__(self, other):
        if isinstance(other, ComplexNumber):
            return self.real == other.real and self.imag == other.imag
        return False
    
    def __str__(self):
        if self.imag >= 0:
            return f"{self.real} + {self.imag}i"
        else:
            return f"{self.real} - {-self.imag}i"
    
    def __repr__(self):
        return f"ComplexNumber({self.real}, {self.imag})"
    
    def conjugate(self):
        """Return complex conjugate"""
        return ComplexNumber(self.real, -self.imag)
    
    def magnitude(self):
        """Return magnitude"""
        import math
        return math.sqrt(self.real**2 + self.imag**2)

print("\n" + "="*60)
print("=== Complex Number Operator Overloading ===")
print("="*60)

c1 = ComplexNumber(3, 4)
c2 = ComplexNumber(1, 2)

print(f"c1 = {c1}")
print(f"c2 = {c2}")

print(f"\nc1 + c2 = {c1 + c2}")
print(f"c1 - c2 = {c1 - c2}")
print(f"c1 * c2 = {c1 * c2}")
print(f"c1 / c2 = {c1 / c2}")
print(f"c1 == c2: {c1 == c2}")
print(f"c1.conjugate() = {c1.conjugate()}")
print(f"c1.magnitude() = {c1.magnitude():.2f}")

# =============================================
# EXAMPLE 3: Matrix Operator Overloading
# =============================================
class Matrix:
    """Simple matrix class with operator overloading"""
    
    def __init__(self, data):
        if not all(len(row) == len(data[0]) for row in data):
            raise ValueError("All rows must have same length")
        self.data = data
        self.rows = len(data)
        self.cols = len(data[0])
    
    def __add__(self, other):
        if not isinstance(other, Matrix):
            raise TypeError("Can only add Matrix to Matrix")
        if self.rows != other.rows or self.cols != other.cols:
            raise ValueError("Matrices must have same dimensions")
        
        result = []
        for i in range(self.rows):
            row = []
            for j in range(self.cols):
                row.append(self.data[i][j] + other.data[i][j])
            result.append(row)
        return Matrix(result)
    
    def __sub__(self, other):
        if not isinstance(other, Matrix):
            raise TypeError("Can only subtract Matrix from Matrix")
        if self.rows != other.rows or self.cols != other.cols:
            raise ValueError("Matrices must have same dimensions")
        
        result = []
        for i in range(self.rows):
            row = []
            for j in range(self.cols):
                row.append(self.data[i][j] - other.data[i][j])
            result.append(row)
        return Matrix(result)
    
    def __mul__(self, other):
        if isinstance(other, (int, float)):
            # Scalar multiplication
            result = []
            for i in range(self.rows):
                row = []
                for j in range(self.cols):
                    row.append(self.data[i][j] * other)
                result.append(row)
            return Matrix(result)
        elif isinstance(other, Matrix):
            # Matrix multiplication
            if self.cols != other.rows:
                raise ValueError("Number of columns in first matrix must equal number of rows in second")
            
            result = []
            for i in range(self.rows):
                row = []
                for j in range(other.cols):
                    total = 0
                    for k in range(self.cols):
                        total += self.data[i][k] * other.data[k][j]
                    row.append(total)
                result.append(row)
            return Matrix(result)
        raise TypeError("Unsupported operand type")
    
    def __rmul__(self, other):
        # Scalar multiplication from left
        return self.__mul__(other)
    
    def __matmul__(self, other):
        # Matrix multiplication using @ operator
        return self.__mul__(other)
    
    def __str__(self):
        rows = []
        for row in self.data:
            rows.append("  ".join(f"{val:6.1f}" for val in row))
        return "[\n" + "\n".join(rows) + "\n]"
    
    def __repr__(self):
        return f"Matrix({self.data})"
    
    def transpose(self):
        """Return transposed matrix"""
        result = []
        for j in range(self.cols):
            row = []
            for i in range(self.rows):
                row.append(self.data[i][j])
            result.append(row)
        return Matrix(result)
    
    def __eq__(self, other):
        if not isinstance(other, Matrix):
            return False
        if self.rows != other.rows or self.cols != other.cols:
            return False
        for i in range(self.rows):
            for j in range(self.cols):
                if self.data[i][j] != other.data[i][j]:
                    return False
        return True

print("\n" + "="*60)
print("=== Matrix Operator Overloading ===")
print("="*60)

m1 = Matrix([[1, 2], [3, 4]])
m2 = Matrix([[5, 6], [7, 8]])

print(f"m1 = \n{m1}")
print(f"\nm2 = \n{m2}")

print(f"\nm1 + m2 = \n{m1 + m2}")
print(f"\nm1 - m2 = \n{m1 - m2}")
print(f"\nm1 * 2 = \n{m1 * 2}")
print(f"\nm1 * m2 = \n{m1 * m2}")
print(f"\nm1 @ m2 = \n{m1 @ m2}")  # Same as multiplication
print(f"\nm1.transpose() = \n{m1.transpose()}")
print(f"\nm1 == m2: {m1 == m2}")

Duck Typing

Duck typing is a programming concept where the type or class of an object is determined by its behavior (methods and properties) rather than its inheritance. "If it walks like a duck and quacks like a duck, it must be a duck."

Duck Typing Principle

In Python, we don't care about what an object is (its type/class), we care about what an object does (its methods and behavior). If an object has the required methods, it can be used regardless of its actual type.

duck_typing.py
# =============================================
# EXAMPLE 1: Classic Duck Typing Example
# =============================================
class Duck:
    def quack(self):
        return "Quack!"
    
    def fly(self):
        return "Flying high!"
    
    def walk(self):
        return "Waddling like a duck"

class Person:
    def quack(self):
        return "I'm quacking like a duck!"
    
    def fly(self):
        return "I'm flapping my arms!"
    
    def walk(self):
        return "Walking normally"

class Dog:
    def bark(self):
        return "Woof!"
    
    def run(self):
        return "Running fast"
    
    # No quack, fly, or walk methods

class ToyDuck:
    def quack(self):
        return "Squeak squeak!"
    
    # No fly or walk methods

def duck_test(obj):
    """Duck typing test - if it quacks like a duck..."""
    try:
        print(f"Testing {obj.__class__.__name__}:")
        print(f"  Can quack: {obj.quack()}")
        print(f"  Can fly: {obj.fly()}")
        print(f"  Can walk: {obj.walk()}")
        return True
    except AttributeError as e:
        print(f"  Not a duck! Missing: {e}")
        return False

print("=== Classic Duck Typing ===")
duck = Duck()
person = Person()
dog = Dog()
toy = ToyDuck()

objects = [duck, person, dog, toy]

for obj in objects:
    is_duck = duck_test(obj)
    print(f"  Is it a duck? {'Yes!' if is_duck else 'No'}\n")

# =============================================
# EXAMPLE 2: File-like Objects with Duck Typing
# =============================================
class StringIO:
    """String-based file-like object"""
    def __init__(self, initial_string=""):
        self.content = initial_string
        self.position = 0
    
    def read(self, size=-1):
        """Read from string"""
        if size == -1:
            result = self.content[self.position:]
            self.position = len(self.content)
        else:
            result = self.content[self.position:self.position + size]
            self.position += len(result)
        return result
    
    def write(self, string):
        """Write to string"""
        self.content += string
        return len(string)
    
    def seek(self, position):
        """Seek to position"""
        if 0 <= position <= len(self.content):
            self.position = position
        else:
            raise ValueError("Invalid position")
    
    def tell(self):
        """Get current position"""
        return self.position
    
    def close(self):
        """Close the stream"""
        self.content = ""
        self.position = 0

class NetworkStream:
    """Network-based file-like object"""
    def __init__(self, server, port):
        self.server = server
        self.port = port
        self.buffer = ""
        self.connected = False
    
    def connect(self):
        """Connect to server"""
        self.connected = True
        self.buffer = f"Connected to {self.server}:{self.port}"
    
    def read(self, size=-1):
        """Read from network"""
        if not self.connected:
            raise ConnectionError("Not connected")
        
        # Simulate network reading
        if "HTTP" in self.buffer:
            response = "HTTP/1.1 200 OK\nContent-Type: text/html\n\nHello World"
        else:
            response = f"Data from {self.server}"
        
        if size == -1:
            return response
        return response[:size]
    
    def write(self, data):
        """Write to network"""
        if not self.connected:
            raise ConnectionError("Not connected")
        self.buffer = data
        return len(data)
    
    def close(self):
        """Close connection"""
        self.connected = False
        self.buffer = ""

def process_file_like_object(file_obj, data_to_write=""):
    """
    Process any file-like object using duck typing
    Doesn't care about actual type, only about methods
    """
    print(f"\nProcessing {file_obj.__class__.__name__}:")
    
    # Write if object supports it
    if hasattr(file_obj, 'write'):
        bytes_written = file_obj.write(data_to_write)
        print(f"  Written {bytes_written} bytes")
    
    # Read if object supports it
    if hasattr(file_obj, 'read'):
        content = file_obj.read()
        print(f"  Read content: {content[:50]}...")
    
    # Close if object supports it
    if hasattr(file_obj, 'close'):
        file_obj.close()
        print(f"  Closed successfully")

print("\n" + "="*60)
print("=== File-like Objects with Duck Typing ===")
print("="*60)

# Real file object
import io
real_file = io.StringIO("Initial content")

# Our custom file-like objects
string_io = StringIO("Hello StringIO")
network_stream = NetworkStream("example.com", 80)
network_stream.connect()

# Process all file-like objects the same way
file_like_objects = [real_file, string_io, network_stream]

for file_obj in file_like_objects:
    process_file_like_object(file_obj, "Additional data")

# =============================================
# EXAMPLE 3: Payment Processors with Duck Typing
# =============================================
class CreditCardProcessor:
    """Credit card payment processor"""
    
    def __init__(self, card_number, expiry_date, cvv):
        self.card_number = card_number
        self.expiry_date = expiry_date
        self.cvv = cvv
    
    def process_payment(self, amount):
        """Process credit card payment"""
        # Simulate credit card processing
        print(f"  Processing ${amount} via Credit Card")
        print(f"  Card: **** **** **** {self.card_number[-4:]}")
        print(f"  Expiry: {self.expiry_date}")
        return {
            'success': True,
            'transaction_id': f"CC_{abs(hash(self.card_number))}",
            'amount': amount,
            'method': 'Credit Card'
        }
    
    def refund(self, transaction_id, amount):
        """Process refund"""
        print(f"  Refunding ${amount} for transaction {transaction_id}")
        return True

class PayPalProcessor:
    """PayPal payment processor"""
    
    def __init__(self, email):
        self.email = email
        self.logged_in = False
    
    def login(self, password):
        """Login to PayPal"""
        self.logged_in = True
        return True
    
    def process_payment(self, amount):
        """Process PayPal payment"""
        if not self.logged_in:
            raise Exception("Not logged into PayPal")
        
        print(f"  Processing ${amount} via PayPal")
        print(f"  Account: {self.email}")
        return {
            'success': True,
            'transaction_id': f"PP_{abs(hash(self.email))}",
            'amount': amount,
            'method': 'PayPal'
        }
    
    def get_balance(self):
        """Get PayPal balance"""
        return 1000.00  # Simulated balance

class BankTransferProcessor:
    """Bank transfer payment processor"""
    
    def __init__(self, account_number, routing_number):
        self.account_number = account_number
        self.routing_number = routing_number
    
    def process_payment(self, amount):
        """Process bank transfer"""
        print(f"  Processing ${amount} via Bank Transfer")
        print(f"  Account: ****{self.account_number[-4:]}")
        print(f"  Routing: {self.routing_number}")
        return {
            'success': True,
            'transaction_id': f"BT_{abs(hash(self.account_number))}",
            'amount': amount,
            'method': 'Bank Transfer'
        }
    
    def get_account_details(self):
        """Get account details"""
        return {
            'account_number': self.account_number,
            'routing_number': self.routing_number
        }

class ShoppingCart:
    """Shopping cart that accepts any payment processor"""
    
    def __init__(self):
        self.items = []
        self.total = 0
    
    def add_item(self, item, price):
        """Add item to cart"""
        self.items.append((item, price))
        self.total += price
        print(f"Added {item} for ${price}")
    
    def checkout(self, payment_processor):
        """
        Checkout using any payment processor
        Duck typing: only needs process_payment() method
        """
        print(f"\nChecking out ${self.total:.2f}")
        
        # Don't check type, just try to use it
        try:
            # This is duck typing in action!
            result = payment_processor.process_payment(self.total)
            
            if result.get('success'):
                print(f"✅ Payment successful!")
                print(f"   Transaction ID: {result['transaction_id']}")
                print(f"   Method: {result['method']}")
                self.items = []
                self.total = 0
                return True
            else:
                print(f"❌ Payment failed")
                return False
                
        except AttributeError:
            print(f"❌ Error: Payment processor must have process_payment() method")
            return False
        except Exception as e:
            print(f"❌ Payment error: {e}")
            return False

print("\n" + "="*60)
print("=== Payment Processors with Duck Typing ===")
print("="*60)

# Create shopping cart
cart = ShoppingCart()
cart.add_item("Python Book", 49.99)
cart.add_item("Coffee Mug", 19.99)
cart.add_item("T-shirt", 24.99)

# Different payment processors
credit_card = CreditCardProcessor("4111111111111111", "12/25", "123")
paypal = PayPalProcessor("user@example.com")
paypal.login("password123")
bank_transfer = BankTransferProcessor("123456789", "021000021")

# Try all payment processors (duck typing!)
payment_methods = [
    ("Credit Card", credit_card),
    ("PayPal", paypal),
    ("Bank Transfer", bank_transfer)
]

for method_name, processor in payment_methods:
    print(f"\n--- Trying {method_name} ---")
    cart.checkout(processor)

# =============================================
# EXAMPLE 4: Iterator Protocol with Duck Typing
# =============================================
class Countdown:
    """Custom countdown iterator"""
    def __init__(self, start):
        self.current = start
    
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value

class Fibonacci:
    """Fibonacci sequence generator"""
    def __init__(self, limit):
        self.limit = limit
        self.a, self.b = 0, 1
        self.count = 0
    
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.count >= self.limit:
            raise StopIteration
        
        if self.count == 0:
            result = self.a
        elif self.count == 1:
            result = self.b
        else:
            result = self.a + self.b
            self.a, self.b = self.b, result
        
        self.count += 1
        return result

class FileLines:
    """File line iterator"""
    def __init__(self, filename):
        self.filename = filename
        self.file = open(filename, 'r')
    
    def __iter__(self):
        return self
    
    def __next__(self):
        line = self.file.readline()
        if not line:
            self.file.close()
            raise StopIteration
        return line.strip()
    
    def __del__(self):
        if hasattr(self, 'file') and not self.file.closed:
            self.file.close()

def process_iterable(iterable, description):
    """Process any iterable using duck typing"""
    print(f"\n{description}:")
    
    # Duck typing: we don't care about the type,
    # only that it's iterable (has __iter__ and __next__)
    try:
        for item in iterable:
            print(f"  {item}", end=" ")
        print()
    except TypeError:
        print(f"  Error: {description} is not iterable")

print("\n" + "="*60)
print("=== Iterator Protocol with Duck Typing ===")
print("="*60)

# Create various iterables
countdown = Countdown(5)
fibonacci = Fibonacci(10)
range_obj = range(1, 6)
list_obj = ["apple", "banana", "cherry"]
dict_obj = {"a": 1, "b": 2, "c": 3}

# Process all iterables the same way
iterables = [
    (countdown, "Countdown from 5"),
    (fibonacci, "First 10 Fibonacci numbers"),
    (range_obj, "Range 1-5"),
    (list_obj, "List of fruits"),
    (dict_obj, "Dictionary items")
]

for iterable, description in iterables:
    process_iterable(iterable, description)

# =============================================
# DUCK TYPING BENEFITS AND CAVEATS
# =============================================
print("\n" + "="*60)
print("=== Duck Typing: Benefits and Caveats ===")
print("="*60)

print("\n✅ Benefits of Duck Typing:")
print("1. Flexibility: Code works with any object that has required methods")
print("2. Loose Coupling: No dependency on specific classes/interfaces")
print("3. Pythonic: Follows Python's dynamic nature")
print("4. Testability: Easy to create mock objects for testing")
print("5. Extensibility: New types can be added without modifying existing code")

print("\n⚠️ Caveats of Duck Typing:")
print("1. Runtime Errors: Type errors occur at runtime, not compile time")
print("2. Documentation: Must document expected interface clearly")
print("3. Debugging: Can be harder to debug interface mismatches")
print("4. Intent: Less explicit about expected types")

print("\n🎯 When to Use Duck Typing:")
print("• When working with protocols (file-like, iterator-like)")
print("• When you want maximum flexibility")
print("• When creating library/framework code")
print("• When the interface is simple and well-defined")

print("\n🎯 When to Use Explicit Typing:")
print("• For public APIs where interface should be clear")
print("• When type safety is critical")
print("• When working with complex interfaces")
print("• When using type hints for better IDE support")

Real-world Polymorphism Applications

Game Development

Game entities with polymorphic behavior:

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

class Player(GameObject):
    def update(self): 
        # Player-specific update
        pass
    def render(self):
        # Render player sprite
        pass
Graphics System

Drawing shapes polymorphically:

class Shape:
    def draw(self): pass

class Circle(Shape):
    def draw(self):
        # Draw circle algorithm
        return "Drawing circle"
Database ORM

Database operations with polymorphism:

class Database:
    def save(self): pass

class MySQL(Database):
    def save(self):
        # MySQL specific save
        return "Saved to MySQL"
Export System

Export data in different formats:

class Exporter:
    def export(self, data): pass

class CSVExporter(Exporter):
    def export(self, data):
        # Export to CSV
        return "Exported to CSV"

Polymorphism Best Practices

  • Use meaningful method names: Overridden methods should maintain semantic consistency
  • Follow Liskov Substitution Principle: Subclasses should be substitutable for base classes
  • Document interfaces: Clearly document what methods subclasses should implement
  • Use ABC for abstract methods: from abc import ABC, abstractmethod for clarity
  • Be consistent with operator overloading: Follow mathematical conventions
  • Handle edge cases: Consider what happens with invalid operations
  • Use duck typing judiciously: Balance flexibility with clarity
  • Test polymorphic behavior: Ensure all implementations work correctly
  • Avoid deep inheritance hierarchies: Favor composition over deep inheritance
  • Use type hints for clarity: def process(obj: Shape) -> None:
polymorphism_best_practices.py
from abc import ABC, abstractmethod
from typing import List, Union

# =============================================
# GOOD PRACTICE: Using ABC for clear interfaces
# =============================================
class Renderable(ABC):
    """Abstract base class for renderable objects"""
    
    @abstractmethod
    def render(self):
        """Render the object to screen"""
        pass
    
    @abstractmethod
    def get_bounding_box(self):
        """Get object's bounding box"""
        pass

class Circle(Renderable):
    def __init__(self, x, y, radius):
        self.x = x
        self.y = y
        self.radius = radius
    
    def render(self):
        """Render circle"""
        return f"Rendering circle at ({self.x}, {self.y}) with radius {self.radius}"
    
    def get_bounding_box(self):
        """Get circle bounding box"""
        return (self.x - self.radius, self.y - self.radius,
                self.x + self.radius, self.y + self.radius)

# =============================================
# GOOD PRACTICE: Type hints for polymorphic functions
# =============================================
def process_shapes(shapes: List[Renderable]) -> None:
    """Process multiple shapes polymorphically"""
    for shape in shapes:
        print(shape.render())
        print(f"  Bounding box: {shape.get_bounding_box()}")

# =============================================
# GOOD PRACTICE: Consistent operator overloading
# =============================================
class Money:
    """Money class with proper operator overloading"""
    
    def __init__(self, amount: float, currency: str = "USD"):
        if amount < 0:
            raise ValueError("Amount cannot be negative")
        self.amount = amount
        self.currency = currency
    
    def __add__(self, other: 'Money') -> 'Money':
        if not isinstance(other, Money):
            raise TypeError("Can only add Money to Money")
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies")
        return Money(self.amount + other.amount, self.currency)
    
    def __sub__(self, other: 'Money') -> 'Money':
        if not isinstance(other, Money):
            raise TypeError("Can only subtract Money from Money")
        if self.currency != other.currency:
            raise ValueError("Cannot subtract different currencies")
        return Money(self.amount - other.amount, self.currency)
    
    def __mul__(self, factor: Union[int, float]) -> 'Money':
        if not isinstance(factor, (int, float)):
            raise TypeError("Can only multiply by scalar")
        return Money(self.amount * factor, self.currency)
    
    def __rmul__(self, factor: Union[int, float]) -> 'Money':
        return self.__mul__(factor)
    
    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Money):
            return False
        return self.amount == other.amount and self.currency == other.currency
    
    def __str__(self) -> str:
        return f"{self.currency} {self.amount:.2f}"
    
    def __repr__(self) -> str:
        return f"Money({self.amount}, '{self.currency}')"

# =============================================
# GOOD PRACTICE: Duck typing with defensive programming
# =============================================
class DataProcessor:
    """Process data from various sources"""
    
    @staticmethod
    def process(data_source):
        """
        Process data from any source that supports read()
        
        Args:
            data_source: Any object with a read() method
        """
        # Defensive check
        if not hasattr(data_source, 'read'):
            raise TypeError("data_source must have a read() method")
        
        try:
            data = data_source.read()
            # Process data...
            return f"Processed {len(data)} bytes"
        except Exception as e:
            return f"Error processing data: {e}"

# =============================================
# COMPARISON: Good vs Bad Polymorphism
# =============================================
print("=== Good Polymorphism Practices ===")

# Good: Clear interface with ABC
circle = Circle(10, 20, 5)
print(f"Circle: {circle.render()}")

# Good: Proper operator overloading
m1 = Money(100)
m2 = Money(50)
print(f"\nMoney operations:")
print(f"m1 + m2 = {m1 + m2}")
print(f"m1 - m2 = {m1 - m2}")
print(f"m1 * 2 = {m1 * 2}")

# Good: Defensive duck typing
class StringReader:
    def __init__(self, data):
        self.data = data
    
    def read(self):
        return self.data

reader = StringReader("Hello World")
result = DataProcessor.process(reader)
print(f"\nData processing: {result}")

print("\n" + "="*60)
print("=== Polymorphism Anti-patterns to Avoid ===")
print("="*60)

print("\n❌ Anti-pattern 1: Breaking Liskov Substitution")
print("   - Subclass that changes method semantics")
print("   - Overriding method with completely different behavior")

print("\n❌ Anti-pattern 2: Operator overloading with unexpected behavior")
print("   - Overloading + to do subtraction")
print("   - Inconsistent mathematical operations")

print("\n❌ Anti-pattern 3: Fragile duck typing")
print("   - Assuming too much about objects")
print("   - Not handling missing methods gracefully")

print("\n❌ Anti-pattern 4: Deep inheritance hierarchies")
print("   - Class inheriting from grandparent's grandparent")
print("   - Method resolution becomes confusing")

print("\n✅ Follow these principles:")
print("1. Single Responsibility: Each class has one reason to change")
print("2. Open/Closed: Open for extension, closed for modification")
print("3. Liskov Substitution: Subclasses are substitutable for base")
print("4. Interface Segregation: Many specific interfaces")
print("5. Dependency Inversion: Depend on abstractions, not concretions")

Python Polymorphism Key Takeaways

  • Polymorphism means "many forms" - same interface, different implementations
  • Method Overriding: Child classes provide specific implementations of parent methods
  • Operator Overloading: Customize behavior of operators (+, -, *, etc.) using dunder methods
  • Duck Typing: Focus on object behavior rather than type ("if it quacks like a duck...")
  • Use super() to call parent class methods from overridden methods
  • Special methods like __add__, __str__, __len__ enable operator overloading
  • Duck typing enables flexible, loosely-coupled code but requires careful documentation
  • Use Abstract Base Classes (ABC) for clear interface definitions
  • Follow Liskov Substitution Principle: subclasses should be substitutable for parent classes
  • Polymorphism makes code more extensible, maintainable, and reusable
Next Topics: We'll cover Python Inheritance and Advanced OOP Concepts