Same call, different behavior
Polymorphism sounds academic. The idea is not. You are using it every time you call len() on different things:
1print(len("hello")) # 5 -- counts characters2print(len([1, 2, 3])) # 3 -- counts elements3print(len({"a": 1})) # 1 -- counts keys
The same function call (len(x)) does something different depending on what x is. You do not write string_len() and list_len() separately. One name, behavior determined by the object.
The word comes from Greek: poly (many) morphism (forms). The same interface, many implementations. The caller does not need to know or care which implementation is running.
1class Dog:2 def speak(self):3 return "Woof!"45class Cat:6 def speak(self):7 return "Meow."89class Duck:10 def speak(self):11 return "Quack."1213# This function works with any object that has .speak()14def make_noise(animal):15 print(animal.speak())1617make_noise(Dog()) # Woof!18make_noise(Cat()) # Meow.19make_noise(Duck()) # Quack.
make_noise() never asks what type it received. It just calls speak(). The right thing happens because each class knows what speak() means for it. That is polymorphism.
Duck typing: Python's approach
In Java, for polymorphism to work, your classes must share a common parent or implement a declared interface. Python does not require that. It asks a simpler question: does this object have the method I want to call?
The name comes from a saying: "If it walks like a duck and quacks like a duck, treat it as a duck." Python does not care what class an object belongs to. It cares whether the object can do the thing you are asking it to do.
1class Dog:2 def speak(self):3 return "Woof!"45class Robot:6 def speak(self):7 return "Bzzzt... hello, human."89class TrafficLight:10 def speak(self):11 return "Please stop."1213def make_noise(thing):14 print(thing.speak())1516# None of these share a parent class or interface17# Python only cares that they all have .speak()18make_noise(Dog())19make_noise(Robot())20make_noise(TrafficLight())
A Dog, a Robot, and a TrafficLight have nothing in common except a speak() method. That is all make_noise() needs.
If the object does not have the method, you get an AttributeError at the point of the call. Python does not check ahead of time. This is the trade-off: you get flexibility, you lose early detection. Abstract base classes (covered next) can give you earlier errors when you need them.
Duck typing is why Python code is often more flexible than equivalent Java or C++ code. You can write a function that accepts any object that satisfies an informal interface, without declaring anything upfront.
Method overriding
When a subclass defines a method that already exists on the parent, the subclass version replaces the parent's version for instances of that subclass. This is method overriding.
1class Shape:2 def area(self):3 return 045 def describe(self):6 return f"I am a {type(self).__name__} with area {self.area():.2f}"78class Circle(Shape):9 def __init__(self, radius):10 self.radius = radius1112 def area(self): # overrides Shape.area13 import math14 return math.pi * self.radius ** 21516class Rectangle(Shape):17 def __init__(self, w, h):18 self.w, self.h = w, h1920 def area(self): # overrides Shape.area21 return self.w * self.h2223shapes = [Circle(5), Rectangle(3, 4)]24for s in shapes:25 print(s.describe()) # describe() is inherited, area() is overridden
Notice describe() is defined once on Shape and calls self.area(). When called on a Circle, self.area() dispatches to Circle.area(), not Shape.area(). The parent's method benefits from the child's override. This is called dynamic dispatch, and it is how polymorphism works in practice.
isinstance checks in business logic are a warning sign
isinstance() has legitimate uses: validating input at function boundaries, checking types before an operation that is genuinely type-specific. But when you see it in the middle of business logic, it is almost always a sign that polymorphism should be doing that work instead.
1# Wrong: type-checking in business logic2def process_payment(payment):3 if isinstance(payment, CreditCard):4 charge_credit_card(payment)5 elif isinstance(payment, BankTransfer):6 initiate_transfer(payment)7 elif isinstance(payment, Crypto):8 broadcast_transaction(payment)9 # Every time you add a payment type, you edit this function.10 # It never ends.
Every new payment type means editing process_payment(). The logic that belongs inside each payment type lives outside it. The fix is to put the behavior on the object:
1class CreditCard:2 def process(self):3 charge_credit_card(self)45class BankTransfer:6 def process(self):7 initiate_transfer(self)89class Crypto:10 def process(self):11 broadcast_transaction(self)1213# Now process_payment never changes, regardless of new types14def process_payment(payment):15 payment.process()1617# Adding PayPal? Write PayPal.process(), done.18# process_payment() stays the same.
The second version follows the Open-Closed Principle: the process_payment() function is open for extension (add new payment types) but closed for modification (you never touch it again). The isinstance version is the opposite.
When you find yourself writing if isinstance(x, A): ... elif isinstance(x, B): ..., ask: can each class implement this behavior itself? Usually yes.