Module 6.3

Polymorphism and Magic Methods

Polymorphism means one interface, multiple implementations. Different objects can respond to the same method call in their own unique way. This powerful concept enables writing flexible, extensible code that works with objects based on what they can do, not what class they belong to.

45 min
Intermediate
Hands-on
What You'll Learn
  • Duck typing philosophy
  • Method overriding in subclasses
  • Operator overloading with magic methods
  • Common dunder methods
  • Polymorphic function design
Contents
01

Duck Typing

"If it walks like a duck and quacks like a duck, then it must be a duck." Python does not care about an object's class. It only cares whether the object has the methods and attributes you try to use. This philosophy enables flexible, interface-based programming.

Key Concept

Behavior Over Type

Duck typing focuses on what an object can do, not what it is. If an object has a speak() method, you can call it regardless of its class. This differs from statically typed languages that check class hierarchies at compile time.

Why it matters: Duck typing enables loose coupling. Functions work with any object that has the required methods, making your code more flexible and reusable.

Duck Typing Concept
Dog
speak()
"Woof!"
Cat
speak()
"Meow!"
Robot
speak()
"Beep!"
make_speak(animal)
animal.speak() Works for ALL!
Python asks: "Can it speak()?" NOT "Is it an Animal?"

Duck Typing in Action

Write functions that accept any object with the required methods. Python will call the method at runtime without checking the class.

class Dog:
    def speak(self):
        return "Woof!"

class Cat:
    def speak(self):
        return "Meow!"

class Robot:
    def speak(self):
        return "Beep boop!"

def make_speak(entity):
    print(entity.speak())  # Works for any object with speak()

make_speak(Dog())    # Output: Woof!
make_speak(Cat())    # Output: Meow!
make_speak(Robot())  # Output: Beep boop!

The make_speak() function does not know or care about the class of the entity. It just calls speak() and trusts the object to handle it.

Polymorphic Iteration

Duck typing shines when processing collections of different objects that share common behavior.

class Circle:
    def __init__(self, radius):
        self.radius = radius
    def area(self):
        return 3.14159 * self.radius ** 2

class Rectangle:
    def __init__(self, width, height):
        self.width, self.height = width, height
    def area(self):
        return self.width * self.height

shapes = [Circle(5), Rectangle(4, 6), Circle(3)]
total = sum(shape.area() for shape in shapes)
print(f"Total area: {total:.2f}")  # Output: Total area: 126.27

Different shape classes, same area() method. The loop processes them uniformly without type checks.

Practice: Duck Typing

Task: Create Bird and Airplane classes, each with a fly() method. Write a function that calls fly() on any object passed to it.

Show Solution
class Bird:
    def fly(self):
        return "Flapping wings!"

class Airplane:
    def fly(self):
        return "Engines roaring!"

def take_off(flyer):
    print(flyer.fly())

take_off(Bird())      # Output: Flapping wings!
take_off(Airplane())  # Output: Engines roaring!

Task: Create Employee and Contractor classes with a get_pay() method. Make a list of both types and calculate total payroll.

Show Solution
class Employee:
    def __init__(self, salary):
        self.salary = salary
    def get_pay(self):
        return self.salary

class Contractor:
    def __init__(self, hourly, hours):
        self.hourly, self.hours = hourly, hours
    def get_pay(self):
        return self.hourly * self.hours

workers = [Employee(5000), Contractor(50, 80), Employee(6000)]
total = sum(w.get_pay() for w in workers)
print(f"Total payroll: ${total}")  # Output: Total payroll: $15000

Task: Create a StringBuffer class with write() and read() methods. Write a function that works with it the same way it would with a file.

Show Solution
class StringBuffer:
    def __init__(self):
        self.data = ""
    def write(self, text):
        self.data += text
    def read(self):
        return self.data

def save_greeting(output):
    output.write("Hello, ")
    output.write("World!")

buffer = StringBuffer()
save_greeting(buffer)
print(buffer.read())  # Output: Hello, World!
02

Method Overriding

Method overriding occurs when a subclass provides its own implementation of a method defined in the parent class. The child's version replaces the parent's version for objects of that child class. This is classical polymorphism through inheritance.

Method Overriding Flow
Animal Base Class
speak() "Some sound"
inherits
Dog
speak()
"Woof!"
OVERRIDES
Cat
speak()
"Meow!"
OVERRIDES
dog.speak() Dog.speak() is called, NOT Animal.speak()

Basic Method Overriding

