Polymorphism: a Common Interface, Different Behavior
- Explain what Polymorphism is in simple words and with an example
- Write code that works on objects of different types through a common interface, without checking the type
- Understand why Polymorphism replaces chains of if/elif by type
- Know Duck Typing — Polymorphism without a common inheritance
What will we learn?
- What Polymorphism is.
- A Common Interface: the same method, different behavior for each type.
- Why it replaces the
if type == .... - Duck Typing: Python’s flexible version.
Why do we need this?
Suppose a payment system needs to charge a customer, and the customer can pay by credit card, by PayPal or by bank transfer. Without Polymorphism:
def pay(method, amount):
if method["type"] == "credit":
print("Charging card", method["card"], amount)
elif method["type"] == "paypal":
print("Redirecting", method["email"], "to PayPal for", amount)
elif method["type"] == "bank":
print("Waiting for transfer of", amount, "to account", method["account"])And what happens when you add Apple Pay? You have to add an elif here, and also in the refund function, and also in the monthly report, and also in the fee check… Every function that handles payments contains the same if chain. You forget one, and there is a bug.
In the first OOP lesson this was “problem 4”. Polymorphism is its solution.
Explanation from the basics
What is Polymorphism?
Polymorphism (from Greek: “many forms”) = the ability to work with objects of different types through the same interface, where every object performs the action in its own way.
In simple words: the same call (method.pay(100)) → different behavior, according to the object’s type. The calling code does not need to know which type it is holding.
The remote has an “on” button. You press it in front of a TV, and it turns on. In front of an air conditioner, it starts cooling. In front of a speaker, it starts playing. The same button (an interface), a different action for every device. And you, holding the remote, do not need to know how each device works inside. A new device arrives? If it “understands” the button, the remote works with it without any change.
How it works: a common interface
- In the Parent, define the interface: which methods all the types have (
pay,refund,fee). - Every Child overrides the methods in its own way.
- The code that uses them works against the interface:
method.pay(amount), and not against the types.
We have already seen this! In the describe() of Employee, which called self.monthly_cost() and reached the right version of a manager or an intern.
The main advantage: Open/Closed
With Polymorphism, adding a new type (Apple Pay) = writing one new Class. Not a single line in the existing code that uses payments changes. The system is open to extension and closed to modification, an important principle in software design.
Duck Typing
In Python, Polymorphism does not require inheritance. Python does not check “does the object inherit from PaymentMethod?”. It only tries to call the method. If the method exists, it works.
“If it walks like a duck and quacks like a duck, it’s a duck.”
Duck Typing = “it does not matter what you are, it matters what you know how to do”. We have already used this: len() works on str, list, dict and set, because they all have a “length”. for works on anything you can go over.
And still, inheriting from a common Parent is the recommended way when you design an interface on purpose: it documents what the interface is, and allows shared code.
Syntax
class Base:
def action(self): # the common interface
raise NotImplementedError("subclasses must implement action()")
class TypeA(Base):
def action(self):
return "A does it this way"
class TypeB(Base):
def action(self):
return "B does it another way"
for item in [TypeA(), TypeB()]: # polymorphic code:
print(item.action()) # one call, many behaviors, no if/elifraise NotImplementedError in the Parent = “every Child must implement this method”. If a Child forgot, you get a clear error at the moment of the call.
First code example
The payment system, with Polymorphism. week-3/payments.py:
class PaymentMethod:
def __init__(self, owner):
self.owner = owner
def pay(self, amount):
raise NotImplementedError("subclasses must implement pay()")
def fee(self, amount):
return 0
class CreditCard(PaymentMethod):
def __init__(self, owner, last_digits):
super().__init__(owner)
self.last_digits = last_digits
def pay(self, amount):
return "Charged " + str(amount) + " to card ****" + self.last_digits
def fee(self, amount):
return round(amount * 0.02, 2)
class PayPal(PaymentMethod):
def __init__(self, owner, email):
super().__init__(owner)
self.email = email
def pay(self, amount):
return "Sent " + str(amount) + " via PayPal from " + self.email
class BankTransfer(PaymentMethod):
def pay(self, amount):
return "Waiting for a bank transfer of " + str(amount) + " from " + self.owner
def checkout(method, amount):
total = amount + method.fee(amount)
print(method.pay(total), "| fee:", method.fee(amount))
methods = [CreditCard("Dana", "4321"), PayPal("Avi", "avi@mail.com"), BankTransfer("Noa")]
for method in methods:
checkout(method, 100)Output:
Charged 102.0 to card ****4321 | fee: 2.0
Sent 100 via PayPal from avi@mail.com | fee: 0
Waiting for a bank transfer of 100 from Noa | fee: 0Breaking down the code line by line
| Code | Explanation |
|---|---|
class PaymentMethod | The interface: every payment method must have pay, and has fee (default 0). |
raise NotImplementedError | An “abstract method”: it defines an interface, without an implementation. |
CreditCard.pay, PayPal.pay, BankTransfer.pay | Each one implements pay in its own way. |
CreditCard.fee | An Override: a 2% fee. The others inherit the Parent’s 0. |
def checkout(method, amount): | The polymorphic code: it does not check the type, it contains no if. It only calls method.fee() and method.pay(). |
| The loop | Exactly the same function, three different behaviors. |
Another example
Adding a new type — without touching existing code. And then Duck Typing.
class ApplePay(PaymentMethod):
def __init__(self, owner, device):
super().__init__(owner)
self.device = device
def pay(self, amount):
return "Paid " + str(amount) + " with Apple Pay on " + self.device
def fee(self, amount):
return 1
checkout(ApplePay("Tom", "iPhone 15"), 100)Paid 101 with Apple Pay on iPhone 15 | fee: 1checkout did not change at all. We added a Class, and it works. In the if/elif version we would have had to edit every function that handles payments.
Duck Typing — an object that does not inherit from PaymentMethod:
class GiftCard:
def __init__(self, code):
self.code = code
def pay(self, amount):
return "Redeemed " + str(amount) + " from gift card " + self.code
def fee(self, amount):
return 0
checkout(GiftCard("GIFT-2026"), 100)Redeemed 100 from gift card GIFT-2026 | fee: 0GiftCard does not inherit from PaymentMethod, but it has pay and fee, so checkout works with it. This is Duck Typing. (In a real project it is still better for it to inherit, so that the interface is documented and explicit.)
Another familiar example: notifications
class Notification:
def __init__(self, recipient):
self.recipient = recipient
def send(self, text):
raise NotImplementedError
class EmailNotification(Notification):
def send(self, text):
return "Email to " + self.recipient + ": " + text
class SmsNotification(Notification):
def send(self, text):
return "SMS to " + self.recipient + ": " + text[:20]
def notify_all(channels, text):
for channel in channels:
print(channel.send(text))
notify_all([EmailNotification("dana@mail.com"), SmsNotification("050-1234567")],
"Your order #1042 has shipped and will arrive tomorrow")Email to dana@mail.com: Your order #1042 has shipped and will arrive tomorrow
SMS to 050-1234567: Your order #1042 hasEvery channel has its own rules (SMS is cut to 20 characters), and notify_all does not know and does not care.
Common mistake
Mistake 1: checking the type instead of using Polymorphism.
def checkout(method, amount):
if isinstance(method, CreditCard):
...
elif isinstance(method, PayPal):
...This takes us back exactly to the original problem, only with Classes. If you find yourself writing if isinstance(...) on your own Classes to decide how to perform an action, it is a sign that the action should be a method that every Class implements.
Mistake 2: forgetting to implement an abstract method.
class Cash(PaymentMethod):
pass
checkout(Cash("Rina"), 50)NotImplementedError: subclasses must implement pay()And that is good: the error is clear and points exactly at what is missing. Without the raise in the Parent, you would get a silent, wrong behavior, or None instead of a string.
Mistake 3: an inconsistent interface between the types.
class CreditCard(PaymentMethod):
def pay(self, amount, cvv):
...Now checkout (which calls method.pay(total)) fails only on credit cards. All the implementations must receive the same parameters. Information specific to a type (like a CVV) belongs in the __init__ or in an Attribute of that type.
Mistake 4: thinking that Polymorphism requires inheritance. In Python it does not (Duck Typing). But inheriting from a common Base documents the interface, and prevents mistakes. Prefer it when you design.
Practice
The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.
What you practice on this page: polymorphism — the same call for several types, where each type behaves differently; adding a new type without changing existing code; Duck Typing and NotImplementedError.
By the end of the page you will know how to:
- Replace an
ifchain by type with a shared method that every type implements - Add a new type without touching the code that uses it
- Explain what Duck Typing is
- Force Children to implement a method using
NotImplementedError
Exercise 1: Instead of an if chain
What you build: payment methods at the checkout — without checking in the code what the type of each one is.
The code you replace:
def pay(method, amount):
if method["type"] == "credit":
return "Charging card " + str(amount)
elif method["type"] == "paypal":
return "Redirecting to PayPal " + str(amount)What to write:
- A base Class named
PaymentMethod, withpaythat receives an amount and throwsNotImplementedError. - Two Children:
CreditCardandPayPal. Each one overridespayand returns its own sentence from the output. - A function named
checkoutthat receives a payment method and an amount, and callspay— without anifon the type.
Test code:
for method in [CreditCard(), PayPal()]:
print(checkout(method, 100))The desired output:
Charging card 100
Redirecting to PayPal 100Show solution
class PaymentMethod:
def pay(self, amount):
raise NotImplementedError("subclasses must implement pay()")
class CreditCard(PaymentMethod):
def pay(self, amount):
return "Charging card " + str(amount)
class PayPal(PaymentMethod):
def pay(self, amount):
return "Redirecting to PayPal " + str(amount)
def checkout(method, amount):
return method.pay(amount)
for method in [CreditCard(), PayPal()]:
print(checkout(method, 100))Charging card 100
Redirecting to PayPal 100checkout does not know and does not need to know which kind of payment it is — it only calls pay. Each type implements it differently. That is Polymorphism.
Exercise 2: A new type — without touching checkout
What you build: continuing the checkout — adding a new payment method and a fee.
What to write:
- A new Child named
ApplePay, withpaythat returns its own sentence from the output. - In
PaymentMethod, addfeethat receives an amount and returns 0 — a default for everyone. - In
CreditCard, overridefee: a fee of 2% of the amount. - Update
checkoutso that it also returns the fee, as in the output.
Test code:
for method in [CreditCard(), PayPal(), ApplePay()]:
print(checkout(method, 100))The desired output:
Charging card 100 | fee: 2.0
Redirecting to PayPal 100 | fee: 0
Paid with Apple Pay 100 | fee: 0Show solution
class PaymentMethod:
def pay(self, amount):
raise NotImplementedError("subclasses must implement pay()")
def fee(self, amount):
return 0
class CreditCard(PaymentMethod):
FEE_PERCENT = 2
def pay(self, amount):
return "Charging card " + str(amount)
def fee(self, amount):
return amount * CreditCard.FEE_PERCENT / 100
class PayPal(PaymentMethod):
def pay(self, amount):
return "Redirecting to PayPal " + str(amount)
class ApplePay(PaymentMethod):
def pay(self, amount):
return "Paid with Apple Pay " + str(amount)
def checkout(method, amount):
return method.pay(amount) + " | fee: " + str(method.fee(amount))
for method in [CreditCard(), PayPal(), ApplePay()]:
print(checkout(method, 100))Charging card 100 | fee: 2.0
Redirecting to PayPal 100 | fee: 0
Paid with Apple Pay 100 | fee: 0ApplePay was added without changing a single line in checkout. PayPal and ApplePay inherit the Parent’s fee, and CreditCard overrides it.
Exercise 3: Characters in a game
What you build: a fight between characters in a game — where every kind of character has a different attack power.
What to write:
- A base Class named
Character, with a name and health, andattack_powerthat throwsNotImplementedError. Warrior— its attack power is always 15.Mage— its attack power is 10, plus 10 if it has more than 50 health.- A function named
attackthat receives an attacker and a target: it takes health away from the target according to the attacker’s power, and prints a sentence as in the output.
Test code:
conan = Warrior("Conan", 100)
merlin = Mage("Merlin", 60)
attack(conan, merlin)
attack(merlin, conan)
print(merlin.name, merlin.health)
print(conan.name, conan.health)The desired output:
Conan hits Merlin for 15
Merlin hits Conan for 10
Merlin 45
Conan 90Show solution
class Character:
def __init__(self, name, health):
self.name = name
self.health = health
def attack_power(self):
raise NotImplementedError("subclasses must implement attack_power()")
class Warrior(Character):
def attack_power(self):
return 15
class Mage(Character):
STRONG_FROM = 50
def attack_power(self):
if self.health > Mage.STRONG_FROM:
return 20
return 10
def attack(attacker, target):
power = attacker.attack_power()
target.health = target.health - power
print(attacker.name, "hits", target.name, "for", power)
conan = Warrior("Conan", 100)
merlin = Mage("Merlin", 60)
attack(conan, merlin)
attack(merlin, conan)
print(merlin.name, merlin.health)
print(conan.name, conan.health)Conan hits Merlin for 15
Merlin hits Conan for 10
Merlin 45
Conan 90After Conan attacked, Merlin was left with 45 health — below 50 — so his power dropped to 10. attack knows nothing about each character’s rules.
Exercise 4: Duck Typing
What you do: read code and explain — without writing code.
The code:
class Duck:
def speak(self):
return "Quack"
class Robot:
def speak(self):
return "Beep"
def make_it_speak(thing):
return thing.speak()The task:
DuckandRobotdo not inherit from the same Parent. Willmake_it_speakwork with both? Why?- Explain in one sentence what Duck Typing is.
Show solution
- Yes.
make_it_speakonly callsspeak()— it does not check the type. Any object that hasspeakfits. - Duck Typing: “if it walks like a duck and quacks like a duck — it is a duck”. In Python what matters is that the method exists, not which Class the object comes from. A common base Class is still useful — to define the interface in one place and give a default behavior — but it is not required.
Exercise 5: NotImplementedError
What you build: shapes that each know how to calculate their own area — and a Parent that requires it.
The code:
class Shape:
def area(self):
raise NotImplementedError("subclasses must implement area()")
class Square(Shape):
def __init__(self, side):
self.side = sideThe task:
- What will happen when you call the area of
Square(3)? Why is that actually good? - Fix
Squareso that it calculates an area. - Add
Circlethat receives a radius, and calculates the area withmath.pi, rounded to two digits. - Print in a loop the area of a square with side 3 and a circle with radius 3.
The desired output:
9
28.27Show solution
- A
NotImplementedError: subclasses must implement area()will be thrown. That is good: the mistake is discovered immediately and clearly — whoever wroteSquareforgot to implementarea. Without it, a wrong result would come out silently.
import math
class Shape:
def area(self):
raise NotImplementedError("subclasses must implement area()")
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return round(math.pi * self.radius ** 2, 2)
for shape in [Square(3), Circle(3)]:
print(shape.area())9
28.27Summary
- Polymorphism = the same call, different behavior according to the object’s type. The calling code does not need to know the type.
- Build a common interface in the Parent (with
raise NotImplementedErrorfor required methods), and every Child implements it in its own way. - It replaces chains of
if isinstance/if type ==. A new type = a new Class, without changing existing code. - A Parent’s method that calls
self.x()reaches the Child’s version (Template Method). - Duck Typing: in Python it is enough that the object has the method. A common inheritance is still recommended as documentation.
- All the implementations receive the same parameters.