Python OOP part 3: polymorphism, duck typing and operator overloading
Advertisement
Polymorphism is a Greek-derived word meaning "many forms". In programming it means that one name or operation can work with different kinds of objects, each responding in its own way. You already rely on it: len() works on strings, lists and dictionaries, + adds numbers but joins strings, and print() accepts anything. In this third part of the object-oriented series you will see how Python achieves polymorphism through duck typing, method overriding and operator overloading, and why "method overloading" works differently in Python than in languages such as Java. Every output was produced on Python 3.9.
Polymorphism you already use
print(len("hello"), len([1, 2, 3]), len({"a": 1})) # 5 3 1
print(1 + 2, "a" + "b", [1] + [2], (1,) + (2,)) # 3 ab [1, 2] (1, 2)
print(max(3, 7), max("apple", "pear"), max([1, 5, 2])) # 7 pear 5
The same function name, len, or the same operator, +, produces the right result for each type. As a class author, you can give your own classes this behaviour. There are four ways, covered below.
1. Duck typing
Python does not check what type an object has before using it; it only cares whether the object has the method or attribute you call. The saying is: "if it walks like a duck and quacks like a duck, it is a duck."
class Duck:
def speak(self): return "Quack"
class Dog:
def speak(self): return "Woof"
class Robot:
def speak(self): return "Beep"
def make_noise(thing):
return thing.speak()
print([make_noise(x) for x in (Duck(), Dog(), Robot())])
# ['Quack', 'Woof', 'Beep']
make_noise works with any object that has a speak() method, even though Duck, Dog and Robot share no parent class. If you pass something without one, the failure happens at the call:
try:
make_noise(42)
except AttributeError as e:
print(e) # 'int' object has no attribute 'speak'
This is why Python code is so flexible: functions are written against behaviour, not against a class hierarchy. The price is that mistakes show up at run time, so add tests and, where it helps, type hints.
2. Method overriding
When a child class defines a method with the same name as its parent's, the child's version is used for child objects. This is overriding, and it is the classic form of polymorphism in a class hierarchy (see inheritance).
class Shape:
def area(self):
raise NotImplementedError("subclasses must implement area()")
def describe(self):
return f"{type(self).__name__} with area {self.area():.2f}"
class Rectangle(Shape):
def __init__(self, w, h): self.w, self.h = w, h
def area(self): return self.w * self.h
class Circle(Shape):
def __init__(self, r): self.r = r
def area(self): return 3.14159 * self.r ** 2
for s in (Rectangle(3, 4), Circle(1)):
print(s.describe())
Rectangle with area 12.00
Circle with area 3.14
The loop does not care which shape it holds. describe() is written once in Shape, and Python picks the right area() for each object at run time. Adding a Triangle later needs no change to the loop. That is the real value of polymorphism: new behaviour without editing old code.
A child can also extend a parent's method instead of replacing it by calling super():
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)
def describe(self):
return super().describe() + " (a square)"
print(Square(2).describe()) # Square with area 4.00 (a square)
Overriding the constructor
__init__ is overridden the same way. If the child defines its own, it should normally call the parent's with super().__init__(...) so the parent's attributes are created. That is how Square passes its side to Rectangle above.
3. Operator overloading
Every operator is a method with a special "magic" name, written with double underscores (also called dunder methods). When you write a + b, Python calls a.__add__(b). Define the method in your class, and your objects work with the operator.
class Money:
def __init__(self, amount):
self.amount = amount
def __add__(self, other):
return Money(self.amount + other.amount)
def __eq__(self, other):
return self.amount == other.amount
def __lt__(self, other):
return self.amount < other.amount
def __repr__(self):
return f"Money({self.amount})"
a, b = Money(50), Money(30)
print(a + b, a == Money(50), a < b, sorted([a, b]))
# Money(80) True False [Money(30), Money(50)]
Defining __lt__ is enough for sorted(), which only needs "less than". The most useful magic methods:
| Operator or function | Method | Operator or function | Method |
|---|---|---|---|
a + b |
__add__ |
a == b |
__eq__ |
a - b |
__sub__ |
a != b |
__ne__ |
a * b |
__mul__ |
a < b, a <= b |
__lt__, __le__ |
a / b |
__truediv__ |
a > b, a >= b |
__gt__, __ge__ |
a // b, a % b |
__floordiv__, __mod__ |
len(a) |
__len__ |
a ** b |
__pow__ |
a[i] |
__getitem__ |
-a |
__neg__ |
x in a |
__contains__ |
str(a), repr(a) |
__str__, __repr__ |
a() |
__call__ |
A container class shows several at once:
class Playlist:
def __init__(self, *songs):
self.songs = list(songs)
def __len__(self):
return len(self.songs)
def __getitem__(self, i):
return self.songs[i]
def __contains__(self, song):
return song in self.songs
p = Playlist("a", "b", "c")
print(len(p), p[1], "c" in p, [s.upper() for s in p])
# 3 b True ['A', 'B', 'C']
Because Playlist has __getitem__, even the for loop in the comprehension works on it.
When the other side is not your type
Money(5) + 3 would crash inside __add__ with an AttributeError, since 3 has no .amount. A well-behaved operator returns the special value NotImplemented for types it does not understand; Python then tries the other operand's method and finally raises a proper TypeError:
class Money:
def __init__(self, amount): self.amount = amount
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
return Money(self.amount + other.amount)
try:
Money(5) + 3
except TypeError as e:
print(e) # unsupported operand type(s) for +: 'Money' and 'int'
Define __radd__ as well if you want 3 + Money(5) to work, and note that defining __eq__ without __hash__ makes the objects unhashable, so they can't be dictionary keys or set members unless you also define __hash__.
4. Method overloading
In Java or C++ you can write several methods with the same name and different parameter lists, and the compiler picks one. Python does not do this. If you define the same name twice in a class, the second definition silently replaces the first:
class Calc:
def add(self, a, b):
return a + b
def add(self, a, b, c): # replaces the first add
return a + b + c
c = Calc()
print(c.add(1, 2, 3))
try:
c.add(1, 2)
except TypeError as e:
print(e) # add() missing 1 required positional argument: 'c'
Python gives you better tools for the same need.
Default arguments cover optional parameters:
class Calc:
def add(self, a, b, c=0):
return a + b + c
print(Calc().add(1, 2), Calc().add(1, 2, 3)) # 3 6
Variable arguments (*args) cover any number:
class Calc:
def add(self, *numbers):
return sum(numbers)
print(Calc().add(), Calc().add(5), Calc().add(1, 2, 3, 4)) # 0 5 10
Type checks inside one method cover different types, though overusing them defeats duck typing:
def show(x):
if isinstance(x, (list, tuple)):
return "sequence of " + str(len(x))
if isinstance(x, dict):
return "mapping of " + str(len(x))
return "value " + repr(x)
print(show([1, 2]), show({"a": 1}), show(7))
# sequence of 2 mapping of 1 value 7
The standard library also offers functools.singledispatch, which chooses a function by the type of the first argument:
from functools import singledispatch
@singledispatch
def describe(x): return f"object {x!r}"
@describe.register(int)
def _(x): return f"integer {x}"
@describe.register(list)
def _(x): return f"list of {len(x)}"
print(describe(5), describe([1, 2]), describe("s"))
# integer 5 list of 2 object 's'
Constructor overloading
The same applies to constructors: only one __init__ per class. Use default arguments, *args, or a class method as an alternative constructor:
class Date:
def __init__(self, y, m, d):
self.y, self.m, self.d = y, m, d
@classmethod
def from_text(cls, text):
y, m, d = map(int, text.split("-"))
return cls(y, m, d)
def __repr__(self):
return f"Date({self.y}, {self.m}, {self.d})"
print(Date(2026, 10, 24), Date.from_text("2026-10-24"))
# Date(2026, 10, 24) Date(2026, 10, 24)
Summary of the forms
| Form | How Python does it | Decided |
|---|---|---|
| Duck typing | any object with the right method works | at run time |
| Overriding | child redefines a parent method | by the object's class |
| Operator overloading | implement magic methods | by the left operand's class |
| Method overloading | not by signature; use defaults, *args, singledispatch |
in your own code |
Common mistakes
- Defining the same method name twice and expecting overloading.
- Letting
__add__crash on unexpected types instead of returningNotImplemented. - Defining
__eq__and breaking hashing. - Overriding a method but changing its meaning or parameters so that code written for the parent breaks.
- Forgetting
super().__init__()when overriding a constructor. - Overusing
isinstancechecks, which turns polymorphism back into anifladder.
Practice
Write a Vector class with x and y, supporting +, -, multiplication by a number, ==, abs() (use __abs__) and a readable repr. Then write three classes that each have a price() method (a book, a course, a subscription) and a function that totals any list of them without checking their types.
Continue
The next part covers abstract classes, interfaces, access control and string representation. Revisit part 1 for classes and objects, and part 2 for inheritance. The Python video course covers the whole object-oriented section, and the Python interview questions include polymorphism questions.