Understanding Abstract Classes and Interfaces in Python
Abstract classes and interfaces are fundamental concepts in object-oriented programming that help create more structured, maintainable, and robust code. While these concepts originated in languages like Java and C#, Python provides its own mechanisms to implement them through the abc (Abstract Base Classes) module.
Think of abstract classes and interfaces as blueprints or contracts. They define what a class should do without specifying how it should do it. This separation between "what" and "how" enables polymorphism and promotes the design principle of "programming to an interface, not an implementation."
In today's session, we'll explore how to implement and use abstract classes and interfaces in Python, understand their benefits, and see how they're applied in real-world web development scenarios.
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/ ├── abstract_classes/ │ ├── __init__.py (empty file to make the folder a package) │ ├── abc_basics.py │ ├── abstract_methods.py │ ├── python_interfaces.py │ ├── protocol_classes.py │ └── real_world_examples.py
All code examples will be saved in these files, allowing you to organize and revisit these concepts easily.
Abstract Base Classes in Python
Let's start by understanding what abstract base classes are and how to implement them in Python using the abc module. Create a file named abc_basics.py with the following code:
# File: abstract_classes/abc_basics.py
import abc
class Shape(abc.ABC):
"""Abstract base class for shapes"""
def __init__(self, color):
self.color = color
@abc.abstractmethod
def area(self):
"""Calculate the area of the shape"""
pass
@abc.abstractmethod
def perimeter(self):
"""Calculate the perimeter of the shape"""
pass
def describe(self):
"""Non-abstract method that can be inherited as-is"""
return f"A {self.color} shape with area {self.area()} and perimeter {self.perimeter()}"
# Trying to instantiate an abstract class directly will raise an error
try:
shape = Shape("red")
except TypeError as e:
print(f"Error: {e}")
class Circle(Shape):
"""Concrete implementation of a Shape: Circle"""
def __init__(self, color, radius):
super().__init__(color)
self.radius = radius
def area(self):
"""Implementation of the abstract area method"""
import math
return math.pi * self.radius ** 2
def perimeter(self):
"""Implementation of the abstract perimeter method"""
import math
return 2 * math.pi * self.radius
class Rectangle(Shape):
"""Concrete implementation of a Shape: Rectangle"""
def __init__(self, color, width, height):
super().__init__(color)
self.width = width
self.height = height
def area(self):
"""Implementation of the abstract area method"""
return self.width * self.height
def perimeter(self):
"""Implementation of the abstract perimeter method"""
return 2 * (self.width + self.height)
# Create instances of concrete classes
circle = Circle("blue", 5)
rectangle = Rectangle("green", 4, 6)
print(circle.describe())
print(rectangle.describe())
# This would raise an error because Triangle doesn't implement all abstract methods
class IncompleteTriangle(Shape):
def __init__(self, color, base, height):
super().__init__(color)
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
# Missing perimeter implementation will cause an error when instantiating
try:
triangle = IncompleteTriangle("yellow", 3, 4)
except TypeError as e:
print(f"Error: {e}")
Code Breakdown:
- We import the
abcmodule, which provides the infrastructure for defining abstract base classes (ABCs). - We create an abstract base class
Shapeby inheriting fromabc.ABCand using the@abc.abstractmethoddecorator to define abstract methods. - Abstract methods (
areaandperimeter) declare what subclasses must implement but don't provide an implementation themselves. - Non-abstract methods (
describe) provide default behavior that subclasses can inherit or override. - We can't instantiate abstract classes directly; attempting to do so raises a
TypeError. - Concrete subclasses (
CircleandRectangle) must implement all abstract methods defined in the parent class. - The
IncompleteTriangleclass demonstrates what happens when a subclass doesn't implement all abstract methods.
Real-world analogy: Think of an abstract class like a blueprint for a building. The blueprint specifies what rooms should be in the building (abstract methods) but doesn't dictate how those rooms should be decorated or arranged (implementation details). Different types of buildings (concrete classes) must include all the required rooms but can arrange and decorate them differently.
Abstract Methods and Properties
Python's abc module provides decorators for creating abstract methods, class methods, static methods, and properties. Let's explore these capabilities in a file named abstract_methods.py:
# File: abstract_classes/abstract_methods.py
import abc
class Vehicle(abc.ABC):
"""Abstract base class for vehicles"""
def __init__(self, manufacturer, model, year):
self.manufacturer = manufacturer
self.model = model
self.year = year
self._fuel_level = 0 # Private-by-convention attribute
@abc.abstractmethod
def start_engine(self):
"""Start the vehicle's engine"""
pass
@abc.abstractmethod
def stop_engine(self):
"""Stop the vehicle's engine"""
pass
@abc.abstractproperty
def fuel_level(self):
"""Get the current fuel level (abstract property)"""
pass
@fuel_level.setter
@abc.abstractmethod
def fuel_level(self, value):
"""Set the fuel level (abstract property setter)"""
pass
@abc.abstractclassmethod
def get_vehicle_type(cls):
"""Get the type of vehicle (abstract class method)"""
pass
@abc.abstractstaticmethod
def get_wheel_count():
"""Get the number of wheels (abstract static method)"""
pass
class Car(Vehicle):
"""Concrete implementation of a Vehicle: Car"""
def __init__(self, manufacturer, model, year, fuel_capacity):
super().__init__(manufacturer, model, year)
self._fuel_capacity = fuel_capacity
def start_engine(self):
if self._fuel_level > 0:
return f"The {self.manufacturer} {self.model}'s engine is now running."
return f"The {self.manufacturer} {self.model} has no fuel!"
def stop_engine(self):
return f"The {self.manufacturer} {self.model}'s engine is now off."
@property
def fuel_level(self):
"""Implementation of the abstract fuel_level property"""
return self._fuel_level
@fuel_level.setter
def fuel_level(self, value):
"""Implementation of the abstract fuel_level setter"""
if value < 0:
raise ValueError("Fuel level cannot be negative")
if value > self._fuel_capacity:
value = self._fuel_capacity
self._fuel_level = value
@classmethod
def get_vehicle_type(cls):
"""Implementation of the abstract class method"""
return "Car"
@staticmethod
def get_wheel_count():
"""Implementation of the abstract static method"""
return 4
# Create and use a Car instance
car = Car("Toyota", "Corolla", 2022, 50)
car.fuel_level = 25 # Set fuel level to 50%
print(f"Vehicle Type: {Car.get_vehicle_type()}")
print(f"Wheel Count: {Car.get_wheel_count()}")
print(f"Fuel Level: {car.fuel_level}")
print(car.start_engine())
car.fuel_level = 0
print(car.start_engine())
Code Breakdown:
- We define various types of abstract members in the
Vehicleclass:@abc.abstractmethod: Regular abstract instance methods@abc.abstractproperty: Abstract properties@abc.abstractclassmethod: Abstract class methods@abc.abstractstaticmethod: Abstract static methods
- The
Carclass implements all of these abstract members with concrete implementations. - Notice how the property implementation uses the standard
@propertyand@fuel_level.setterdecorators. - Abstract class methods and static methods must be implemented as class methods and static methods in the subclass.
Important note: In modern Python (3.3+), @abc.abstractproperty is deprecated in favor of using @property and @abc.abstractmethod together:
@property
@abc.abstractmethod
def fuel_level(self):
pass
@fuel_level.setter
@abc.abstractmethod
def fuel_level(self, value):
pass
Real-world analogy: If we think of an abstract class as a job description, then abstract methods, properties, class methods, and static methods are like different responsibilities that a person filling the position must be able to handle. Some tasks require access to the employee's personal desk (instance methods), some relate to the department as a whole (class methods), and some are general company procedures that anyone should be able to follow (static methods).
Interfaces in Python
Unlike languages like Java or C#, Python doesn't have a specific interface keyword. However, we can use abstract base classes with only abstract methods to create interface-like structures. Create a file named python_interfaces.py with the following code:
# File: abstract_classes/python_interfaces.py
import abc
class DatabaseInterface(abc.ABC):
"""Interface for database operations"""
@abc.abstractmethod
def connect(self, connection_string):
"""Connect to the database"""
pass
@abc.abstractmethod
def disconnect(self):
"""Disconnect from the database"""
pass
@abc.abstractmethod
def execute_query(self, query, parameters=None):
"""Execute a query with optional parameters"""
pass
@abc.abstractmethod
def fetch_all(self):
"""Fetch all rows from the result set"""
pass
@abc.abstractmethod
def fetch_one(self):
"""Fetch a single row from the result set"""
pass
class PostgreSQLDatabase(DatabaseInterface):
"""PostgreSQL implementation of the DatabaseInterface"""
def __init__(self):
self.connection = None
self.cursor = None
def connect(self, connection_string):
print(f"Connecting to PostgreSQL with: {connection_string}")
# In a real implementation, we would use psycopg2 or another PostgreSQL driver
self.connection = f"PostgreSQL Connection: {connection_string}"
self.cursor = "PostgreSQL Cursor"
return True
def disconnect(self):
print("Disconnecting from PostgreSQL")
self.connection = None
self.cursor = None
def execute_query(self, query, parameters=None):
if not self.connection:
raise RuntimeError("Not connected to the database")
print(f"Executing PostgreSQL query: {query}")
if parameters:
print(f"With parameters: {parameters}")
return True
def fetch_all(self):
if not self.cursor:
raise RuntimeError("No active query result")
print("Fetching all rows from PostgreSQL")
# Simulated result set
return [{"id": 1, "name": "John"}, {"id": 2, "name": "Jane"}]
def fetch_one(self):
if not self.cursor:
raise RuntimeError("No active query result")
print("Fetching one row from PostgreSQL")
# Simulated result
return {"id": 1, "name": "John"}
class MySQLDatabase(DatabaseInterface):
"""MySQL implementation of the DatabaseInterface"""
def __init__(self):
self.connection = None
self.cursor = None
def connect(self, connection_string):
print(f"Connecting to MySQL with: {connection_string}")
# In a real implementation, we would use mysql-connector or another MySQL driver
self.connection = f"MySQL Connection: {connection_string}"
self.cursor = "MySQL Cursor"
return True
def disconnect(self):
print("Disconnecting from MySQL")
self.connection = None
self.cursor = None
def execute_query(self, query, parameters=None):
if not self.connection:
raise RuntimeError("Not connected to the database")
print(f"Executing MySQL query: {query}")
if parameters:
print(f"With parameters: {parameters}")
return True
def fetch_all(self):
if not self.cursor:
raise RuntimeError("No active query result")
print("Fetching all rows from MySQL")
# Simulated result set
return [{"id": 1, "name": "John"}, {"id": 2, "name": "Jane"}]
def fetch_one(self):
if not self.cursor:
raise RuntimeError("No active query result")
print("Fetching one row from MySQL")
# Simulated result
return {"id": 1, "name": "John"}
# Function that works with any class implementing the DatabaseInterface
def fetch_user_data(database, user_id):
"""Fetch user data using any database implementation"""
database.connect("host=localhost;user=admin;password=secret")
try:
database.execute_query("SELECT * FROM users WHERE id = %s", [user_id])
user = database.fetch_one()
return user
finally:
database.disconnect()
# Use both implementations interchangeably
postgres_db = PostgreSQLDatabase()
mysql_db = MySQLDatabase()
print("Fetching user data with PostgreSQL:")
user1 = fetch_user_data(postgres_db, 1)
print(f"User: {user1}")
print("\nFetching user data with MySQL:")
user2 = fetch_user_data(mysql_db, 1)
print(f"User: {user2}")
Code Breakdown:
- We define a
DatabaseInterfaceas an abstract base class with only abstract methods, making it function like an interface in other languages. - Two concrete implementations,
PostgreSQLDatabaseandMySQLDatabase, provide specific implementations for each database system. - The
fetch_user_datafunction demonstrates polymorphism—it works with any class that implements theDatabaseInterface, regardless of the specific implementation. - This demonstrates the principle of "programming to an interface, not an implementation," which makes code more flexible and easier to test.
Key Characteristics of Interfaces in Python
- Interfaces typically contain only abstract methods (no implementation).
- Classes can implement multiple interfaces (through multiple inheritance).
- Interfaces define a contract that implementing classes must fulfill.
- Interfaces enable polymorphism and dependency inversion.
Real-world analogy: Interfaces are like electrical outlets. They define a standard way to connect to a power source, but they don't specify how the electricity is generated. Different power plants (implementations) can generate electricity in different ways (coal, nuclear, solar), but as long as they deliver it through the standard outlet (interface), any appliance can use it without knowing or caring about the source.
Protocol Classes (Python 3.8+)
Python 3.8 introduced a new feature called "Protocols" through the typing module, providing a more Pythonic way to define interfaces with structural subtyping (duck typing). Create a file named protocol_classes.py with the following code:
# File: abstract_classes/protocol_classes.py
from typing import Protocol, runtime_checkable
from datetime import datetime
@runtime_checkable
class Serializable(Protocol):
"""Protocol defining serializable objects"""
def to_dict(self) -> dict:
"""Convert the object to a dictionary"""
...
def from_dict(self, data: dict) -> None:
"""Update the object from a dictionary"""
...
@runtime_checkable
class HasTimestamp(Protocol):
"""Protocol for objects with timestamp capabilities"""
@property
def created_at(self) -> datetime:
"""Get the creation timestamp"""
...
@property
def updated_at(self) -> datetime:
"""Get the update timestamp"""
...
class BlogPost:
"""Blog post class implementing Serializable and HasTimestamp protocols"""
def __init__(self, title, content, author):
self.title = title
self.content = content
self.author = author
self._created_at = datetime.now()
self._updated_at = self._created_at
def to_dict(self) -> dict:
"""Convert the blog post to a dictionary"""
return {
'title': self.title,
'content': self.content,
'author': self.author,
'created_at': self._created_at.isoformat(),
'updated_at': self._updated_at.isoformat()
}
def from_dict(self, data: dict) -> None:
"""Update the blog post from a dictionary"""
self.title = data.get('title', self.title)
self.content = data.get('content', self.content)
self.author = data.get('author', self.author)
# Update the updated_at timestamp
self._updated_at = datetime.now()
@property
def created_at(self) -> datetime:
"""Get the creation timestamp"""
return self._created_at
@property
def updated_at(self) -> datetime:
"""Get the update timestamp"""
return self._updated_at
class User:
"""User class implementing only Serializable protocol"""
def __init__(self, username, email):
self.username = username
self.email = email
def to_dict(self) -> dict:
"""Convert the user to a dictionary"""
return {
'username': self.username,
'email': self.email
}
def from_dict(self, data: dict) -> None:
"""Update the user from a dictionary"""
self.username = data.get('username', self.username)
self.email = data.get('email', self.email)
class Comment:
"""Comment class implementing only HasTimestamp protocol"""
def __init__(self, text, author):
self.text = text
self.author = author
self._created_at = datetime.now()
self._updated_at = self._created_at
@property
def created_at(self) -> datetime:
"""Get the creation timestamp"""
return self._created_at
@property
def updated_at(self) -> datetime:
"""Get the update timestamp"""
return self._updated_at
# Functions that work with protocol classes
def save_serializable(obj: Serializable, filename: str) -> None:
"""Save a serializable object to a file"""
import json
data = obj.to_dict()
print(f"Saving to {filename}: {data}")
# In a real implementation, we would actually write to the file
def print_timestamps(obj: HasTimestamp) -> None:
"""Print timestamps for an object"""
print(f"Created: {obj.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Updated: {obj.updated_at.strftime('%Y-%m-%d %H:%M:%S')}")
# Create objects
post = BlogPost("Python Protocols", "A guide to Protocol classes...", "John Doe")
user = User("johndoe", "john@example.com")
comment = Comment("Great post!", "Jane Smith")
# Check if objects implement protocols
print(f"Is BlogPost Serializable? {isinstance(post, Serializable)}")
print(f"Is BlogPost HasTimestamp? {isinstance(post, HasTimestamp)}")
print(f"Is User Serializable? {isinstance(user, Serializable)}")
print(f"Is User HasTimestamp? {isinstance(user, HasTimestamp)}")
print(f"Is Comment Serializable? {isinstance(comment, Serializable)}")
print(f"Is Comment HasTimestamp? {isinstance(comment, HasTimestamp)}")
# Use the functions with different objects
print("\nSaving BlogPost:")
save_serializable(post, "post.json")
print("\nSaving User:")
save_serializable(user, "user.json")
print("\nTimestamps for BlogPost:")
print_timestamps(post)
print("\nTimestamps for Comment:")
print_timestamps(comment)
# This would raise a TypeError at runtime
try:
print("\nTrying to save Comment (not Serializable):")
save_serializable(comment, "comment.json")
except TypeError as e:
print(f"Error: {e}")
try:
print("\nTrying to print timestamps for User (not HasTimestamp):")
print_timestamps(user)
except TypeError as e:
print(f"Error: {e}")
Code Breakdown:
- We define two protocols,
SerializableandHasTimestamp, using theProtocolclass fromtyping. - The
@runtime_checkabledecorator allows runtime checking withisinstance(). - Protocol methods use
...as a body, indicating they're method signatures without implementations. - We implement three classes:
BlogPost(implements both protocols),User(implements onlySerializable), andComment(implements onlyHasTimestamp). - The functions
save_serializableandprint_timestampsaccept any object that adheres to the respective protocol. - We use
isinstance()to check if objects implement the protocols at runtime. - Trying to use an object with a function that expects a different protocol raises a
TypeError.
Key Advantages of Protocols
- Structural Subtyping: Classes don't need to explicitly inherit from or implement protocols—they just need to have the required methods/attributes.
- Static Type Checking: Protocols work with static type checkers like mypy to catch type errors before runtime.
- Duck Typing: Protocols formalize Python's "duck typing" philosophy—"If it walks like a duck and quacks like a duck, then it probably is a duck."
- No Runtime Overhead: Unless using
@runtime_checkable, protocols have no runtime cost; they're purely for static type checking.
Real-world analogy: Protocols are like informal qualifications for a job. Instead of requiring specific certifications (explicit inheritance), you just need to demonstrate the skills (have the right methods). If someone can perform all the tasks required for the job, it doesn't matter where or how they learned to do them.
Real-World Examples in Python Web Development
Let's create a file called real_world_examples.py that demonstrates how abstract classes and interfaces are used in actual web development contexts:
# File: abstract_classes/real_world_examples.py
import abc
from typing import Protocol, List, Dict, Any, Optional
# Example 1: ORM (Object-Relational Mapping) Models
class Model(abc.ABC):
"""Abstract base class for database models"""
@classmethod
@abc.abstractmethod
def find_by_id(cls, id):
"""Find a record by ID"""
pass
@classmethod
@abc.abstractmethod
def find_all(cls):
"""Find all records"""
pass
@abc.abstractmethod
def save(self):
"""Save the record to the database"""
pass
@abc.abstractmethod
def delete(self):
"""Delete the record from the database"""
pass
class SQLModel(Model):
"""SQL implementation of the Model class"""
def __init__(self, **kwargs):
self.id = kwargs.get('id')
self._data = kwargs
@classmethod
def find_by_id(cls, id):
print(f"SQL query: SELECT * FROM {cls.__name__.lower()} WHERE id = {id}")
# Simulated database query
return cls(id=id, name=f"Object {id}")
@classmethod
def find_all(cls):
print(f"SQL query: SELECT * FROM {cls.__name__.lower()}")
# Simulated database query
return [cls(id=i, name=f"Object {i}") for i in range(1, 4)]
def save(self):
if self.id:
print(f"SQL query: UPDATE {self.__class__.__name__.lower()} SET {self._data} WHERE id = {self.id}")
else:
print(f"SQL query: INSERT INTO {self.__class__.__name__.lower()} VALUES ({self._data})")
self.id = 1 # Simulated ID assignment
return True
def delete(self):
print(f"SQL query: DELETE FROM {self.__class__.__name__.lower()} WHERE id = {self.id}")
return True
class User(SQLModel):
"""User model extending SQLModel"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.username = kwargs.get('username')
self.email = kwargs.get('email')
# Example 2: Web Authentication Providers
class AuthProvider(abc.ABC):
"""Abstract base class for authentication providers"""
@abc.abstractmethod
def authenticate(self, username, password):
"""Authenticate a user"""
pass
@abc.abstractmethod
def get_user(self, user_id):
"""Get a user by ID"""
pass
@abc.abstractmethod
def create_user(self, username, password, **kwargs):
"""Create a new user"""
pass
class LocalAuthProvider(AuthProvider):
"""Local database authentication provider"""
def authenticate(self, username, password):
print(f"Authenticating user {username} with local database")
# Simulated authentication
return {"id": 1, "username": username}
def get_user(self, user_id):
print(f"Getting user {user_id} from local database")
# Simulated user retrieval
return {"id": user_id, "username": f"user{user_id}"}
def create_user(self, username, password, **kwargs):
print(f"Creating user {username} in local database")
# Simulated user creation
return {"id": 1, "username": username}
class OAuth2Provider(AuthProvider):
"""OAuth2 authentication provider"""
def __init__(self, provider_name, client_id, client_secret):
self.provider_name = provider_name
self.client_id = client_id
self.client_secret = client_secret
def authenticate(self, username, password):
print(f"Authenticating user with {self.provider_name} OAuth2")
# Simulated OAuth2 authentication
return {"id": 1, "username": username, "oauth_provider": self.provider_name}
def get_user(self, user_id):
print(f"Getting user {user_id} from {self.provider_name} OAuth2")
# Simulated user retrieval
return {"id": user_id, "username": f"user{user_id}", "oauth_provider": self.provider_name}
def create_user(self, username, password, **kwargs):
print(f"Creating user {username} with {self.provider_name} OAuth2")
# Simulated user creation
return {"id": 1, "username": username, "oauth_provider": self.provider_name}
# Example 3: Template Rendering (using Protocol)
class TemplateRenderer(Protocol):
"""Protocol for template renderers"""
def render(self, template_name: str, context: Dict[str, Any]) -> str:
"""Render a template with the given context"""
...
class JinjaRenderer:
"""Jinja2 template renderer"""
def render(self, template_name: str, context: Dict[str, Any]) -> str:
print(f"Rendering template {template_name} with Jinja2")
# Simulated Jinja2 rendering
return f"{context.get('title', '')}
"
class MakoRenderer:
"""Mako template renderer"""
def render(self, template_name: str, context: Dict[str, Any]) -> str:
print(f"Rendering template {template_name} with Mako")
# Simulated Mako rendering
return f"{context.get('title', '')}
"
# Example 4: Web Framework Request Handlers
class RequestHandler(abc.ABC):
"""Abstract base class for HTTP request handlers"""
def __init__(self, request):
self.request = request
@abc.abstractmethod
def get(self, *args, **kwargs):
"""Handle HTTP GET requests"""
pass
@abc.abstractmethod
def post(self, *args, **kwargs):
"""Handle HTTP POST requests"""
pass
def handle_request(self, method, *args, **kwargs):
"""Dispatch the request to the appropriate method"""
if method.lower() == 'get':
return self.get(*args, **kwargs)
elif method.lower() == 'post':
return self.post(*args, **kwargs)
return {"error": "Method not allowed"}
class HomeHandler(RequestHandler):
"""Home page handler"""
def get(self, *args, **kwargs):
return {"title": "Home Page", "content": "Welcome to our website!"}
def post(self, *args, **kwargs):
return {"error": "POST not supported on home page"}
class ContactHandler(RequestHandler):
"""Contact form handler"""
def get(self, *args, **kwargs):
return {"title": "Contact Us", "content": "Contact form"}
def post(self, *args, **kwargs):
return {"success": "Message sent successfully"}
# Test the examples
# Example 1: ORM Models
print("ORM Models Example:")
user = User(username="john", email="john@example.com")
user.save()
users = User.find_all()
print()
# Example 2: Authentication Providers
print("Authentication Providers Example:")
local_auth = LocalAuthProvider()
oauth_auth = OAuth2Provider("Google", "client123", "secret456")
local_user = local_auth.authenticate("john", "password123")
oauth_user = oauth_auth.authenticate("john", "token123")
print()
# Example 3: Template Renderers
print("Template Renderers Example:")
jinja = JinjaRenderer()
mako = MakoRenderer()
# We can use either renderer without checking its type
def render_page(renderer: TemplateRenderer, template_name: str, context: Dict[str, Any]) -> str:
return renderer.render(template_name, context)
jinja_output = render_page(jinja, "home.html", {"title": "Home"})
mako_output = render_page(mako, "home.html", {"title": "Home"})
print()
# Example 4: Request Handlers
print("Request Handlers Example:")
# Simulate a request object
class Request:
def __init__(self, method, path, data=None):
self.method = method
self.path = path
self.data = data or {}
home_request = Request("GET", "/")
contact_get_request = Request("GET", "/contact")
contact_post_request = Request("POST", "/contact", {"email": "john@example.com", "message": "Hello!"})
home_handler = HomeHandler(home_request)
contact_handler = ContactHandler(contact_post_request)
print("Home Handler Result:")
print(home_handler.handle_request("GET"))
print("\nContact Handler Result:")
print(contact_handler.handle_request("POST"))
Code Breakdown:
- Example 1: Demonstrates an abstract
Modelclass for an ORM (Object-Relational Mapping) system, similar to what you might find in Django or SQLAlchemy. TheSQLModelimplementation provides concrete methods for database operations, and theUserclass extends it for specific user-related functionality. - Example 2: Shows an authentication system with an abstract
AuthProviderclass and two implementations:LocalAuthProviderfor database authentication andOAuth2Providerfor OAuth2-based authentication. This pattern allows swapping authentication providers without changing the application code. - Example 3: Uses the Protocol approach to define a
TemplateRendererinterface. BothJinjaRendererandMakoRendererimplement this protocol, allowing them to be used interchangeably in therender_pagefunction. - Example 4: Implements a request handling system similar to web frameworks like Flask or Django. The abstract
RequestHandlerclass defines the interface for handling HTTP requests, and concrete handlers likeHomeHandlerandContactHandlerimplement specific behavior for different routes.
These examples show how abstract classes and interfaces are fundamental to modern web development frameworks and libraries. They enable modular, extensible, and maintainable code by separating interface from implementation and allowing components to be swapped or extended easily.
Abstract Classes vs. Interfaces: When to Use Each
Abstract Classes
Use when:
- You want to share code among several closely related classes
- The classes that will extend your abstract class have many common methods or fields
- You want to declare non-abstract methods that subclasses can use or override
- You need to define a template method pattern with some default behavior
Interfaces (Protocol Classes)
Use when:
- You want to define a contract that unrelated classes can implement
- You need to achieve something similar to multiple inheritance (a class can implement multiple interfaces)
- You want to specify the behavior of a particular data type but not concerned about who implements its behavior
- You want to take advantage of polymorphism without forcing inheritance relationships
Comparison Table
| Feature | Abstract Classes | Interfaces (Protocols) |
|---|---|---|
| Implementation | Can provide method implementations | No method implementations (just signatures) |
| Inheritance | A class can inherit from only one abstract class | A class can implement multiple interfaces |
| State | Can have instance variables and constructor | Typically no state, just method signatures |
| Purpose | Code reuse and defining a common base class | Defining a contract for behavior |
| Relationship | "is-a" relationship (inheritance) | "can-do" relationship (capability) |
Concrete example: Consider a system for drawing shapes. An abstract class might be appropriate for a Shape base class if all shapes share common properties like position and color, and some common methods like calculating area. Interfaces might be appropriate for capabilities like Drawable or Rotatable that could apply to shapes and other objects.
Best Practices
1. Keep Interfaces Simple and Focused
Follow the Interface Segregation Principle (ISP): "Clients should not be forced to depend upon interfaces that they do not use." Create smaller, more focused interfaces rather than large, monolithic ones.
# Good: Focused interfaces
class Readable(Protocol):
def read(self) -> str: ...
class Writable(Protocol):
def write(self, data: str) -> None: ...
# Bad: Monolithic interface
class FileHandler(Protocol):
def read(self) -> str: ...
def write(self, data: str) -> None: ...
def create(self) -> None: ...
def delete(self) -> None: ...
def copy(self, destination: str) -> None: ...
# Too many methods in one interface
2. Favor Composition Over Inheritance
Abstract classes are a form of inheritance. Remember that inheritance can sometimes lead to rigid hierarchies. When possible, consider composition (using objects as components) as an alternative.
3. Design for Extension
Follow the Open/Closed Principle: "Software entities should be open for extension but closed for modification." Design abstract classes and interfaces that can be extended without modifying the original code.
4. Document Your Abstract Classes and Interfaces
Clear documentation is essential for abstract classes and interfaces. Document the contract, the expected behavior, and any implementation requirements for each abstract method.
5. Use Type Hints
Type hints make your code more readable and help catch errors early. Use them to specify the expected types for abstract method parameters and return values.
class Repository(abc.ABC):
@abc.abstractmethod
def find_by_id(self, id: int) -> Optional[Dict[str, Any]]:
"""Find a record by ID, returns None if not found"""
pass
6. Be Consistent with Method Signatures
When implementing abstract methods in subclasses, maintain consistent method signatures (parameter names and types, return types). This makes your code more predictable and easier to maintain.
7. Provide Base Implementations When Appropriate
If most subclasses would implement a method similarly, consider providing a default implementation in the abstract class that subclasses can call or override as needed.
Key Takeaways
- Abstract Base Classes in Python are created using the
abcmodule and provide a way to define classes that cannot be instantiated directly but must be subclassed. - Abstract Methods declare what subclasses must implement without providing an implementation. They act as a contract that concrete subclasses must fulfill.
- Interfaces in Python are typically implemented as abstract classes with only abstract methods. They define a contract for behavior that implementing classes must provide.
- Protocol Classes (Python 3.8+) offer a more Pythonic approach to interfaces through structural subtyping, formalizing Python's duck typing philosophy.
- Real-World Applications of abstract classes and interfaces in web development include ORM models, authentication providers, template renderers, and request handlers.
- The choice between abstract classes and interfaces depends on whether you're looking to share implementation (abstract classes) or just define a contract (interfaces/protocols).
- Following best practices like keeping interfaces simple, favoring composition, and designing for extension will lead to more maintainable and flexible code.
Assignment: Extend Your Previous Class System with Abstract Classes and Interfaces
For today's assignment, you'll extend the class-based system you created in previous lessons by implementing abstract classes and interfaces.
Requirements:
- Create at least one abstract base class that defines common attributes and methods for a group of related classes.
- Implement at least two concrete subclasses that inherit from your abstract base class.
- Define at least one interface (using either the
abcmodule or Protocol classes) that specifies a set of methods that unrelated classes might implement. - Create at least two classes that implement your interface.
- Implement a function or class that demonstrates polymorphism by working with any object that implements your interface.
- Include docstrings and comments to explain your code's structure and design decisions.
- Create a demonstration script that shows how your abstract classes and interfaces enable code reuse and polymorphism.
Bonus Challenges:
- Implement a class that inherits from an abstract base class and also implements one or more interfaces, demonstrating how both concepts can be combined.
- Create a layered architecture with abstract classes at each layer (e.g., abstract models, abstract services, abstract controllers).
- Implement a simple plugin system where plugins must implement a specific interface to be usable by the main application.
- Use Protocol classes from
typingto define interfaces and demonstrate how static type checking works with structural subtyping.
Submit your work as a Python module with clear structure and organization. Be prepared to explain your design choices and how abstract classes and interfaces enhance your system's flexibility and extensibility.
Further Reading and Resources
- Python Official Documentation: Abstract Base Classes
- PEP 544: Protocols - Structural subtyping (static duck typing)
- Python typing.Protocol Documentation
- Real Python: Implementing an Interface in Python
- Design Patterns: Elements of Reusable Object-Oriented Software (Gang of Four book)
- Clean Architecture: A Craftsman's Guide to Software Structure and Design by Robert C. Martin