Python Polymorphism: Method Overriding and Duck Typing
Polymorphism lets you write code that works with objects of different types through a common interface. In statically-typed languages like Java or C++, this typically requires explicit inheritance...
Key Insights
- Python supports two distinct forms of polymorphism: classical method overriding through inheritance and duck typing based on object behavior rather than type hierarchy
- Duck typing embraces Python’s dynamic nature and often produces more flexible code than rigid inheritance hierarchies, following the EAFP principle over explicit type checking
- Abstract Base Classes and Protocol types provide a middle ground, offering structural guarantees while preserving the flexibility that makes Python productive
Introduction to Polymorphism in Python
Polymorphism lets you write code that works with objects of different types through a common interface. In statically-typed languages like Java or C++, this typically requires explicit inheritance hierarchies and interface declarations. Python takes a fundamentally different approach.
Python’s dynamic type system means polymorphism is built into the language’s DNA. You don’t need to declare that a class implements an interface—if an object has the methods you’re calling, it works. This creates two distinct polymorphic patterns: traditional method overriding through inheritance, and duck typing where any object with the right behavior qualifies.
Understanding when to use each approach will make you a more effective Python developer. Inheritance provides clear contracts and IDE support. Duck typing offers flexibility and reduces coupling. Modern Python even provides tools to get the best of both worlds.
Method Overriding Fundamentals
Method overriding is the classical OOP approach to polymorphism. A child class inherits from a parent and replaces specific methods with its own implementation. Python makes this straightforward—just define a method with the same name in the child class.
class Shape:
def __init__(self, name):
self.name = name
def calculate_area(self):
raise NotImplementedError("Subclasses must implement calculate_area")
def describe(self):
return f"{self.name} with area {self.calculate_area()}"
class Rectangle(Shape):
def __init__(self, width, height):
super().__init__("Rectangle")
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
super().__init__("Circle")
self.radius = radius
def calculate_area(self):
return 3.14159 * self.radius ** 2
# Polymorphism in action
shapes = [Rectangle(5, 3), Circle(4), Rectangle(2, 8)]
for shape in shapes:
print(shape.describe()) # Each calls its own calculate_area()
The super() function is crucial for method overriding. It lets child classes extend rather than completely replace parent behavior. This is particularly important in __init__ methods where you often want the parent’s initialization logic plus your own additions.
Notice how describe() in the parent class calls calculate_area() without knowing which implementation will run. This is polymorphism—the same interface, different behaviors based on the actual object type.
Duck Typing: “If It Walks Like a Duck…”
Duck typing is where Python really diverges from traditional OOP languages. The principle is simple: if an object has the methods you need, it doesn’t matter what class it inherits from or what interfaces it claims to implement.
The name comes from the phrase “If it walks like a duck and quacks like a duck, it’s a duck.” In code terms: if an object has a write() method, you can treat it as writable, regardless of its actual type.
class FileWriter:
def __init__(self, filename):
self.filename = filename
def write(self, data):
with open(self.filename, 'a') as f:
f.write(data + '\n')
print(f"Wrote to file: {self.filename}")
class DatabaseWriter:
def __init__(self, connection_string):
self.connection = connection_string
def write(self, data):
# Simulate database write
print(f"Inserted into database: {data}")
class APIWriter:
def __init__(self, endpoint):
self.endpoint = endpoint
def write(self, data):
# Simulate API call
print(f"Posted to {self.endpoint}: {data}")
def log_event(writer, event):
"""Accepts ANY object with a write() method"""
writer.write(f"Event: {event}")
# All three work interchangeably
log_event(FileWriter("events.log"), "User login")
log_event(DatabaseWriter("postgresql://localhost"), "Purchase completed")
log_event(APIWriter("https://api.example.com/events"), "Error occurred")
These classes share no inheritance relationship, yet they work identically with log_event(). This is duck typing in practice. The function doesn’t check types—it just calls write() and trusts the object to handle it.
This follows Python’s EAFP principle: “Easier to Ask for Forgiveness than Permission.” Rather than checking if an object is the right type before calling a method, just call it and handle any exceptions that arise.
Practical Comparison: Override vs Duck Typing
Both approaches have their place. Method overriding creates explicit contracts that IDEs understand and developers can see clearly. Duck typing maximizes flexibility and reduces coupling between components.
Consider a payment processing system implemented both ways:
# Inheritance-based approach
class Payment:
def process(self, amount):
raise NotImplementedError
def refund(self, transaction_id):
raise NotImplementedError
class CreditCardPayment(Payment):
def process(self, amount):
return f"Charged ${amount} to credit card"
def refund(self, transaction_id):
return f"Refunded transaction {transaction_id}"
class PayPalPayment(Payment):
def process(self, amount):
return f"Processed ${amount} via PayPal"
def refund(self, transaction_id):
return f"PayPal refund for {transaction_id}"
# Duck typing approach
class StripeProcessor:
def process(self, amount):
return f"Stripe: charged ${amount}"
def refund(self, transaction_id):
return f"Stripe: refunded {transaction_id}"
class CryptoProcessor:
def process(self, amount):
return f"Crypto: transferred ${amount}"
def refund(self, transaction_id):
return f"Crypto: reversed {transaction_id}"
def handle_payment(processor, amount):
"""Works with any processor that has process() method"""
result = processor.process(amount)
print(result)
# Both approaches work
handle_payment(CreditCardPayment(), 100)
handle_payment(StripeProcessor(), 50)
Use inheritance when you have a genuine “is-a” relationship and want to share implementation code. Use duck typing when you need flexibility or when objects from different domains happen to share behavior. In large codebases with multiple teams, inheritance can make contracts more explicit. In smaller projects or when integrating third-party code, duck typing reduces friction.
Abstract Base Classes: The Middle Ground
Abstract Base Classes (ABCs) from Python’s abc module provide formal interfaces while preserving flexibility. They let you define required methods without forcing rigid inheritance hierarchies.
from abc import ABC, abstractmethod
class DataProcessor(ABC):
@abstractmethod
def load(self, source):
"""Load data from source"""
pass
@abstractmethod
def transform(self, data):
"""Transform the data"""
pass
@abstractmethod
def save(self, data, destination):
"""Save transformed data"""
pass
class CSVProcessor(DataProcessor):
def load(self, source):
return f"Loading CSV from {source}"
def transform(self, data):
return f"Transforming: {data}"
def save(self, data, destination):
return f"Saving to {destination}"
class JSONProcessor(DataProcessor):
def load(self, source):
return f"Parsing JSON from {source}"
def transform(self, data):
return f"Mapping: {data}"
def save(self, data, destination):
return f"Writing JSON to {destination}"
# This won't work - missing required methods
# class IncompleteProcessor(DataProcessor):
# def load(self, source):
# return "loaded"
# TypeError: Can't instantiate abstract class
def process_pipeline(processor: DataProcessor, source, dest):
data = processor.load(source)
transformed = processor.transform(data)
processor.save(transformed, dest)
ABCs provide compile-time (well, instantiation-time) guarantees that objects implement required methods. You get the safety of interfaces with the flexibility of Python’s dynamic typing.
Best Practices and Common Pitfalls
Modern Python offers Protocol from the typing module for structural subtyping—duck typing with type hints. This gives you IDE autocomplete and type checker support without requiring inheritance.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str:
...
class Circle:
def draw(self) -> str:
return "Drawing circle"
class Square:
def draw(self) -> str:
return "Drawing square"
def render(shape: Drawable) -> None:
"""Type checkers verify shape has draw() method"""
print(shape.draw())
# Both work, no inheritance needed
render(Circle())
render(Square())
Follow the Liskov Substitution Principle: subclasses should be usable wherever their parent class is expected. If overriding a method changes its fundamental behavior or contracts, you’re probably violating LSP.
Avoid over-engineering with deep inheritance hierarchies. Composition often beats inheritance. If you find yourself creating abstract base classes with only one or two implementations, duck typing might be simpler.
Test polymorphic code with multiple implementations. Don’t just test the happy path with one concrete class—verify that all implementations work correctly with your polymorphic functions.
Python’s flexibility is a strength, but with large teams, explicit ABCs or Protocols can prevent bugs and improve code comprehension. Choose the right tool for your context. When in doubt, start simple with duck typing and add structure as complexity demands it.