Polymorphism in Python
By the Syncfusion DevTutorials team · Last updated
One name and many behaviours: method overriding, why Python has no overloading, dynamic binding, and operator overloading with magic methods.
Think about the word "play".
Play a song. Play a game. Play a part in a school drama.
One word, three different actions. Nobody is confused, because what happens depends on what you are playing.
Programs do the same. One method name, and what actually happens depends on which object you called it on.
One name, three behaviours
class Student:
def describe(self):
return "I study here"
class Teacher:
def describe(self):
return "I teach here"
class Visitor:
def describe(self):
return "I am visiting"
for person in [Student(), Teacher(), Visitor()]:
print(person.describe())
The loop does not need separate code for each class. It calls describe() on each object, and Python runs that object's method.
Polymorphism means the same method name produces different behaviour depending on the object.
What You Will Learn
In this lesson, you will learn:
- What polymorphism is, and why it removes long chains of type checks
- How a child class replaces a parent's method
- Why Python has no method overloading, and what it uses instead
- What dynamic binding is, and when Python decides which method to run
- How to give your own classes meaning for
+,==and the rest
Polymorphism
What Is Polymorphism?
Polymorphism means one name can have different behaviours. In this lesson, the object receiving a method call determines which behaviour runs.
You have been using it since lesson 1:
Polymorphism you already use
print(len("Asha"))
print(len([78, 85, 60]))
print(len({"maths": 78}))
print(78 + 85)
print("78" + "85")
print([78] + [85])
len() counts characters, items and keys. + adds, joins text and joins lists. One name each time; different work behind it.
Benefits of Polymorphism
Without polymorphism, a function may need a new if branch for every kind of object it handles.
A chain that grows with every new class
class Student:
pass
class Teacher:
pass
class Visitor:
pass
def describe(person):
if isinstance(person, Student):
return "I study here"
elif isinstance(person, Teacher):
return "I teach here"
elif isinstance(person, Visitor):
return "I am visiting"
return "unknown"
for person in [Student(), Teacher(), Visitor()]:
print(describe(person))
Add a Cleaner and you must find every chain like this one and add a branch. Miss one and it silently returns "unknown".
With polymorphism the knowledge lives in each class:
Each class knows its own answer
class Student:
def describe(self):
return "I study here"
class Teacher:
def describe(self):
return "I teach here"
class Visitor:
def describe(self):
return "I am visiting"
class Cleaner:
def describe(self):
return "I keep the place clean"
for person in [Student(), Teacher(), Visitor(), Cleaner()]:
print(person.describe())
Adding Cleaner needed no change to the loop at all.
Runtime Polymorphism
Python decides which method to run when the line runs, not when the file is written. That is why one loop can handle objects it has never seen.
The decision happens as it runs
class Student:
def describe(self):
return "a student"
class Teacher:
def describe(self):
return "a teacher"
def announce(person):
return "This is " + person.describe()
print(announce(Student()))
print(announce(Teacher()))
announce() does not need to know the object's class in advance. It calls describe() on whatever object it receives.
This is often called duck typing: if an object supports the operation you need, you can use it for that task. Python does not check for the method in advance; it tries the call, and raises AttributeError if the method is missing.
No shared parent needed
class Student:
def describe(self):
return "a student"
class Robot:
def describe(self):
return "a robot"
for thing in [Student(), Robot()]:
print(thing.describe())
Student and Robot are unrelated classes, and the loop does not care.
When the method is missing, it fails at that moment:
An object without the method
class Student:
def describe(self):
return "a student"
class Chair:
pass
for thing in [Student(), Chair()]:
print(thing.describe())
AttributeError: 'Chair' object has no attribute 'describe'. The student's description prints first; the error occurs when Python reaches the chair and tries to call a method it does not have.
Polymorphism with Inheritance
A shared parent can provide a common method for its child classes. A child can keep that version or replace it with its own.
Person (parent)
├─ Student
├─ Teacher
└─ Visitor
A parent setting the expectation
class Person:
def __init__(self, name):
self.name = name
def describe(self):
return f"{self.name} is a person"
class Student(Person):
def describe(self):
return f"{self.name} is a student"
class Teacher(Person):
def describe(self):
return f"{self.name} teaches"
class Visitor(Person):
pass
for person in [Student("Asha"), Teacher("Meera"), Visitor("Ravi")]:
print(person.describe())
Visitor defined nothing, so it used the parent's version. The other two replaced it.
Method Overriding
Overriding Parent Methods
Overriding means a child defines a method the parent already has. The child's version is used.
The child's version wins
class Person:
def greet(self):
return "Hello"
class Student(Person):
def greet(self):
return "Hi, I am a student"
print(Person().greet())
print(Student().greet())
Nothing special marks an override. The name alone is enough, which is why a typo creates a new method instead of replacing one:
A typo quietly fails to override
class Person:
def greet(self):
return "Hello from Person"
class Student(Person):
def gret(self):
return "Hi from Student"
asha = Student()
print(asha.greet())
print(asha.gret())
greet() still gives the parent's answer. Nothing warned, because gret is a perfectly valid new method.
Customized Behaviour
Overriding is how each class gives its own answer to a shared question.
One question, four answers
class Person:
def __init__(self, name):
self.name = name
def daily_routine(self):
return f"{self.name} comes to school"
class Student(Person):
def daily_routine(self):
return f"{self.name} attends classes and does homework"
class Teacher(Person):
def daily_routine(self):
return f"{self.name} teaches lessons and marks work"
class Principal(Teacher):
def daily_routine(self):
return f"{self.name} runs the school and meets parents"
for person in [Person("Ravi"), Student("Asha"), Teacher("Meera"), Principal("Ken")]:
print(person.daily_routine())
An override can also extend the parent rather than replace it, using super():
Extending instead of replacing
class Person:
def __init__(self, name):
self.name = name
def details(self):
return f"Name: {self.name}"
class Student(Person):
def __init__(self, name, marks):
super().__init__(name)
self.marks = marks
def details(self):
return super().details() + f", Marks: {self.marks}"
print(Student("Asha", 78).details())
The child added to the parent's sentence instead of rewriting it.
Runtime Method Resolution
When you call a method, Python first looks in the object's class. If it does not find the method there, it checks parent classes in order and uses the first version it finds.
Where Python looks, in order
class Person:
def describe(self):
return "Person's version"
class Teacher(Person):
def describe(self):
return "Teacher's version"
class Principal(Teacher):
pass
print(Principal().describe())
print([cls.__name__ for cls in Principal.__mro__])
Principal has no describe(), so Python used Teacher's and never reached Person's.
You can still reach a specific version deliberately:
Calling a particular version on purpose
class Person:
def describe(self):
return "Person's version"
class Student(Person):
def describe(self):
return "Student's version"
asha = Student()
print(asha.describe())
print(Person.describe(asha))
print(super(Student, asha).describe())
Method Overloading in Python
What Is Method Overloading?
In some languages you can write the same method name several times with different parameters, and the compiler picks the right one. That is overloading.
Python's Approach to Overloading
Python does not have it. The last definition simply replaces the earlier ones.
The second definition wins
class Report:
def show(self, name):
return f"Report for {name}"
def show(self, name, marks):
return f"Report for {name}: {marks}"
report = Report()
print(report.show("Asha", 78))
print(report.show("Asha"))
The two-argument version replaced the one-argument version, so the second call fails with a TypeError. Nothing warned when the class was defined.
Python gets the same results in other ways.
Default Arguments
One method, with values that may be left out.
One method, several ways to call it
class Report:
def show(self, name, marks=None, subject="all subjects"):
if marks is None:
return f"Report for {name}"
return f"Report for {name}: {marks} in {subject}"
report = Report()
print(report.show("Asha"))
print(report.show("Asha", 78))
print(report.show("Asha", 78, "Maths"))
This covers most of what overloading is used for, and it is clearer: one place to read, one place to change.
Variable-Length Arguments
*args and **kwargs handle a genuinely unknown number of values.
Any number of marks
class Report:
def average(self, *marks):
if not marks:
return 0
return round(sum(marks) / len(marks), 1)
report = Report()
print(report.average())
print(report.average(78))
print(report.average(78, 85, 60))
Checking the type by hand is possible when the behaviour really must differ:
Behaving differently by type
class Report:
def add(self, value):
if isinstance(value, (list, tuple)):
return f"Adding {len(value)} marks"
if isinstance(value, int):
return f"Adding one mark: {value}"
return f"Cannot add {type(value).__name__}"
report = Report()
print(report.add(78))
print(report.add([78, 85]))
print(report.add("seventy"))
Use this sparingly. A method that behaves completely differently for different types is usually two methods wearing one name.
| Instead of overloading | Python uses |
|---|---|
| two versions, one with fewer parameters | a default argument |
| versions taking 1, 2 or 3 values | *args |
| versions taking different named options | **kwargs |
| versions taking different types | one method, checking with isinstance() |
Dynamic Binding
What Is Dynamic Binding?
Binding is deciding which piece of code a call refers to. Dynamic binding means the decision is made when the line runs, using the object that is actually there.
Python chooses the method using the object that is actually present when the call runs. A variable can refer to objects from different classes at different times, so the method can change from one call to the next.
Runtime Method Binding
The same line, two different methods
class Student:
def describe(self):
return "a student"
class Teacher:
def describe(self):
return "a teacher"
def announce(person):
return person.describe()
for person in [Student(), Teacher()]:
print(announce(person))
person.describe() is one line in the source and ran two different methods.
Dynamic Dispatch
Choosing a method at run time based on the object's class is called dynamic dispatch.
The object decides, not the variable
class Person:
def describe(self):
return "Person"
class Student(Person):
def describe(self):
return "Student"
thing = Person()
print(thing.describe())
thing = Student()
print(thing.describe())
The same variable gave two answers, because what matters is the object it points at now.
Dynamic Binding Examples
Python's lateness goes further than most languages: a method can be replaced while the program runs.
Changing behaviour at run time
class Student:
def describe(self):
return "a student"
asha = Student()
print(asha.describe())
def new_describe(self):
return "a student, now with a better description"
Student.describe = new_describe
print(asha.describe())
The existing object now uses the new method because Python looks up the method each time the call runs.
This is powerful and easy to misuse. It is worth knowing about mostly to understand why Python cannot catch a misspelt method name until that line runs.
Why a typo waits until the line runs
class Student:
def describe(self):
return "a student"
asha = Student()
print("This line runs fine")
print(asha.descrybe())
Nothing is checked until the call happens. The first line printed; the second failed.
Because Python looks up methods when a call runs, a misspelt name may not be noticed until that line is reached. Tests should cover the different paths through a program so these errors are found sooner.
Operator Overloading
What Is Operator Overloading?
Operator overloading is teaching your own class what +, ==, < and the others should mean for it.
For example, Python cannot add two Marks objects unless the class defines what + should do:
Python will not guess
class Marks:
def __init__(self, value):
self.value = value
print(Marks(78) + Marks(85))
TypeError: unsupported operand type(s) for +. Python does not know what adding two Marks objects should mean, so it raises an error.
Using Magic Methods for Operator Overloading
Each operator maps to a method with two underscores on each side. These are called magic methods or dunder methods, and defining one tells Python what the operator means.
| Operator | Method |
|---|---|
+ | __add__ |
- | __sub__ |
* | __mul__ |
/ | __truediv__ |
== | __eq__ |
!= | __ne__ |
< | __lt__ |
<= | __le__ |
> | __gt__ |
>= | __ge__ |
Overloading Arithmetic Operators
Teaching a class to add
class Marks:
def __init__(self, value):
self.value = value
def __add__(self, other):
return Marks(self.value + other.value)
def __sub__(self, other):
return Marks(self.value - other.value)
def __mul__(self, factor):
return Marks(self.value * factor)
def __repr__(self):
return f"Marks({self.value})"
a = Marks(78)
b = Marks(85)
print(a + b)
print(b - a)
print(a * 2)
a + b is turned by Python into a.__add__(b). Returning a new Marks keeps the original two untouched, which is how + behaves everywhere else.
A more useful example
class MarkSheet:
def __init__(self, marks):
self.marks = marks
def __add__(self, other):
return MarkSheet(self.marks + other.marks)
def __repr__(self):
return f"MarkSheet({self.marks})"
term1 = MarkSheet([78, 85])
term2 = MarkSheet([60, 92])
print(term1 + term2)
print(term1)
Overloading Comparison Operators
By default, == on two objects asks whether they are the same object, so two identical records are not equal:
The default is identity
class Student:
def __init__(self, name, marks):
self.name = name
self.marks = marks
a = Student("Asha", 78)
b = Student("Asha", 78)
print(a == b)
print(a is b)
__eq__ fixes that:
Teaching a class what equal means
class Student:
def __init__(self, name, marks):
self.name = name
self.marks = marks
def __eq__(self, other):
return self.name == other.name and self.marks == other.marks
a = Student("Asha", 78)
b = Student("Asha", 78)
c = Student("Ravi", 85)
print(a == b)
print(a == c)
print(a != c)
!= came free: Python works it out from __eq__ unless you say otherwise.
__lt__ makes a class sortable, because sorting only needs "is this less than that":
One method makes a class sortable
class Student:
def __init__(self, name, marks):
self.name = name
self.marks = marks
def __lt__(self, other):
return self.marks < other.marks
def __repr__(self):
return f"{self.name}({self.marks})"
students = [Student("Asha", 78), Student("Ravi", 85), Student("Meera", 60)]
print(sorted(students))
print(max(students))
print(Student("Asha", 78) < Student("Ravi", 85))
Defining all six comparisons by hand is tedious, so Python offers a shortcut:
Four more comparisons for free
from functools import total_ordering
@total_ordering
class Student:
def __init__(self, name, marks):
self.name = name
self.marks = marks
def __eq__(self, other):
return self.marks == other.marks
def __lt__(self, other):
return self.marks < other.marks
a = Student("Asha", 78)
b = Student("Ravi", 85)
print(a < b, a <= b, a > b, a >= b, a == b, a != b)
@total_ordering works out the other four from __eq__ and __lt__.
A method that cannot handle the other value should say so rather than crash oddly:
Refusing politely
class Marks:
def __init__(self, value):
self.value = value
def __add__(self, other):
if not isinstance(other, Marks):
return NotImplemented
return Marks(self.value + other.value)
def __repr__(self):
return f"Marks({self.value})"
print(Marks(78) + Marks(85))
try:
print(Marks(78) + 5)
except TypeError as error:
print("TypeError:", error)
Returning NotImplemented tells Python that this method cannot handle the other value. Python can then try the other operand's matching method; if neither side can handle the operation, Python raises TypeError.
Overload an operator only when the meaning is obvious to a reader. Adding two mark sheets is clear. Dividing a student by a teacher is not, and a plain method with a name would serve better.
Common mistakes
Each row is one mistake. The middle column is what you will actually see on screen.
| Mistake | What you see | Why it happens | Fix |
|---|---|---|---|
| A typo in an overriding method name | The parent's version still runs | The typo made a new method, not an override | Check the spelling against the parent |
| Two methods with the same name in one class | TypeError about arguments | The second definition replaces the first | Use default arguments or *args |
| Overriding and losing the parent's work | Behaviour silently missing | The child replaced rather than extended | Call super().method() inside |
| Expecting a misspelt method to be caught early | AttributeError deep into a run | Python binds the name when the line runs | Test every branch |
Using + on a class that has no __add__ | TypeError: unsupported operand type(s) | Python will not guess a meaning | Define __add__ |
| Expecting two identical objects to be equal | False | == compares identity by default | Define __eq__ |
An __add__ that changes self | The original object mutates | + is expected to produce a new value | Return a new object |
Raising TypeError in __add__ for an unsupported type | Python cannot try the other operand's method | The method stops the operator lookup early | Return NotImplemented |
Defining __eq__ but needing sorting | TypeError: '<' not supported | Sorting needs __lt__ | Add __lt__, or use @total_ordering |
A chain of isinstance() checks that grows | A branch missed for a new class | The knowledge is outside the classes | Give each class the method |
What to Remember
- Polymorphism means the same method name produces different behaviour depending on the object.
- It replaces long
isinstance()chains: each class carries its own answer, so adding a class changes nothing else. - Python uses duck typing — it checks that the method exists, not what class the object is.
- Overriding is a child defining a method the parent already has. Nothing marks it, so a typo silently fails to override.
super().method()lets an override extend the parent instead of replacing it.- Python has no method overloading. A second definition replaces the first; use defaults,
*argsorisinstance(). - Dynamic binding means the method is chosen when the line runs, which is why a misspelt method is only found then.
- Magic methods teach your class what an operator means:
__add__for+,__eq__for==,__lt__for<. - An operator should return a new object rather than changing the one on the left.
- Return
NotImplementedfor a type you cannot handle, and let Python raise its own clear error.
Next: Abstraction