Python Polymorphism and Duck Typing
1Python Polymorphism: overview
Polymorphism means different kinds of objects respond to the same method call in their own way, and in Python duck typing means an object only needs to have the right method, not the right parent class, to be used.
Before this lesson: Python Inheritance
2Python Polymorphism: diagram
3Python Polymorphism: explained step by step
Polymorphism (Greek for "many forms") means one name, many behaviours. You call thing.send(message) or len(thing) without knowing exactly what thing is, and each kind of object does the right thing in its own way. The code that makes the call stays short and never has to ask "what are you?".
You met a first taste of this in the inheritance lesson. Here you will see the three places where polymorphism shows up in Python: overridden methods in a class family, duck typing with unrelated classes, and the built-in functions and operators themselves.
One call, many behaviours: overriding
When a parent class and its children all define a method with the same name, a call runs the version that belongs to the object it was made on. A child can replace the parent's version completely, or build on it with super():
class Sender:
def __init__(self, recipient):
self.recipient = recipient
def send(self, message):
return f"To {self.recipient}: {message}"
class EmailSender(Sender):
def send(self, message):
return "Email " + super().send(message)
class SmsSender(Sender):
def send(self, message):
return "SMS " + super().send(message[:19] + "...") # texts must be short
class PushSender(Sender):
def send(self, message):
return f"Push banner for {self.recipient}: {message.upper()}"
channels = [EmailSender("asha@example.com"), SmsSender("+1-555-0100"), PushSender("Asha's phone")]
for channel in channels:
print(channel.send("Your parcel arrives tomorrow"))The list holds three different kinds of sender, yet the line channel.send(...) is identical for all of them. Python looks in each object's own class first, so each object gets its own behaviour. Add a fourth channel next month and the loop does not change at all.
Duck typing: "if it quacks like a duck..."
Python never checks an object's class before calling a method. At the moment of the call it only asks whether the object has that method. The saying goes: if it walks like a duck and quacks like a duck, it is a duck. So classes do not need a shared parent to be interchangeable. They just need the same method name and compatible arguments.
import json
class CsvExporter:
def export(self, rows):
return "\n".join(",".join(str(cell) for cell in row) for row in rows)
class JsonExporter:
def export(self, rows):
return json.dumps(rows)
class HtmlExporter:
def export(self, rows):
lines = []
for row in rows:
cells = "".join(f"<td>{cell}</td>" for cell in row)
lines.append(f"<tr>{cells}</tr>")
return "\n".join(lines)
def save_report(exporter, rows):
print(exporter.export(rows))
rows = [("Asha", 91), ("Ben", 78)]
for exporter in [CsvExporter(), JsonExporter(), HtmlExporter()]:
save_report(exporter, rows)None of the three exporters inherits from another, and save_report has no idea which one it received. Hand it anything with an export(rows) method — a class you write next year, a stand-in used in tests, a class from someone else's library — and it works.
Built-in functions and operators are polymorphic too
You have been using polymorphism since your first lessons:
things = ["python", [1, 2, 3], {"a": 1, "b": 2}, (7, 8)]
for thing in things:
print(type(thing).__name__, len(thing))
print(3 + 4)
print("3" + "4")
print([3] + [4])
print(3 * 4, "3" * 4, [3] * 4)len() works on strings, lists, dictionaries and tuples. The + symbol adds numbers, glues strings and joins lists, while * multiplies numbers and repeats strings and lists. Each type supplies its own meaning for the same symbol through the dunder methods from the previous lesson. That is also why your own classes can join in: define __len__ or __add__ and the built-in syntax works on them.
Why not just check the type?
A tempting alternative is a chain of isinstance() checks. This is plain text on purpose, because it shows the style to avoid:
# every new payment class means editing this function
def pay_all(methods, amount):
for method in methods:
if isinstance(method, CreditCard):
print("card:", method.pay(amount))
elif isinstance(method, GiftVoucher):
print("voucher:", method.pay(amount))
# a new BankTransfer class would be silently ignored
# works for today's classes and for every future one
def pay_all(methods, amount):
for method in methods:
print(method.pay(amount))
The chain needs a new branch for each class, and a class you forget is ignored without any error. When the object knows how to do the job, the caller can just ask. Keep isinstance() for cases where the objects genuinely cannot decide for themselves, such as checking input at the edge of your program.
Look before you leap, or just try it
Sometimes you really do not know whether an object supports an operation. Python programmers have two idioms for that:
- LBYL, "look before you leap": check first with
hasattr(obj, "send"). - EAFP, "easier to ask forgiveness than permission": call the method and handle
AttributeErrorif it is missing.
class EmailSender:
def send(self, text):
return f"emailed: {text}"
class Pager:
def send(self, text):
return f"paged: {text}"
class Fax:
pass # no send() method
for device in [EmailSender(), Fax(), Pager()]:
if hasattr(device, "send"):
print(device.send("Server restarted"))
else:
print(type(device).__name__, "cannot send")
for device in [Fax(), Pager()]:
try:
print(device.send("Disk full"))
except AttributeError:
print(type(device).__name__, "has no send()")Python programmers lean towards the second style, but keep the try block small. If send() itself contains a typo that raises AttributeError, a wide try/except reports "no send()" and hides the real bug.
The price of duck typing
Nobody checks the "contract" before the program runs. A misspelt method name or a different parameter list only shows up as an error when that line executes. So keep method names and parameters identical across your classes. When you want Python to enforce the contract up front, abstract base classes (a later lesson) can do that.
4Python Polymorphism: syntax
# 1) Polymorphism through a class family (overriding)
class Base:
def action(self, data): ...
class ChildA(Base):
def action(self, data): ... # same name, its own behaviour
class ChildB(Base):
def action(self, data):
return super().action(data) # or build on the parent's version
# 2) Duck typing: no shared parent needed
class Unrelated:
def action(self, data): ...
for obj in [ChildA(), ChildB(), Unrelated()]:
obj.action(data) # Python only asks: does obj have action()?
# 3) When you are not sure an object has the method
hasattr(obj, "action") # look before you leap
try:
obj.action(data) # or just try it ...
except AttributeError:
... # ... and handle the failure
5Python Polymorphism: run the example
Press Run (or Ctrl + Enter). The output (step 6) appears directly below the code. Change anything and run it again.
class PaymentMethod:
def pay(self, amount):
return f"{amount:.2f} paid"
class CreditCard(PaymentMethod):
def pay(self, amount):
return f"{amount * 1.03:.2f} charged to card"
class GiftVoucher: # no parent class
def __init__(self, balance):
self.balance = balance
def pay(self, amount):
used = min(amount, self.balance)
self.balance -= used
return f"{used:.2f} taken from voucher"
wallet = [PaymentMethod(), CreditCard(), GiftVoucher(30)]
for method in wallet:
print(method.pay(50))
print(isinstance(wallet[2], PaymentMethod))Expected output50.00 paid
51.50 charged to card
30.00 taken from voucher
False
7Python Polymorphism: how the example works
Hover or tap a step to highlight its lines in the example above.
- Line 1-3
PaymentMethodis the parent class. Itspay()method returns a plain payment message. - Line 5-7
CreditCardinherits fromPaymentMethodand overridespay()with the same name and parameters but different behaviour: it adds a 3% fee. - Line 9-11
GiftVoucherhas no parent class and keeps its ownbalance. It is not related to the other two. - Line 13-16It still has a
pay(amount)method with the same shape, so it counts as a valid payment method for duck typing. It takes what the balance allows and updates the balance. - Line 18One list holds three objects of three different classes.
- Line 19-20The loop does not check any types.
method.pay(50)runs the parent's version, then the child's version (50 times 1.03 is 51.50), then the voucher's version (only 30 is available). - Line 21
isinstanceconfirms that the voucher is not aPaymentMethod, and it worked in the loop anyway. Having the method is what mattered.
8Python Polymorphism: common mistakes
Giving the same job different method names
✗ Wrong
class EmailSender:
def send(self, message):
return f"Email: {message}"
class SmsSender:
def send_sms(self, message):
return f"SMS: {message}"
for channel in [EmailSender(), SmsSender()]:
print(channel.send("Hello"))✓ Right
class EmailSender:
def send(self, message):
return f"Email: {message}"
class SmsSender:
def send(self, message):
return f"SMS: {message}"
for channel in [EmailSender(), SmsSender()]:
print(channel.send("Hello"))Polymorphism only works when every class offers the call under the same name. Pick one name for the job (send) and use it in all the classes, whether or not they share a parent.
Methods with the same name but different parameters
✗ Wrong
class EmailSender:
def send(self, message):
return f"Email: {message}"
class PushSender:
def send(self):
return "Push: default text"
for channel in [EmailSender(), PushSender()]:
print(channel.send("Hello"))✓ Right
class EmailSender:
def send(self, message):
return f"Email: {message}"
class PushSender:
def send(self, message):
return f"Push: {message}"
for channel in [EmailSender(), PushSender()]:
print(channel.send("Hello"))The shared name is only half of the contract: every class must also accept the same arguments. Give PushSender.send the message parameter, even if it decides to use it differently.
Handling each class in an isinstance() chain
✗ Wrong
class EmailSender:
def send(self, message):
return f"Email: {message}"
class SmsSender:
def send(self, message):
return f"SMS: {message}"
class PushSender:
def send(self, message):
return f"Push: {message}"
def deliver(channel, message):
if isinstance(channel, EmailSender):
return channel.send(message)
elif isinstance(channel, SmsSender):
return channel.send(message)
print(deliver(PushSender(), "Hello"))✓ Right
class EmailSender:
def send(self, message):
return f"Email: {message}"
class SmsSender:
def send(self, message):
return f"SMS: {message}"
class PushSender:
def send(self, message):
return f"Push: {message}"
def deliver(channel, message):
return channel.send(message)
print(deliver(PushSender(), "Hello"))The chain has to be edited for every new class, and a class you forget is ignored silently. Let the objects do the work: call channel.send(message) directly and delete the chain.
Wrapping too much code in try/except AttributeError
✗ Wrong
class Pager:
def __init__(self, number):
self.number = number
def send(self, message):
return f"Page {self.numbr}: {message}"
try:
print(Pager(42).send("Server down"))
except AttributeError:
print("Pager has no send()")✓ Right
class Pager:
def __init__(self, number):
self.number = number
def send(self, message):
return f"Page {self.number}: {message}"
pager = Pager(42)
send = getattr(pager, "send", None)
if send is None:
print("Pager has no send()")
else:
print(send("Server down"))The except caught an AttributeError raised inside send() and mislabelled it. Look the method up on its own (getattr(obj, "send", None) or hasattr), then call it outside the try, so real bugs still show their traceback. The typo is also fixed in the code below.
9Python Polymorphism: exercises
Write your answer, then press Check. The checks run in your browser.
1. Broadcast to any sender
Write code Easy ✓ CompletedEmailSender is given. Do two things:
- Write a class
SmsSenderwith a methodsend(message)that returns"SMS: <message>". - Write
broadcast(senders, message)that returns a list with the result ofsend(message)for every object insenders, in order. It must work for any object that has asendmethod, even one that is not one of your classes.
class EmailSender:
def send(self, message):
return f"Email: {message}"
# write class SmsSender here
def broadcast(senders, message):
pass
print(broadcast([EmailSender()], "Hello"))[sender.send(message) for sender in senders]. No isinstance needed, because every sender has a send method.class EmailSender:
def send(self, message):
return f"Email: {message}"
class SmsSender:
def send(self, message):
return f"SMS: {message}"
def broadcast(senders, message):
return [sender.send(message) for sender in senders]
print(broadcast([EmailSender(), SmsSender()], "Hello"))
2. Predict the headers
Predict the output Medium ✓ Completedheader() is only written in Report and Memo, but it calls self.title(). Type exactly what the program prints.
class Report:
def title(self):
return "Report"
def header(self):
return f"== {self.title()} =="
class SalesReport(Report):
def title(self):
return "Sales " + super().title()
class Memo:
def header(self):
return "-- memo --"
for doc in [Report(), SalesReport(), Memo()]:
print(doc.header())
print(isinstance(Memo(), Report), hasattr(Memo(), "title"))header(), self is the object the call was made on. For a SalesReport, which title() does self.title() find first? Memo has nothing to do with Report.== Report ==
== Sales Report ==
-- memo --
False False
2 more exercises → 5 more examples
Questions learners ask
What is polymorphism in Python?
Polymorphism means one method call or operator can behave differently depending on the object it is used on. Calling send() on an EmailSender and an SmsSender runs different code, and len() or + give different results for lists, strings and numbers. You write one loop or function and it works with all of those objects.
What is duck typing in Python?
Duck typing means Python cares about what an object can do, not what class it is. If an object has the method you call, with compatible arguments, it works, even when it shares no parent class with the other objects. The name comes from the saying: if it walks like a duck and quacks like a duck, it is a duck.
Do I need inheritance to use polymorphism in Python?
No. Inheritance with method overriding is one way to get polymorphism, but duck typing lets completely unrelated classes be used interchangeably as long as they offer the same method names. Use inheritance when classes share real code or a genuine is-a relationship, and plain duck typing when they only share behaviour.
Further reading
10Next lesson
Up nextPython Multiple Inheritance and MROMultiple inheritance means a class lists more than one parent, such as class Duck(Swimmer, Flyer); Python then follows …Related:
Python Dunder (Magic) MethodsPython InheritancePython Abstract Classes (ABC)
← Python Inheritance · All Object-Oriented Python lessons · Practise in the IDE