Polymorphism in Python
What is Polymorphism?
"Poly" = many, "morph" = forms. Polymorphism means ONE name (a function, method or operator) behaves DIFFERENTLY depending on the object it works with.
Real-Life Analogy
The word "pay". You can pay by card, by UPI, or by cash. The action is the same ("pay"), but HOW it happens is different for each method.
Why is It Useful?
We can write code that works with MANY types without if/else checks:
for method in payment_methods:
method.pay(500) <- the same line works for every type
Adding a new type later needs NO change to this loop. That is a big win in real projects (the "Open/Closed Principle").
Types of Polymorphism in Python
- Built-in polymorphism (len, +, * work on many types)
- Duck typing ("if it walks like a duck and quacks like a duck...")
- Method overriding (runtime polymorphism through inheritance)
- Method overloading (simulated with default args / *args)
- Operator overloading (
__add__,__eq__,__lt__... "dunder" methods)
Built-In Polymorphism
Students already use polymorphism without knowing it. The same function/operator works differently for different types.
print(" len('Python') ->", len("Python")) # counts characters
print(" len([10, 20, 30]) ->", len([10, 20, 30])) # counts items
print(" len({'a': 1, 'b': 2})->", len({"a": 1, "b": 2})) # counts keys
print(" 5 + 3 ->", 5 + 3) # addition
print(" 'Hello ' + 'World' ->", "Hello " + "World") # joining text
print(" [1, 2] + [3] ->", [1, 2] + [3]) # joining lists
print(" 3 * 4 ->", 3 * 4) # multiplication
print(" 'ab' * 3 ->", "ab" * 3) # repetition
Polymorphism with Functions
Different classes with the SAME method name. One function can work with all of them, because it only calls that method. Industry example: generating different types of reports.
class SalesReport:
def generate(self):
return "Sales Report: Q3 revenue Rs.12,00,000"
class InventoryReport:
def generate(self):
return "Inventory Report: 342 items in stock"
class HRReport:
def generate(self):
return "HR Report: 5 new joiners this month"
def print_report(report):
# This function does not care WHICH report it gets.
# It only needs an object with a generate() method.
print(" " + report.generate())
for r in (SalesReport(), InventoryReport(), HRReport()):
print_report(r)
Duck Typing
"If it walks like a duck and quacks like a duck, it is a duck." Python does not check the CLASS of an object; it only checks whether the method EXISTS when it is called. The classes below share NO parent class, yet they work together. Industry example: payment methods at checkout.
class CreditCard:
def pay(self, amount):
print(f" Paid Rs.{amount} by Credit Card (2% fee: Rs.{amount * 0.02:.2f})")
class UPI:
def pay(self, amount):
print(f" Paid Rs.{amount} by UPI (no fee)")
class CashOnDelivery:
def pay(self, amount):
print(f" Rs.{amount} will be collected at delivery")
class GiftCoupon: # does NOT have pay() -> fails at runtime
def redeem(self):
pass
def checkout(payment_method, amount):
payment_method.pay(amount)
for method in (CreditCard(), UPI(), CashOnDelivery()):
checkout(method, 1500)
try:
checkout(GiftCoupon(), 1500)
except AttributeError as err:
print(" ERROR:", err)
# Safer duck typing: check before calling
coupon = GiftCoupon()
if hasattr(coupon, "pay"):
coupon.pay(100)
else:
print(" GiftCoupon cannot pay. Skipped safely using hasattr()")
Method Overriding (Runtime Polymorphism)
A parent defines a method; each child gives its own version. Which version runs is decided at RUNTIME, based on the actual object. Industry example: calculating delivery charges for different couriers.
class Courier:
def __init__(self, weight_kg):
self.weight_kg = weight_kg
def delivery_charge(self):
return 50 # default flat charge
def describe(self):
# describe() is written ONCE in the parent, but calls the
# child's delivery_charge(). This is polymorphism at work.
name = type(self).__name__
print(f" {name:<21} {self.weight_kg}kg -> Rs.{self.delivery_charge()}")
class StandardCourier(Courier):
pass # uses the parent's version
class ExpressCourier(Courier):
def delivery_charge(self):
return 100 + 20 * self.weight_kg
class InternationalCourier(Courier):
def delivery_charge(self):
return 1500 + 300 * self.weight_kg
shipments = [StandardCourier(2), ExpressCourier(2), InternationalCourier(2)]
for s in shipments:
s.describe()
Method Overloading (The Python Way)
Java/C++: many methods with the same name but different parameters. Python: a second method with the same name REPLACES the first. So we get the same effect using default arguments and *args. Industry example: a billing calculator.
class BillingCalculator:
# Overloading by DEFAULT ARGUMENTS
def price(self, base, tax_rate=0.0, discount=0.0):
return round(base * (1 + tax_rate) - discount, 2)
# Overloading by *args (any number of values)
def total(self, *amounts):
return sum(amounts)
calc = BillingCalculator()
print(" price(1000) ->", calc.price(1000))
print(" price(1000, 0.18) ->", calc.price(1000, 0.18))
print(" price(1000, 0.18, 100) ->", calc.price(1000, 0.18, 100))
print(" total(100) ->", calc.total(100))
print(" total(100, 250, 75, 40) ->", calc.total(100, 250, 75, 40))
# Proof that a real "overload" does not work in Python:
class Demo:
def show(self, a):
print("one argument")
def show(self, a, b): # replaces the method above
print(" show(a, b) called with", a, b)
Demo().show(1, 2)
try:
Demo().show(1)
except TypeError as err:
print(" ERROR:", err)
Operator Overloading
Operators like + == < are just method calls behind the scenes:
a + b -> a.__add__(b)
a == b -> a.__eq__(b)
a < b -> a.__lt__(b)
str(a) -> a.__str__()
By writing these "dunder" (double-underscore) methods, our own objects can use normal operators. Industry example: a Money class that refuses to add different currencies.
class Money:
def __init__(self, amount, currency="INR"):
self.amount = amount
self.currency = currency
def __add__(self, other): # money1 + money2
if self.currency != other.currency:
raise ValueError(f"Cannot add {self.currency} and {other.currency}")
return Money(self.amount + other.amount, self.currency)
def __sub__(self, other): # money1 - money2
if self.currency != other.currency:
raise ValueError(f"Cannot subtract {other.currency} from {self.currency}")
return Money(self.amount - other.amount, self.currency)
def __mul__(self, factor): # money * 3
return Money(self.amount * factor, self.currency)
def __eq__(self, other): # money1 == money2
return self.amount == other.amount and self.currency == other.currency
def __lt__(self, other): # money1 < money2
return self.amount < other.amount
def __str__(self): # print(money)
return f"{self.currency} {self.amount:,.2f}"
rent = Money(15000)
food = Money(6000)
print(" rent + food =", rent + food)
print(" rent - food =", rent - food)
print(" food * 3 =", food * 3)
print(" rent == food =", rent == food)
print(" food < rent =", food < rent)
print(" sorted =", [str(m) for m in sorted([rent, food, Money(500)])])
try:
print(rent + Money(100, "USD"))
except ValueError as err:
print(" ERROR:", err)
Make Your Objects Work with Built-In Functions
len(obj) -> obj.__len__()
x in obj -> obj.__contains__(x)
obj[i] -> obj.__getitem__(i) (also makes "for x in obj" work)
Industry example: a music playlist.
class Playlist:
def __init__(self, name, songs):
self.name = name
self.songs = list(songs)
def __len__(self):
return len(self.songs)
def __contains__(self, song):
return song in self.songs
def __getitem__(self, index):
return self.songs[index]
playlist = Playlist("Workout", ["Believer", "Thunder", "Stronger"])
print(" len(playlist) ->", len(playlist))
print(" 'Thunder' in playlist ->", "Thunder" in playlist)
print(" playlist[0] ->", playlist[0])
print(" playlist[-1] ->", playlist[-1])
print(" for loop:", end=" ")
for song in playlist: # works because of __getitem__
print(song, end=" | ")
print()
Industry Example - Discount Engine
An e-commerce checkout applies different discount rules. Each rule is a class with the same apply() method. The checkout loop never changes. To add a new rule, we just write a new class. (This is the "Strategy" design pattern, built on polymorphism.)
class Discount:
label = "No discount"
def apply(self, amount):
return amount
class PercentageDiscount(Discount):
def __init__(self, percent):
self.percent = percent
self.label = f"{percent}% off"
def apply(self, amount):
return amount * (100 - self.percent) / 100
class FlatDiscount(Discount):
def __init__(self, flat, min_order):
self.flat = flat
self.min_order = min_order
self.label = f"Rs.{flat} off above Rs.{min_order}"
def apply(self, amount):
return amount - self.flat if amount >= self.min_order else amount
class FestivalDiscount(PercentageDiscount):
def __init__(self):
super().__init__(25)
self.label = "Festival sale 25% (max Rs.1000)"
def apply(self, amount):
discounted = super().apply(amount)
return max(discounted, amount - 1000) # cap the saving at 1000
cart_value = 5000
rules = [Discount(), PercentageDiscount(10), FlatDiscount(500, 3000), FestivalDiscount()]
for rule in rules:
final = rule.apply(cart_value) # SAME call, DIFFERENT behaviour
print(f" {rule.label:<34} -> pay Rs.{final:,.2f}")
best = min(rules, key=lambda r: r.apply(cart_value))
print(f" Best offer for the customer: {best.label}")
Quick Revision
- Polymorphism = same name, different behaviour.
- Duck typing: Python only cares that the method exists.
- Overriding: a child replaces a parent method (decided at runtime).
- Overloading: Python keeps only the last definition; use defaults/*args.
- Operator overloading: define
__add__,__eq__,__lt__,__str__,__len__... - Polymorphism lets us add new types WITHOUT changing existing code.
Practice Questions
- Create Circle, Rectangle and Triangle with
area(). Print every - Create a Vector(x, y) class that supports +, -, == and
print(). - Create a Cart class that supports len(cart) and "item in cart".
- Add a new BuyOneGetOne discount class without changing the loop