Python - Polymorphism with Examples

Polymorphism enables a single interface to represent different underlying forms. In Python, this manifests through duck typing: 'If it walks like a duck and quacks like a duck, it's a duck.' The...

Key Insights

  • Polymorphism allows objects of different types to be treated uniformly through a common interface, enabling flexible and extensible code design
  • Python implements polymorphism through duck typing, inheritance-based method overriding, and abstract base classes without requiring explicit interface declarations
  • Operator overloading and generic functions demonstrate Python’s built-in polymorphic behavior that can be extended for custom types

Understanding Polymorphism in Python

Polymorphism enables a single interface to represent different underlying forms. In Python, this manifests through duck typing: “If it walks like a duck and quacks like a duck, it’s a duck.” The interpreter doesn’t care about an object’s type—only whether it implements the required methods.

class AudioFile:
    def play(self):
        pass

class MP3(AudioFile):
    def play(self):
        print("Playing MP3 file")

class WAV(AudioFile):
    def play(self):
        print("Playing WAV file")

class FLAC(AudioFile):
    def play(self):
        print("Playing FLAC file")

def play_audio(audio: AudioFile):
    audio.play()

# Polymorphic behavior
files = [MP3(), WAV(), FLAC()]
for file in files:
    play_audio(file)

The play_audio function accepts any object with a play() method. No explicit interface declaration required.

Duck Typing in Action

Python’s dynamic nature makes polymorphism natural. Any object implementing the expected interface works, regardless of inheritance hierarchy.

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

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

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

def make_it_speak(entity):
    print(entity.speak())

# All work despite no common base class
entities = [Dog(), Cat(), Robot()]
for entity in entities:
    make_it_speak(entity)

This flexibility enables rapid development but requires discipline. Runtime errors occur if objects lack expected methods.

Method Overriding and Inheritance

Subclasses can override parent methods to provide specialized implementations while maintaining a common interface.

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass
    
    def log_transaction(self, amount: float):
        print(f"Transaction logged: ${amount:.2f}")

class CreditCardProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        print(f"Processing credit card payment: ${amount:.2f}")
        self.log_transaction(amount)
        return True

class PayPalProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        print(f"Processing PayPal payment: ${amount:.2f}")
        self.log_transaction(amount)
        return True

class CryptoProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        print(f"Processing crypto payment: ${amount:.2f}")
        self.log_transaction(amount)
        return True

def checkout(processor: PaymentProcessor, amount: float):
    if processor.process_payment(amount):
        print("Payment successful\n")

# All processors work through the same interface
processors = [
    CreditCardProcessor(),
    PayPalProcessor(),
    CryptoProcessor()
]

for processor in processors:
    checkout(processor, 99.99)

Abstract base classes enforce interface contracts. Attempting to instantiate PaymentProcessor directly raises TypeError.

Operator Overloading

Python operators are polymorphic by design. The + operator behaves differently for integers, strings, and lists. Custom classes can implement this behavior through magic methods.

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)
    
    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)
    
    def __str__(self):
        return f"Vector({self.x}, {self.y})"
    
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

v1 = Vector(2, 3)
v2 = Vector(4, 1)

print(v1 + v2)  # Vector(6, 4)
print(v1 - v2)  # Vector(-2, 2)
print(v1 * 3)   # Vector(6, 9)
print(v1 == Vector(2, 3))  # True

This enables intuitive mathematical operations on custom types, making code more readable and maintainable.

Protocol Classes and Structural Subtyping

Python 3.8+ introduced Protocol classes for static type checking with structural subtyping—checking interface compatibility without inheritance.

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str:
        ...

class Circle:
    def __init__(self, radius: float):
        self.radius = radius
    
    def draw(self) -> str:
        return f"Drawing circle with radius {self.radius}"

class Square:
    def __init__(self, side: float):
        self.side = side
    
    def draw(self) -> str:
        return f"Drawing square with side {self.side}"

def render(shape: Drawable) -> None:
    print(shape.draw())

# Type checker validates these without explicit inheritance
render(Circle(5.0))
render(Square(4.0))

Protocols provide type safety while preserving duck typing’s flexibility. Mypy and other type checkers verify compatibility at development time.

Generic Functions with functools.singledispatch

Single dispatch enables function polymorphism based on the first argument’s type—a form of function overloading.

from functools import singledispatch
from typing import List, Dict

@singledispatch
def serialize(data):
    raise NotImplementedError(f"Cannot serialize type {type(data)}")

@serialize.register
def _(data: int) -> str:
    return str(data)

@serialize.register
def _(data: str) -> str:
    return f'"{data}"'

@serialize.register
def _(data: list) -> str:
    items = [serialize(item) for item in data]
    return f"[{', '.join(items)}]"

@serialize.register
def _(data: dict) -> str:
    pairs = [f'"{k}": {serialize(v)}' for k, v in data.items()]
    return f"{{{', '.join(pairs)}}}"

print(serialize(42))                          # "42"
print(serialize("hello"))                      # ""hello""
print(serialize([1, 2, 3]))                   # "[1, 2, 3]"
print(serialize({"name": "Alice", "age": 30})) # "{"name": "Alice", "age": 30}"

This pattern replaces lengthy if-elif chains with clean, extensible code. New types register handlers without modifying existing functions.

Real-World Example: Data Pipeline

Combining these concepts creates flexible, maintainable systems.

from abc import ABC, abstractmethod
from typing import List, Any

class DataTransformer(ABC):
    @abstractmethod
    def transform(self, data: Any) -> Any:
        pass

class UpperCaseTransformer(DataTransformer):
    def transform(self, data: str) -> str:
        return data.upper()

class DoubleTransformer(DataTransformer):
    def transform(self, data: int) -> int:
        return data * 2

class FilterNullTransformer(DataTransformer):
    def transform(self, data: List[Any]) -> List[Any]:
        return [item for item in data if item is not None]

class DataPipeline:
    def __init__(self):
        self.transformers: List[DataTransformer] = []
    
    def add_transformer(self, transformer: DataTransformer):
        self.transformers.append(transformer)
        return self
    
    def process(self, data: Any) -> Any:
        result = data
        for transformer in self.transformers:
            result = transformer.transform(result)
        return result

# Build pipeline
pipeline = DataPipeline()
pipeline.add_transformer(FilterNullTransformer())

# Process different data types through same interface
data = ["hello", None, "world", None, "python"]
cleaned = pipeline.process(data)
print(cleaned)  # ['hello', 'world', 'python']

# Different pipeline for different data
text_pipeline = DataPipeline()
text_pipeline.add_transformer(UpperCaseTransformer())
print(text_pipeline.process("hello"))  # "HELLO"

This architecture allows adding transformers without modifying the pipeline. Each transformer implements a common interface, demonstrating polymorphism’s power in building extensible systems.

Performance Considerations

Polymorphism introduces minimal overhead in Python. Duck typing checks happen at runtime, but the flexibility often outweighs microsecond differences. Profile before optimizing.

import timeit

class DirectCall:
    def method(self):
        return 42

class PolymorphicCall:
    def method(self):
        return 42

def direct():
    obj = DirectCall()
    return obj.method()

def polymorphic(obj):
    return obj.method()

# Negligible difference in practice
print(timeit.timeit(direct, number=1000000))
print(timeit.timeit(lambda: polymorphic(PolymorphicCall()), number=1000000))

For performance-critical code, consider Cython or compiled extensions. For typical applications, polymorphism’s design benefits far exceed any performance costs.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.