Lecture 4: Polymorphism - Function Overriding and Operator Overloading
1. Understanding Polymorphism
Polymorphism allows objects of different classes to be treated as objects of a common superclass. It enables a single interface to represent different underlying forms (data types).
1.1 What is Polymorphism?
Polymorphism means "many forms". In OOP, it refers to the ability of different classes to be used interchangeably through a common interface.
class Bird:
def fly(self):
return "I can fly"
class Penguin(Bird):
def fly(self):
return "I can't fly, but I can swim!"
def let_it_fly(bird):
print(bird.fly())
# Different objects, same method, different behaviors
sparrow = Bird()
penguin = Penguin()
let_it_fly(sparrow) # I can fly
let_it_fly(penguin) # I can't fly, but I can swim!
Key Points:
Polymorphism allows the same method name to be used for different types
It promotes code reusability and flexibility
Makes the code more intuitive and easier to understand
2. Function Overriding
Function overriding occurs when a child class provides a specific implementation of a method that is already defined in its parent class.
2.1 Basic Method Overriding
class Animal:
def make_sound(self):
return "Some generic animal sound"
class Dog(Animal):
def make_sound(self):
return "Woof!"
class Cat(Animal):
def make_sound(self):
return "Meow!"
# Create instances
animal = Animal()
dog = Dog()
cat = Cat()
# Same method, different behaviors
print(animal.make_sound()) # Some generic animal sound
print(dog.make_sound()) # Woof!
print(cat.make_sound()) # Meow!
2.2 Using super() in Overridden Methods
class Vehicle:
def __init__(self, brand, model):
self.brand = brand
self.model = model
def info(self):
return f"{self.brand} {self.model}"
class Car(Vehicle):
def __init__(self, brand, model, doors):
super().__init__(brand, model)
self.doors = doors
def info(self):
# Extend the parent class method
return f"{super().info()} with {self.doors} doors"
# Create instances
vehicle = Vehicle("Generic", "Vehicle")
car = Car("Toyota", "Camry", 4)
print(vehicle.info()) # Generic Vehicle
print(car.info()) # Toyota Camry with 4 doors
Important: When overriding methods, it's often a good practice to call the parent class's method using super() to ensure that the parent's initialization and functionality are preserved.
3. Operator Overloading
Operator overloading allows the same operator to have different meanings according to the context. In Python, this is achieved by defining special methods in a class.
class ShoppingCart:
def __init__(self):
self.items = {}
def add_item(self, item, quantity=1):
self.items[item] = self.items.get(item, 0) + quantity
# Subscript access
def __getitem__(self, item):
return self.items.get(item, 0)
def __setitem__(self, item, quantity):
if quantity <= 0:
if item in self.items:
del self.items[item]
else:
self.items[item] = quantity
def __delitem__(self, item):
if item in self.items:
del self.items[item]
def __contains__(self, item):
return item in self.items
def __len__(self):
return len(self.items)
def __str__(self):
return ", ".join(f"{qty}x {item}" for item, qty in self.items.items()) or "Empty"
# Create a shopping cart
cart = ShoppingCart()
# Add items using method
cart.add_item("Apple", 3)
cart.add_item("Banana", 2)
# Subscript access
print(cart["Apple"]) # 3
# Update quantity
cart["Apple"] = 5
print(cart["Apple"]) # 5
# Check if item exists
print("Banana" in cart) # True
# Delete item
del cart["Banana"]
print("Banana" in cart) # False
Context Managers
class Timer:
def __init__(self, name):
self.name = name
def __enter__(self):
import time
self.start_time = time.time()
print(f"Starting {self.name}...")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
import time
elapsed = time.time() - self.start_time
print(f"{self.name} took {elapsed:.2f} seconds")
# Return False to propagate exceptions, True to suppress them
return False
# Using the context manager
with Timer("long_operation"):
# Simulate a long operation
import time
time.sleep(1.5)
# Output:
# Starting long_operation...
# long_operation took 1.50 seconds
3.2 Common Special Methods for Operator Overloading
Operation
Method
Description
Addition
__add__(self, other)
Implements + operator
Subtraction
__sub__(self, other)
Implements - operator
Multiplication
__mul__(self, other)
Implements * operator
Division
__truediv__(self, other)
Implements / operator
Equality
__eq__(self, other)
Implements == operator
Less than
__lt__(self, other)
Implements < operator
String representation
__str__(self)
Implements str(obj)
Length
__len__(self)
Implements len(obj)
Context manager
__enter__(self), __exit__(self, ...)
Implements with statement
4. Duck Typing and Polymorphism
Python uses "duck typing" - if an object walks like a duck and quacks like a duck, then it must be a duck. This is a form of dynamic typing where the type or class of an object is less important than the methods it defines.
class Duck:
def quack(self):
return "Quack!"
def fly(self):
return "I'm flying!"
class Person:
def quack(self):
return "The person imitates a duck"
def fly(self):
return "The person takes an airplane"
def duck_duck_goose(duck_like_object):
print(duck_like_object.quack())
print(duck_like_object.fly())
# Both objects can be used interchangeably
duck = Duck()
person = Person()
duck_duck_goose(duck)
# Output:
# Quack!
# I'm flying!
duck_duck_goose(person)
# Output:
# The person imitates a duck
# The person takes an airplane
Duck Typing vs. Inheritance:
Duck typing focuses on what methods/properties an object has, not its actual type
Inheritance creates an "is-a" relationship between classes
Duck typing creates a "behaves-like" relationship
Python's dynamic nature makes duck typing very powerful
5. Abstract Base Classes and Polymorphism
Abstract Base Classes (ABCs) can be used to define a common interface that multiple classes must implement, enabling polymorphism through inheritance.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
@abstractmethod
def perimeter(self):
pass
def __str__(self):
return f"{self.__class__.__name__} - Area: {self.area()}, Perimeter: {self.perimeter()}"
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):
import math
return math.pi * self.radius ** 2
def perimeter(self):
import math
return 2 * math.pi * self.radius
# Create a list of shapes
shapes = [Rectangle(4, 5), Circle(3)]
# Process shapes polymorphically
for shape in shapes:
print(shape) # Calls the appropriate area() and perimeter()
# Output:
# Rectangle - Area: 20, Perimeter: 18
# Circle - Area: 28.274333882308138, Perimeter: 18.84955592153876
Best Practice: Use ABCs when you want to define a common interface that multiple classes should implement. This makes your code more maintainable and self-documenting, and it helps catch errors at definition time rather than at runtime.
6. Practice Exercise
Task: Create a Simple Banking System
Implement a simple banking system with different account types that demonstrate polymorphism.
from abc import ABC, abstractmethod
class Account(ABC):
def __init__(self, account_number, balance=0):
self.account_number = account_number
self.balance = balance
@abstractmethod
def deposit(self, amount):
pass
@abstractmethod
def withdraw(self, amount):
pass
def __str__(self):
return f"Account {self.account_number}: Balance = ${self.balance:.2f}"
# Implement the following classes:
# 1. SavingsAccount: Can't withdraw more than balance
# 2. CheckingAccount: Allows overdraft up to a limit
# 3. CreditAccount: Has a credit limit and charges interest on negative balance
# Test your implementation
if __name__ == "__main__":
# accounts = [
# SavingsAccount("SAV001", 1000),
# CheckingAccount("CHK001", 500, overdraft_limit=200),
# CreditAccount("CRD001", 0, credit_limit=1000, interest_rate=0.02)
# ]
#
# for account in accounts:
# print("-" * 40)
# print(f"Initial: {account}")
#
# # Test deposit
# account.deposit(200)
# print(f"After deposit: {account}")
#
# # Test withdraw
# result = account.withdraw(300)
# print(f"Withdraw result: {result}")
# print(f"After withdraw: {account}")
pass
Requirements:
Create an abstract Account class with the specified methods
Implement three account types with different withdrawal behaviors
Each account should have a unique account number and balance
Implement the __str__ method for each class
Test your implementation with different scenarios
7. Course Summary
Key Concepts Covered in Unit-V:
Object-Oriented Programming: Classes, objects, and the four pillars of OOP
Constructors & Special Methods: __init__, __new__, and other magic methods
Inheritance: Single, multiple, multi-level, and hierarchical inheritance
Abstract Base Classes: Creating interfaces with @abstractmethod
Polymorphism: Function overriding and operator overloading
Encapsulation: Public, protected, and private members
Garbage Collection: Reference counting and circular references
Congratulations! You've completed the Python OOP course. You now have a solid understanding of object-oriented programming in Python and are ready to build complex, maintainable applications.