# Polymorphism

Course: [Python](https://www.devtutorials.ai/learn/python) · Section: Object Oriented Programming
Source: https://www.devtutorials.ai/learn/python/oop/polymorphism

> 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.

```python runnable title="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:

```python runnable title="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.

```python runnable title="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:

```python runnable title="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.

```python runnable title="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.

```python runnable title="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:

```python runnable expect-error title="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.

```text
Person (parent)
  ├─ Student
  ├─ Teacher
  └─ Visitor
```

```python runnable title="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.

```python runnable title="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:

```python runnable title="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.

```python runnable title="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()`:

```python runnable title="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.

```python runnable title="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:

```python runnable title="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.

```python runnable expect-error title="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.

```python runnable title="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.

```python runnable title="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:

```python runnable title="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.

<div className="tscroll">

| 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()` |

</div>

## 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

```python runnable title="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**.

```python runnable title="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.

```python runnable title="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.

```python runnable expect-error title="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 runnable expect-error title="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.

<div className="tscroll">

| Operator | Method |
|---|---|
| `+` | `__add__` |
| `-` | `__sub__` |
| `*` | `__mul__` |
| `/` | `__truediv__` |
| `==` | `__eq__` |
| `!=` | `__ne__` |
| `<` | `__lt__` |
| `<=` | `__le__` |
| `>` | `__gt__` |
| `>=` | `__ge__` |

</div>

### Overloading Arithmetic Operators

```python runnable title="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.

```python runnable title="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:

```python runnable title="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:

```python runnable title="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":

```python runnable title="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:

```python runnable title="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:

```python runnable title="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.

<div className="tscroll">

| 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 |

</div>

## 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, `*args` or `isinstance()`.
- **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 `NotImplemented` for a type you cannot handle, and let Python raise its own clear error.

Next: **Abstraction**
