HSHimanshu Sharma
Python Notes

Python OOP part 3: polymorphism, duck typing and operator overloading

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 returning NotImplemented.
  • 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 isinstance checks, which turns polymorphism back into an if ladder.

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.

Advertisement