Define a method in the child class with the same name as the parent. Python uses the most specific version (child's version) for that object.

class Animal:
    def speak(self):
        return "Some generic sound"

class Dog(Animal):
    def speak(self):
        return "Woof!"

class Cat(Animal):
    def speak(self):
        return "Meow!"

animals = [Animal(), Dog(), Cat()]
for animal in animals:
    print(animal.speak())
# Output: Some generic sound, Woof!, Meow!

Each object uses its own class's speak() method. The Animal instance uses the base version while Dog and Cat use their overridden versions.

Using super() to Extend Behavior

Use super() to call the parent's method before or after adding your own logic. This extends rather than completely replaces the parent behavior.

class Vehicle:
    def __init__(self, brand):
        self.brand = brand
    def start(self):
        return f"{self.brand} engine starting..."

class ElectricCar(Vehicle):
    def __init__(self, brand, battery_kwh):
        super().__init__(brand)
        self.battery = battery_kwh
    def start(self):
        base = super().start()
        return f"{base} [Silent electric mode]"

tesla = ElectricCar("Tesla", 100)
print(tesla.start())
# Output: Tesla engine starting... [Silent electric mode]

The super().start() calls the parent version first, then the child adds its own behavior. This is the "extend and customize" pattern.

Pro Tip: Use super() when you want to add to parent behavior. Override completely when the child needs entirely different logic.

Practice: Method Overriding

Task: Create a Shape base class with describe(). Create Circle and Square that override it with specific descriptions.

Show Solution
class Shape:
    def describe(self):
        return "I am a shape"

class Circle(Shape):
    def describe(self):
        return "I am a circle with no corners"

class Square(Shape):
    def describe(self):
        return "I am a square with 4 equal sides"

for s in [Shape(), Circle(), Square()]:
    print(s.describe())

Task: Create Logger with log(msg) that prints the message. Create TimestampLogger that adds a timestamp prefix using super().

Show Solution
from datetime import datetime

class Logger:
    def log(self, msg):
        print(msg)

class TimestampLogger(Logger):
    def log(self, msg):
        timestamp = datetime.now().strftime("%H:%M:%S")
        super().log(f"[{timestamp}] {msg}")

logger = TimestampLogger()
logger.log("Server started")  # [14:30:25] Server started

Task: Create Account, SavingsAccount, and PremiumSavings. Override get_interest_rate() at each level, using super() to add bonuses.

Show Solution
class Account:
    def get_interest_rate(self):
        return 0.01  # 1% base

class SavingsAccount(Account):
    def get_interest_rate(self):
        return super().get_interest_rate() + 0.02  # +2%

class PremiumSavings(SavingsAccount):
    def get_interest_rate(self):
        return super().get_interest_rate() + 0.015  # +1.5%

acc = PremiumSavings()
print(f"Rate: {acc.get_interest_rate():.1%}")  # Rate: 4.5%
03

Operator Overloading

Operator overloading lets you define how operators like +, -, *, and == work with your custom objects. Python uses special "dunder" (double underscore) methods to implement this. When you write a + b, Python calls a.__add__(b).

Operator Method Example Description
+ __add__(self, other) a + b Addition
- __sub__(self, other) a - b Subtraction
* __mul__(self, other) a * b Multiplication
== __eq__(self, other) a == b Equality check
< __lt__(self, other) a < b Less than
len() __len__(self) len(a) Length

Overloading Arithmetic Operators

Define __add__ to enable the + operator for your objects. Return a new object as the result.

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    def __str__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(2, 3)
v2 = Vector(5, 7)
v3 = v1 + v2  # Calls v1.__add__(v2)
print(v3)     # Output: Vector(7, 10)

The __add__ method receives self (left operand) and other (right operand). It returns a new Vector instead of modifying either original.

Overloading Comparison Operators

Define __eq__ for equality (==) and __lt__ for less-than (<). Python can derive other comparisons if you use functools.total_ordering.

class Product:
    def __init__(self, name, price):
        self.name, self.price = name, price
    
    def __eq__(self, other):
        return self.price == other.price
    
    def __lt__(self, other):
        return self.price < other.price

p1 = Product("Phone", 999)
p2 = Product("Tablet", 999)
p3 = Product("Watch", 399)

print(p1 == p2)  # True (same price)
print(p3 < p1)   # True (399 < 999)

Comparison operators let you use sorted() and min()/max() with your objects. Define __lt__ to enable sorting by price.

The __str__ and __repr__ Methods

Define __str__ for human-readable output (print) and __repr__ for unambiguous representation (debugging).

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    
    def __str__(self):
        return f"Point at ({self.x}, {self.y})"
    
    def __repr__(self):
        return f"Point({self.x}, {self.y})"

p = Point(3, 4)
print(p)        # Output: Point at (3, 4) [uses __str__]
print(repr(p))  # Output: Point(3, 4) [uses __repr__]
print([p])      # Output: [Point(3, 4)] [list uses __repr__]

__repr__ should ideally return a string that could recreate the object. __str__ is for friendly display to end users.

Practice: Operator Overloading

Task: Create a Book class with title and author. Implement __str__ to return "Title by Author".

Show Solution
class Book:
    def __init__(self, title, author):
        self.title = title
        self.author = author
    
    def __str__(self):
        return f"{self.title} by {self.author}"

book = Book("1984", "George Orwell")
print(book)  # Output: 1984 by George Orwell

Task: Create a Playlist class that stores songs. Implement __len__ to return the number of songs.

Show Solution
class Playlist:
    def __init__(self):
        self.songs = []
    
    def add(self, song):
        self.songs.append(song)
    
    def __len__(self):
        return len(self.songs)

pl = Playlist()
pl.add("Song A")
pl.add("Song B")
print(len(pl))  # Output: 2

Task: Create a Money class with amount and currency. Implement __add__ that only adds if currencies match (raise error otherwise).

Show Solution
class Money:
    def __init__(self, amount, currency):
        self.amount = amount
        self.currency = currency
    
    def __add__(self, other):
        if self.currency != other.currency:
            raise ValueError("Currency mismatch!")
        return Money(self.amount + other.amount, self.currency)
    
    def __str__(self):
        return f"{self.amount} {self.currency}"

m1 = Money(100, "USD")
m2 = Money(50, "USD")
print(m1 + m2)  # Output: 150 USD

Task: Create a Student class with name and gpa. Implement comparison operators so students can be sorted by GPA.

Show Solution
class Student:
    def __init__(self, name, gpa):
        self.name, self.gpa = name, gpa
    
    def __eq__(self, other):
        return self.gpa == other.gpa
    
    def __lt__(self, other):
        return self.gpa < other.gpa
    
    def __repr__(self):
        return f"{self.name}({self.gpa})"

students = [Student("Alice", 3.8), Student("Bob", 3.5)]
print(sorted(students))  # [Bob(3.5), Alice(3.8)]

Task: Create a Vector3D class (x, y, z). Implement __mul__ for scalar multiplication (v * 3) and __add__ for vector addition.

Show Solution
class Vector3D:
    def __init__(self, x, y, z):
        self.x, self.y, self.z = x, y, z
    
    def __add__(self, other):
        return Vector3D(self.x+other.x, self.y+other.y, self.z+other.z)
    
    def __mul__(self, scalar):
        return Vector3D(self.x*scalar, self.y*scalar, self.z*scalar)
    
    def __repr__(self):
        return f"V({self.x}, {self.y}, {self.z})"

v = Vector3D(1, 2, 3)
print(v * 2)  # V(2, 4, 6)
print(v + Vector3D(1, 1, 1))  # V(2, 3, 4)

Task: Create a Matrix class that stores a 2D list. Implement __getitem__ to access elements with matrix[row][col] syntax.

Show Solution
class Matrix:
    def __init__(self, data):
        self.data = data
    
    def __getitem__(self, row):
        return self.data[row]
    
    def __repr__(self):
        return '\n'.join(str(row) for row in self.data)

m = Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(m[1][2])  # Output: 6
print(m[0])     # Output: [1, 2, 3]

Key Takeaways

Duck Typing is Pythonic

Python checks capabilities, not types. If an object has the method you need, it works regardless of its class hierarchy.

Override to Specialize

Child classes override parent methods to provide specialized behavior while maintaining the same interface.

Use super() to Extend

Call super().method() to run the parent's code first, then add your own logic on top of it.

Operators are Methods

The + operator calls __add__, == calls __eq__. Define these dunder methods to use operators with your objects.

__str__ vs __repr__

Use __str__ for user-friendly output and __repr__ for debugging. repr should ideally be copy-pasteable Python.

Enable Sorting with __lt__

Define __lt__ to use sorted(), min(), and max() with your objects. Python derives other comparisons from it.

Knowledge Check

Quick Quiz

Test what you've learned about Python polymorphism and magic methods

1 What is duck typing in Python?
2 What happens when a child class defines a method with the same name as a parent method?
3 Which method is called when you use the + operator?
4 What is the purpose of super() in method overriding?
5 What is the difference between __str__ and __repr__?
6 Which magic method enables comparison with the == operator?
Answer all questions to check your score