Understanding Polymorphism in Python
Polymorphism is one of the four fundamental pillars of object-oriented programming, alongside encapsulation, inheritance, and abstraction. The word "polymorphism" comes from Greek words meaning "many forms," and that's exactly what it allows in programming: the ability for a single interface to represent different underlying forms (data types or classes).
At its core, polymorphism enables us to write more flexible, reusable code by allowing:
- Different classes to be treated through a common interface
- Different objects to respond to the same method call in their own unique ways
- Code to work with objects of multiple types without explicitly checking their types
In today's session, we'll explore polymorphism in Python, how it works, and how you can leverage it to create more elegant and maintainable software.
Today's File Structure
For today's lesson, we'll create a new Python module in our project. Ensure you have the following directory structure:
project_root/ ├── polymorphism/ │ ├── __init__.py (empty file to make the folder a package) │ ├── duck_typing.py │ ├── method_overriding.py │ ├── operator_overloading.py │ ├── function_polymorphism.py │ └── real_world_examples.py
All code examples will be saved in these files, allowing you to organize and revisit these concepts easily.
Types of Polymorphism in Python
Python supports several forms of polymorphism:
- Duck Typing: Objects are compatible with operations based on the methods and properties they define, not their actual types. "If it walks like a duck and quacks like a duck, then it probably is a duck."
- Method Overriding: Subclasses can provide specific implementations of methods defined in parent classes.
- Operator Overloading: Classes can define how operators like +, -, *, etc., behave when applied to instances of the class.
- Function Polymorphism: Built-in functions and methods that can operate on different types of objects.
Let's explore each of these in detail.
Duck Typing
Duck typing is a programming concept where the type or class of an object is less important than the methods it defines or the operations it supports. This is a core principle in Python and enables a flexible form of polymorphism.
Create a file named duck_typing.py with the following code:
# File: polymorphism/duck_typing.py
class Duck:
def speak(self):
return "Quack!"
def swim(self):
return "Duck swimming"
def fly(self):
return "Duck flying"
class Person:
def speak(self):
return "Hello!"
def swim(self):
return "Person swimming"
def fly(self):
return "Person flying in an airplane"
class Robot:
def speak(self):
return "Beep boop!"
def swim(self):
return "Robot swimming with waterproof components"
def fly(self):
return "Robot activating jet propulsion"
# Function that demonstrates duck typing
def make_it_speak(entity):
"""Any entity that can 'speak' will work here"""
return entity.speak()
def make_it_swim(entity):
"""Any entity that can 'swim' will work here"""
return entity.swim()
def make_it_fly(entity):
"""Any entity that can 'fly' will work here"""
return entity.fly()
# Create instances of each class
donald = Duck()
john = Person()
r2d2 = Robot()
# Make them all speak, swim, and fly without checking their types
for entity in [donald, john, r2d2]:
print(f"{entity.__class__.__name__} says: {make_it_speak(entity)}")
print(f"{entity.__class__.__name__} swims: {make_it_swim(entity)}")
print(f"{entity.__class__.__name__} flies: {make_it_fly(entity)}")
print()
# This will raise an AttributeError because Fish doesn't have a 'fly' method
class Fish:
def swim(self):
return "Fish swimming"
nemo = Fish()
print(f"{nemo.__class__.__name__} swims: {make_it_swim(nemo)}")
try:
print(f"{nemo.__class__.__name__} flies: {make_it_fly(nemo)}")
except AttributeError as e:
print(f"Error: {e}")
Code Breakdown:
- We define three completely different classes (
Duck,Person, andRobot), each withspeak,swim, andflymethods. - The functions
make_it_speak,make_it_swim, andmake_it_flydon't care about the type of object they receive; they only care that the object has the appropriate method. - We can pass instances of any of the three classes to these functions, and they work correctly without any type checking.
- The
Fishclass demonstrates what happens when an object doesn't have the expected method—anAttributeErroris raised.
Key Insights about Duck Typing:
- No inheritance or interface implementation is required—just the presence of expected methods.
- There's no formal type checking; Python simply attempts to call the method and raises an error if it doesn't exist.
- This enables a very flexible form of polymorphism that doesn't require a formal class hierarchy.
- The focus is on an object's behavior (what it can do) rather than its identity (what it is).
Real-world analogy: Duck typing is like focusing on a person's skills rather than their formal qualifications. If someone can perform the required tasks (write code, design interfaces, etc.), it doesn't matter whether they have a computer science degree or are self-taught—they can do the job.
Method Overriding
Method overriding is a form of polymorphism that occurs when a subclass provides a specific implementation for a method already defined in its parent class. This allows different classes in an inheritance hierarchy to respond differently to the same method call.
Create a file named method_overriding.py with the following code:
# File: polymorphism/method_overriding.py
class Animal:
def __init__(self, name):
self.name = name
def make_sound(self):
"""Base make_sound method"""
return "Some generic animal sound"
def move(self):
"""Base move method"""
return "Moving somehow"
def describe(self):
"""Method that uses the polymorphic methods"""
return f"{self.name} is a {self.__class__.__name__}. It says '{self.make_sound()}' and moves by {self.move()}"
class Dog(Animal):
def make_sound(self):
"""Override make_sound for dogs"""
return "Woof!"
def move(self):
"""Override move for dogs"""
return "running on four legs"
class Bird(Animal):
def make_sound(self):
"""Override make_sound for birds"""
return "Tweet!"
def move(self):
"""Override move for birds"""
return "flying with wings"
class Fish(Animal):
def make_sound(self):
"""Override make_sound for fish"""
return "Blub!"
def move(self):
"""Override move for fish"""
return "swimming with fins"
# Create a list of animals and call the same methods on each
animals = [
Animal("Generic Animal"),
Dog("Rufus"),
Bird("Tweety"),
Fish("Nemo")
]
# Demonstrate polymorphism through inheritance
for animal in animals:
print(animal.describe())
# Using isinstance() to check types (sometimes necessary)
def pet_the_animal(animal):
"""Pet the animal if it's a dog or a bird, but not a fish"""
if isinstance(animal, Fish):
return f"Not petting {animal.name} because it's a fish and lives in water."
else:
return f"Petting {animal.name}. It responds: '{animal.make_sound()}'"
print("\nPetting animals:")
for animal in animals:
print(pet_the_animal(animal))
Code Breakdown:
- We define a base
Animalclass withmake_soundandmovemethods. - Subclasses (
Dog,Bird,Fish) override these methods to provide specific implementations. - The
describemethod in theAnimalclass uses the overridden methods, demonstrating how the same method call (make_sound()andmove()) produces different behavior depending on the actual object type. - We create a list of different animal types and iterate through it, calling the same method on each object.
- The
pet_the_animalfunction shows a case where we might need to check the actual type of an object usingisinstance()to make decisions, although this is less common in Python than in some other languages.
Key Insights about Method Overriding:
- Method overriding is based on inheritance relationships.
- The overriding method in the subclass must have the same name, parameters, and return type as the overridden method in the parent class.
- When a method is called on an object, Python first looks for the method in the object's class, then in its parent classes (following the Method Resolution Order).
- Method overriding enables "is-a" polymorphism—each subclass "is a" type of the parent class but behaves in its own specific way.
Real-world analogy: Method overriding is like different family members responding to the same request in different ways. If you ask family members to "make dinner," each might prepare a different meal based on their own skills and preferences, but they're all fulfilling the same basic request.
Operator Overloading
Operator overloading is a form of polymorphism that allows operators like +, -, *, etc., to behave differently depending on the types of the operands. Python provides special method names (often called "dunder" or "magic" methods) that you can implement to define how operators work with your custom classes.
Create a file named operator_overloading.py with the following code:
# File: polymorphism/operator_overloading.py
class Vector2D:
"""A 2D vector class with operator overloading"""
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
"""String representation of the vector"""
return f"Vector2D({self.x}, {self.y})"
def __add__(self, other):
"""Overload the + operator for vector addition"""
if isinstance(other, Vector2D):
# Vector + Vector
return Vector2D(self.x + other.x, self.y + other.y)
elif isinstance(other, (int, float)):
# Vector + scalar
return Vector2D(self.x + other, self.y + other)
else:
raise TypeError("Unsupported operand type")
def __sub__(self, other):
"""Overload the - operator for vector subtraction"""
if isinstance(other, Vector2D):
# Vector - Vector
return Vector2D(self.x - other.x, self.y - other.y)
elif isinstance(other, (int, float)):
# Vector - scalar
return Vector2D(self.x - other, self.y - other)
else:
raise TypeError("Unsupported operand type")
def __mul__(self, other):
"""Overload the * operator for vector scaling or dot product"""
if isinstance(other, Vector2D):
# Vector * Vector (dot product)
return self.x * other.x + self.y * other.y
elif isinstance(other, (int, float)):
# Vector * scalar (scaling)
return Vector2D(self.x * other, self.y * other)
else:
raise TypeError("Unsupported operand type")
def __eq__(self, other):
"""Overload the == operator for vector comparison"""
if not isinstance(other, Vector2D):
return False
return self.x == other.x and self.y == other.y
def __lt__(self, other):
"""Overload the < operator to compare vector magnitudes"""
if not isinstance(other, Vector2D):
raise TypeError("Unsupported operand type")
return self.magnitude() < other.magnitude()
def magnitude(self):
"""Calculate the magnitude (length) of the vector"""
return (self.x ** 2 + self.y ** 2) ** 0.5
class ComplexNumber:
"""A complex number class with operator overloading"""
def __init__(self, real, imag):
self.real = real
self.imag = imag
def __str__(self):
"""String representation of the complex number"""
if self.imag >= 0:
return f"{self.real} + {self.imag}i"
else:
return f"{self.real} - {abs(self.imag)}i"
def __add__(self, other):
"""Overload the + operator for complex addition"""
if isinstance(other, ComplexNumber):
# Complex + Complex
return ComplexNumber(self.real + other.real, self.imag + other.imag)
elif isinstance(other, (int, float)):
# Complex + scalar
return ComplexNumber(self.real + other, self.imag)
else:
raise TypeError("Unsupported operand type")
def __mul__(self, other):
"""Overload the * operator for complex multiplication"""
if isinstance(other, ComplexNumber):
# Complex * Complex
# (a + bi) * (c + di) = (ac - bd) + (ad + bc)i
real = self.real * other.real - self.imag * other.imag
imag = self.real * other.imag + self.imag * other.real
return ComplexNumber(real, imag)
elif isinstance(other, (int, float)):
# Complex * scalar
return ComplexNumber(self.real * other, self.imag * other)
else:
raise TypeError("Unsupported operand type")
# Test the Vector2D class
v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)
print(f"v1 = {v1}")
print(f"v2 = {v2}")
print(f"v1 + v2 = {v1 + v2}")
print(f"v1 - v2 = {v1 - v2}")
print(f"v1 * v2 (dot product) = {v1 * v2}")
print(f"v1 * 2 (scaling) = {v1 * 2}")
print(f"v1 == Vector2D(3, 4): {v1 == Vector2D(3, 4)}")
print(f"v1 < v2: {v1 < v2}")
print(f"v1 magnitude: {v1.magnitude()}")
print(f"v2 magnitude: {v2.magnitude()}")
# Test the ComplexNumber class
c1 = ComplexNumber(3, 4)
c2 = ComplexNumber(1, -2)
print(f"\nc1 = {c1}")
print(f"c2 = {c2}")
print(f"c1 + c2 = {c1 + c2}")
print(f"c1 + 5 = {c1 + 5}")
print(f"c1 * c2 = {c1 * c2}")
print(f"c1 * 2 = {c1 * 2}")
Code Breakdown:
- We define two classes,
Vector2DandComplexNumber, each with several operator overloading methods. - These special methods have names like
__add__,__sub__,__mul__,__eq__, and__lt__, which correspond to operators like +, -, *, ==, and <. - The implementation of each method depends on the class and what makes sense for that type of object. For example:
- Vector addition is element-wise, while complex number addition has specific rules.
- Vector multiplication can be interpreted as a dot product or scaling, depending on the operand types.
- Complex number multiplication follows the formula (a + bi) * (c + di) = (ac - bd) + (ad + bc)i.
- Each overloaded operator can behave differently based on the types of the operands, demonstrating polymorphism.
Common Operator Overloading Methods in Python
| Method | Operator | Example |
|---|---|---|
__add__ |
+ | a + b |
__sub__ |
- | a - b |
__mul__ |
* | a * b |
__truediv__ |
/ | a / b |
__floordiv__ |
// | a // b |
__mod__ |
% | a % b |
__pow__ |
** | a ** b |
__eq__ |
== | a == b |
__lt__ |
< | a < b |
__gt__ |
> | a > b |
__le__ |
<= | a <= b |
__ge__ |
>= | a >= b |
__str__ |
str() | print(a) |
__repr__ |
repr() | repr(a) |
__len__ |
len() | len(a) |
__getitem__ |
[] | a[key] |
__setitem__ |
[] = | a[key] = value |
__call__ |
() | a() |
Key Insights about Operator Overloading:
- Operator overloading allows using standard Python operators with custom classes in ways that make sense for those classes.
- The same operator can behave differently depending on the types of the operands, which is polymorphism in action.
- You only need to implement the methods that make sense for your class—there's no requirement to implement all operators.
- Operator overloading is a key feature that makes Python code read more naturally, especially for mathematical or container-like objects.
Real-world analogy: Operator overloading is like how the + sign has different meanings in different contexts. In arithmetic, it means addition (2 + 3 = 5), but when working with strings, it means concatenation ("Hello" + "World" = "HelloWorld"). The operator behaves differently based on the types it's working with.
Function Polymorphism
Function polymorphism refers to the ability of built-in functions and methods to work with objects of different types. Python's built-in functions like len(), str(), print(), etc., can operate on a wide variety of objects, adapting their behavior based on the object's type.
Create a file named function_polymorphism.py with the following code:
# File: polymorphism/function_polymorphism.py
# len() function works on different types of objects
my_list = [1, 2, 3, 4, 5]
my_tuple = (1, 2, 3)
my_string = "Hello, World!"
my_dict = {"a": 1, "b": 2, "c": 3}
my_set = {1, 2, 3, 4, 5}
print(f"len(my_list): {len(my_list)}")
print(f"len(my_tuple): {len(my_tuple)}")
print(f"len(my_string): {len(my_string)}")
print(f"len(my_dict): {len(my_dict)}")
print(f"len(my_set): {len(my_set)}")
# print() function works with different types
print("\nprint() with different types:")
print(42)
print(3.14)
print("Hello")
print([1, 2, 3])
print({"name": "John", "age": 30})
# + operator works differently with different types
print("\n+ operator with different types:")
print(f"5 + 3 = {5 + 3}")
print(f'"Hello" + " World" = {"Hello" + " World"}')
print(f"[1, 2] + [3, 4] = {[1, 2] + [3, 4]}")
# Custom class supporting built-in functions
class CustomContainer:
def __init__(self, items):
self.items = items
def __len__(self):
return len(self.items)
def __str__(self):
return f"CustomContainer with {len(self)} items: {self.items}"
def __getitem__(self, index):
return self.items[index]
def __iter__(self):
return iter(self.items)
# Create a custom container and use built-in functions with it
container = CustomContainer([10, 20, 30, 40, 50])
print(f"\nlen(container): {len(container)}")
print(f"str(container): {str(container)}")
print(f"container[2]: {container[2]}")
print("\nIterating over container:")
for item in container:
print(item)
# max(), min(), sum() with different collections
numbers_list = [5, 2, 8, 1, 9]
numbers_tuple = (5, 2, 8, 1, 9)
numbers_set = {5, 2, 8, 1, 9}
print(f"\nmax(numbers_list): {max(numbers_list)}")
print(f"min(numbers_tuple): {min(numbers_tuple)}")
print(f"sum(numbers_set): {sum(numbers_set)}")
# sorted() with different collections
print(f"\nsorted(numbers_list): {sorted(numbers_list)}")
print(f"sorted(numbers_tuple): {sorted(numbers_tuple)}")
print(f"sorted(numbers_set): {sorted(numbers_set)}")
print(f"sorted('hello'): {sorted('hello')}")
print(f"sorted(container): {sorted(container)}") # Works with our custom container too!
Code Breakdown:
- We demonstrate how various built-in functions like
len(),print(),max(),min(),sum(), andsorted()work with different types of objects. - We also show how operators like + behave differently depending on the operand types.
- We create a custom
CustomContainerclass that implements special methods like__len__,__str__,__getitem__, and__iter__, allowing it to work with built-in functions. - This demonstrates how Python's duck typing allows objects to work with built-in functions as long as they implement the required methods.
How Function Polymorphism Works:
- Python's built-in functions often rely on specific special methods (dunder methods) being implemented by objects.
- For example,
len(obj)callsobj.__len__(),str(obj)callsobj.__str__(), and so on. - If an object implements these methods, it can work with the corresponding built-in functions, regardless of its actual type.
- This is another expression of duck typing and polymorphism in Python: objects are judged by what they can do, not by what they are.
Key Insights about Function Polymorphism:
- Built-in functions often rely on special methods to work with custom objects.
- By implementing these special methods, you can make your custom classes work seamlessly with Python's built-in functions.
- This form of polymorphism is what makes Python's standard library so flexible and extensible.
- It's another example of "programming to an interface, not an implementation" in Python.
Real-world analogy: Function polymorphism is like how we use the word "size" in everyday language. We can talk about the size of a house (square footage), the size of a person (height/weight), the size of a file (bytes), or the size of a company (number of employees). The word "size" means something different in each context, but we understand what it means based on what we're talking about.
Real-World Examples of Polymorphism in Python Web Development
Let's create a file called real_world_examples.py that demonstrates how polymorphism is used in actual web development contexts:
# File: polymorphism/real_world_examples.py
from abc import ABC, abstractmethod
from datetime import datetime
import json
# Example 1: Database Adapters with Polymorphism
class DatabaseAdapter(ABC):
"""Abstract base class for database adapters"""
@abstractmethod
def connect(self):
"""Connect to the database"""
pass
@abstractmethod
def execute(self, query, params=None):
"""Execute a query"""
pass
@abstractmethod
def fetch_one(self):
"""Fetch a single result"""
pass
@abstractmethod
def fetch_all(self):
"""Fetch all results"""
pass
@abstractmethod
def close(self):
"""Close the connection"""
pass
class PostgreSQLAdapter(DatabaseAdapter):
"""PostgreSQL database adapter"""
def connect(self):
print("Connecting to PostgreSQL database")
# In a real implementation, this would use psycopg2 or another PostgreSQL driver
return self
def execute(self, query, params=None):
param_str = str(params) if params else "none"
print(f"Executing PostgreSQL query: {query} with params: {param_str}")
return self
def fetch_one(self):
print("Fetching one result from PostgreSQL")
# Simulated result
return {"id": 1, "name": "John Doe"}
def fetch_all(self):
print("Fetching all results from PostgreSQL")
# Simulated results
return [
{"id": 1, "name": "John Doe"},
{"id": 2, "name": "Jane Smith"}
]
def close(self):
print("Closing PostgreSQL connection")
class MySQLAdapter(DatabaseAdapter):
"""MySQL database adapter"""
def connect(self):
print("Connecting to MySQL database")
# In a real implementation, this would use mysql-connector or another MySQL driver
return self
def execute(self, query, params=None):
param_str = str(params) if params else "none"
print(f"Executing MySQL query: {query} with params: {param_str}")
return self
def fetch_one(self):
print("Fetching one result from MySQL")
# Simulated result
return {"id": 1, "name": "John Doe"}
def fetch_all(self):
print("Fetching all results from MySQL")
# Simulated results
return [
{"id": 1, "name": "John Doe"},
{"id": 2, "name": "Jane Smith"}
]
def close(self):
print("Closing MySQL connection")
class SQLiteAdapter(DatabaseAdapter):
"""SQLite database adapter"""
def connect(self):
print("Connecting to SQLite database")
# In a real implementation, this would use sqlite3
return self
def execute(self, query, params=None):
param_str = str(params) if params else "none"
print(f"Executing SQLite query: {query} with params: {param_str}")
return self
def fetch_one(self):
print("Fetching one result from SQLite")
# Simulated result
return {"id": 1, "name": "John Doe"}
def fetch_all(self):
print("Fetching all results from SQLite")
# Simulated results
return [
{"id": 1, "name": "John Doe"},
{"id": 2, "name": "Jane Smith"}
]
def close(self):
print("Closing SQLite connection")
# Example 2: Form Validation
class ValidationRule(ABC):
"""Abstract base class for validation rules"""
@abstractmethod
def validate(self, value):
"""Validate a value against this rule"""
pass
class RequiredRule(ValidationRule):
"""Validation rule requiring a non-empty value"""
def validate(self, value):
if not value:
return False, "This field is required"
return True, None
class MinLengthRule(ValidationRule):
"""Validation rule requiring a minimum length"""
def __init__(self, min_length):
self.min_length = min_length
def validate(self, value):
if len(str(value)) < self.min_length:
return False, f"Must be at least {self.min_length} characters"
return True, None
class EmailRule(ValidationRule):
"""Validation rule requiring an email format"""
def validate(self, value):
if not isinstance(value, str) or '@' not in value or '.' not in value:
return False, "Must be a valid email address"
return True, None
class NumericRule(ValidationRule):
"""Validation rule requiring a numeric value"""
def validate(self, value):
try:
float(value)
return True, None
except (ValueError, TypeError):
return False, "Must be a number"
class FormValidator:
"""Form validator that can use different validation rules"""
def __init__(self, fields):
self.fields = fields # Dictionary of field_name: [rules]
def validate(self, data):
errors = {}
for field_name, rules in self.fields.items():
field_value = data.get(field_name, '')
for rule in rules:
is_valid, error_message = rule.validate(field_value)
if not is_valid:
if field_name not in errors:
errors[field_name] = []
errors[field_name].append(error_message)
break # Stop checking this field once an error is found
return len(errors) == 0, errors
# Example 3: Template Rendering
class TemplateRenderer(ABC):
"""Abstract base class for template renderers"""
@abstractmethod
def render(self, template_name, context):
"""Render a template with the given context"""
pass
class SimpleRenderer(TemplateRenderer):
"""Simple string-replacement template renderer"""
def render(self, template_name, context):
with open(template_name, 'r') as file:
template = file.read()
# Simple string replacement
for key, value in context.items():
placeholder = f"{{{{ {key} }}}}"
template = template.replace(placeholder, str(value))
return template
class JinjaLikeRenderer(TemplateRenderer):
"""Jinja-like template renderer with more features"""
def render(self, template_name, context):
with open(template_name, 'r') as file:
template = file.read()
# This is a very simplified version of Jinja-like rendering
# In a real implementation, this would use actual Jinja2
# Replace variables
for key, value in context.items():
placeholder = f"{{{{ {key} }}}}"
template = template.replace(placeholder, str(value))
# Handle simple conditionals (very simplified)
import re
pattern = r"{{% if (\w+) %}}(.*?){{% endif %}}"
matches = re.finditer(pattern, template, re.DOTALL)
for match in matches:
var_name = match.group(1)
content = match.group(2)
if context.get(var_name):
template = template.replace(match.group(0), content)
else:
template = template.replace(match.group(0), '')
return template
# Example 4: Serialization
class Serializer(ABC):
"""Abstract base class for serializers"""
@abstractmethod
def serialize(self, obj):
"""Serialize an object to a string"""
pass
@abstractmethod
def deserialize(self, data):
"""Deserialize a string to an object"""
pass
class JSONSerializer(Serializer):
"""JSON serializer"""
def serialize(self, obj):
return json.dumps(obj)
def deserialize(self, data):
return json.loads(data)
class XMLSerializer(Serializer):
"""Simplified XML serializer"""
def serialize(self, obj):
if isinstance(obj, dict):
xml_parts = ['']
for key, value in obj.items():
xml_parts.append(f'<{key}>{value}{key}>')
xml_parts.append(' ')
return '\n'.join(xml_parts)
elif isinstance(obj, list):
xml_parts = ['']
for item in obj:
xml_parts.append(f'- {item}
')
xml_parts.append(' ')
return '\n'.join(xml_parts)
else:
return f'{obj} '
def deserialize(self, data):
# This is a very simplified XML parser, not for production use
import re
result = {}
# Extract key-value pairs
pattern = r'<(\w+)>(.*?)\1>'
matches = re.finditer(pattern, data)
for match in matches:
key = match.group(1)
value = match.group(2)
if key != 'root':
result[key] = value
return result
# Test the database adapters
def fetch_user(adapter, user_id):
"""Fetch a user using any database adapter"""
adapter.connect().execute("SELECT * FROM users WHERE id = %s", [user_id])
user = adapter.fetch_one()
adapter.close()
return user
print("Database Adapters Example:")
postgres_adapter = PostgreSQLAdapter()
mysql_adapter = MySQLAdapter()
sqlite_adapter = SQLiteAdapter()
user1 = fetch_user(postgres_adapter, 1)
user2 = fetch_user(mysql_adapter, 1)
user3 = fetch_user(sqlite_adapter, 1)
# Test the form validator
print("\nForm Validation Example:")
user_form_validator = FormValidator({
'username': [RequiredRule(), MinLengthRule(3)],
'email': [RequiredRule(), EmailRule()],
'age': [RequiredRule(), NumericRule()]
})
valid_data = {
'username': 'john_doe',
'email': 'john@example.com',
'age': '30'
}
invalid_data = {
'username': '',
'email': 'not-an-email',
'age': 'thirty'
}
is_valid, errors = user_form_validator.validate(valid_data)
print(f"Valid data: {is_valid}, Errors: {errors}")
is_valid, errors = user_form_validator.validate(invalid_data)
print(f"Invalid data: {is_valid}, Errors: {errors}")
# Test the template renderers (assuming template files exist)
"""
# Create test template files
with open('simple_template.html', 'w') as f:
f.write("{{ title }}
{{ content }}
")
with open('conditional_template.html', 'w') as f:
f.write("{{ title }}
{{ content }}
{% if show_footer %}{% endif %}")
print("\nTemplate Rendering Example:")
simple_renderer = SimpleRenderer()
jinja_renderer = JinjaLikeRenderer()
context = {
'title': 'Welcome',
'content': 'This is a test page',
'show_footer': True,
'footer': 'Copyright 2023'
}
simple_output = simple_renderer.render('simple_template.html', context)
jinja_output = jinja_renderer.render('conditional_template.html', context)
print(f"Simple renderer output: {simple_output[:50]}...")
print(f"Jinja-like renderer output: {jinja_output[:50]}...")
"""
# Test the serializers
print("\nSerialization Example:")
json_serializer = JSONSerializer()
xml_serializer = XMLSerializer()
data = {
'name': 'John Doe',
'email': 'john@example.com',
'age': 30
}
json_data = json_serializer.serialize(data)
xml_data = xml_serializer.serialize(data)
print(f"JSON serialized data: {json_data}")
print(f"XML serialized data: {xml_data}")
json_obj = json_serializer.deserialize(json_data)
xml_obj = xml_serializer.deserialize(xml_data)
print(f"JSON deserialized object: {json_obj}")
print(f"XML deserialized object: {xml_obj}")
Code Breakdown:
- Example 1: Database Adapters
- We define an abstract
DatabaseAdapterclass with methods likeconnect,execute,fetch_one, etc. - Concrete adapter classes (
PostgreSQLAdapter,MySQLAdapter,SQLiteAdapter) implement these methods for specific database systems. - The
fetch_userfunction demonstrates polymorphism by working with any database adapter, regardless of the specific implementation.
- We define an abstract
- Example 2: Form Validation
- We define a
ValidationRuleabstract class with avalidatemethod. - Concrete rule classes (
RequiredRule,MinLengthRule,EmailRule,NumericRule) implement this method for specific validation requirements. - The
FormValidatorclass works with any validation rule, demonstrating polymorphism through composition.
- We define a
- Example 3: Template Rendering
- We define a
TemplateRendererabstract class with arendermethod. - Concrete renderer classes (
SimpleRenderer,JinjaLikeRenderer) implement this method with different template rendering strategies. - The same template context can be used with different renderers, demonstrating polymorphism.
- We define a
- Example 4: Serialization
- We define a
Serializerabstract class withserializeanddeserializemethods. - Concrete serializer classes (
JSONSerializer,XMLSerializer) implement these methods for different serialization formats. - The same data can be serialized in different formats using different serializers, demonstrating polymorphism.
- We define a
These examples show how polymorphism is fundamental to modern web development frameworks and libraries. It enables modular, extensible, and maintainable code by allowing components to be swapped or extended easily without changing the code that uses them.
Best Practices for Using Polymorphism in Python
1. Embrace Duck Typing
Python's dynamic nature encourages duck typing. Don't check object types explicitly if you only need objects to support specific methods or operations.
# Good (duck typing)
def process_data(data_provider):
return data_provider.get_data()
# Less Pythonic (explicit type checking)
def process_data(data_provider):
if not isinstance(data_provider, DataProvider):
raise TypeError("data_provider must be a DataProvider")
return data_provider.get_data()
2. Use Abstract Base Classes for Complex Interfaces
When defining complex interfaces with many methods, consider using abstract base classes to formalize the interface and ensure implementing classes provide all required methods.
from abc import ABC, abstractmethod
class DataProvider(ABC):
@abstractmethod
def get_data(self):
pass
@abstractmethod
def save_data(self, data):
pass
3. Follow the SOLID Principles
Especially the Liskov Substitution Principle (LSP), which states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
4. Use Protocols for Structural Typing (Python 3.8+)
For simple interfaces, prefer Protocol classes from the typing module to formalize structural typing without requiring explicit inheritance.
from typing import Protocol
class Renderable(Protocol):
def render(self) -> str:
...
5. Keep Interfaces Small and Focused
Following the Interface Segregation Principle, create smaller, more focused interfaces rather than large, monolithic ones. This makes it easier for classes to implement only what they need.
6. Provide Good Error Messages
When duck typing fails (i.e., an object doesn't have an expected method), the default AttributeError might not be very informative. Consider adding more context to the error message.
def process_data(data_provider):
try:
return data_provider.get_data()
except AttributeError:
raise AttributeError(f"Object {data_provider} does not support the get_data() method required by process_data()")
7. Use Method Overriding Judiciously
When overriding methods from parent classes, make sure the overridden method adheres to the same contract as the parent method. Don't change the method's expected behavior drastically.
8. Document Expected Interfaces
Clearly document what methods and properties your code expects objects to have, especially when using duck typing.
def save_to_file(data, file_like_object):
"""
Save data to a file-like object.
Args:
data: The data to save
file_like_object: Any object with a write() method that accepts a string
"""
file_like_object.write(str(data))
Key Takeaways
- Polymorphism allows different objects to respond to the same method call or operation in their own unique ways, enabling more flexible and reusable code.
- Duck Typing is Python's approach to polymorphism, focusing on what an object can do (its methods and properties) rather than what it is (its type).
- Method Overriding allows subclasses to provide specific implementations of methods defined in parent classes, enabling behavior customization within an inheritance hierarchy.
- Operator Overloading allows custom classes to define how operators like +, -, *, etc., behave when applied to instances of the class.
- Function Polymorphism refers to the ability of built-in functions and methods to work with objects of different types, adapting their behavior based on the object's type.
- Real-World Applications of polymorphism in web development include database adapters, form validation, template rendering, and serialization, among many others.
- Following best practices like embracing duck typing, using abstract base classes for complex interfaces, and keeping interfaces small and focused will lead to more maintainable and flexible code.
Assignment: Implement a Polymorphic Content Management System
For today's assignment, you'll implement a simple content management system (CMS) that demonstrates various forms of polymorphism in Python.
Requirements:
- Design a base
Contentclass with common attributes and methods for different content types. - Implement at least three content type classes (e.g.,
TextContent,ImageContent,VideoContent) that inherit fromContentand override methods as appropriate. - Create a
ContentRendererabstract base class with methods for rendering content in different formats. - Implement at least two renderer classes (e.g.,
HTMLRenderer,MarkdownRenderer) that inherit fromContentRenderer. - Design a
ContentStoreabstract base class with methods for storing and retrieving content. - Implement at least two store classes (e.g.,
FileStore,DatabaseStore) that inherit fromContentStore. - Create a
CMSclass that can work with any content types, renderers, and stores, demonstrating polymorphism through composition. - Implement operator overloading for at least one of your classes (e.g., allow combining content items with the + operator).
- Include proper error handling and documentation.
Bonus Challenges:
- Implement a plugin system for the CMS where plugins must implement a specific interface to be loaded and used.
- Create a command-line interface for interacting with your CMS that demonstrates polymorphism in action.
- Implement a simple template system using duck typing that can render any object with specific attributes.
- Use Protocol classes from
typingto define interfaces and demonstrate static type checking with mypy.
Submit your work as a Python module with clear structure and organization. Be prepared to explain your design choices and how polymorphism enhances your system's flexibility and extensibility.
Further Reading and Resources
- Python Official Documentation: Special Method Names
- Real Python: Operator and Function Overloading in Python
- PEP 544: Protocols - Structural subtyping (static duck typing)
- Wikipedia: Duck Typing
- Design Patterns: Elements of Reusable Object-Oriented Software (Gang of Four book)
- Fluent Python by Luciano Ramalho (especially the chapters on data model, sequences, and object-oriented idioms)