Protocols: Structural Subtyping (PEP 544)
Protocols provide structural subtyping (static duck typing) in Python, enabling flexible, type-safe interfaces without inheritance hierarchies.
Protocol Example: Database Connection
from typing import Protocol, Any, List, Dict, Optional
from dataclasses import dataclass
import json
class DatabaseProtocol(Protocol):
"""Protocol defining database operations."""
def connect(self) -> None:
"""Establish database connection."""
...
def execute(self, query: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Execute SQL query and return results."""
...
def close(self) -> None:
"""Close database connection."""
...
class PostgresDatabase:
"""PostgreSQL implementation."""
def connect(self) -> None:
print("Connecting to PostgreSQL...")
def execute(self, query: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
return [{"id": 1, "name": "John"}]
def close(self) -> None:
print("Closing PostgreSQL connection...")
class SQLiteDatabase:
"""SQLite implementation."""
def connect(self) -> None:
print("Connecting to SQLite...")
def execute(self, query: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
return [{"id": 1, "name": "Jane"}]
def close(self) -> None:
print("Closing SQLite connection...")
def run_database_operations(db: DatabaseProtocol) -> List[Dict[str, Any]]:
"""Works with any database implementing the protocol."""
db.connect()
result = db.execute("SELECT * FROM users", {})
db.close()
return result
# Usage
postgres_db = PostgresDatabase()
sqlite_db = SQLiteDatabase()
# Both work because they implement the protocol
run_database_operations(postgres_db)
run_database_operations(sqlite_db)
When to Use Protocols
Use Protocols when you need flexible interfaces without tight coupling, for dependency injection, testing (mocking), and supporting multiple implementations.
When to Avoid Protocols
Avoid Protocols when you need runtime type checking, when working with legacy code that doesn't support type hints, or when inheritance hierarchies are necessary.
Dataclasses with __slots__
Combine dataclasses with __slots__ for memory efficiency and performance optimization in high-performance applications.
Dataclass with Slots Example
from dataclasses import dataclass, field
from typing import List, Optional
import sys
class UserRegular:
"""Regular class for comparison."""
def __init__(
self,
user_id: int,
username: str,
email: str,
roles: List[str],
is_active: bool = True
):
self.user_id = user_id
self.username = username
self.email = email
self.roles = roles
self.is_active = is_active
@dataclass
class UserDataclass:
"""Dataclass without slots."""
user_id: int
username: str
email: str
roles: List[str] = field(default_factory=list)
is_active: bool = True
@dataclass
class UserSlots:
"""Dataclass with slots for memory efficiency."""
__slots__ = ['user_id', 'username', 'email', 'roles', 'is_active']
user_id: int
username: str
email: str
roles: List[str]
is_active: bool = True
def __init__(
self,
user_id: int,
username: str,
email: str,
roles: List[str] = None,
is_active: bool = True
):
self.user_id = user_id
self.username = username
self.email = email
self.roles = roles or []
self.is_active = is_active
def __repr__(self) -> str:
return (
f"UserSlots(user_id={self.user_id}, "
f"username='{self.username}', email='{self.email}')"
)
# Memory comparison
regular_user = UserRegular(1, "john_doe", "john@example.com", ["admin", "user"])
dataclass_user = UserDataclass(1, "john_doe", "john@example.com", ["admin", "user"])
slots_user = UserSlots(1, "john_doe", "john@example.com", ["admin", "user"])
print(f"Regular class memory: {sys.getsizeof(regular_user)} bytes")
print(f"Dataclass memory: {sys.getsizeof(dataclass_user)} bytes")
print(f"Slots memory: {sys.getsizeof(slots_user)} bytes")
| Feature | Regular Class | Dataclass | Dataclass + Slots |
|---|---|---|---|
| Memory Usage | High | Medium-High | Low |
| Attribute Access Speed | Slow | Fast | Fastest |
| Dynamic Attributes | Yes | Yes | No |
| Boilerplate Code | High | Low | Low |
| Inheritance Support | Full | Full | Limited |
Modern Design Patterns
Essential design patterns adapted for Python's dynamic nature and modern ecosystem.
Dependency Injection
Inject dependencies at runtime for testable, flexible, and maintainable code without tight coupling.
- Constructor injection for explicit dependencies
- Protocol-based interfaces
- Container management with injector library
- Mocking for unit testing
- Configuration-driven dependencies
class PaymentProcessor:
def __init__(self, gateway: PaymentGateway):
self.gateway = gateway
def process(self, amount: float):
return self.gateway.charge(amount)
Repository Pattern
Abstract data access layer to separate business logic from data storage concerns.
- Generic repositories with type parameters
- Async/await support for database operations
- Unit of Work pattern integration
- Multiple database backend support
- Caching layer integration
class UserRepository:
async def get_by_id(self, id: int) -> Optional[User]:
async def save(self, user: User):
async def delete(self, id: int):
Factory Pattern
Create objects without exposing instantiation logic, using modern Python features.
- Factory functions with type hints
- Class methods as alternative constructors
- Registry pattern for plugin systems
- Dependency injection integration
- Configuration-based object creation
@dataclass
class NotificationFactory:
def create(self, type: str) -> Notification:
if type == "email":
return EmailNotification()
elif type == "sms":
return SMSNotification()
Service Layer
Organize business logic into services that coordinate between repositories and other services.
- Transaction management
- Error handling and logging
- Async operation coordination
- Validation and business rules
- Event publishing integration
class UserService:
def __init__(self, repo: UserRepository):
self.repo = repo
async def register(self, user_data: UserCreate):
# Business logic here
user = User(**user_data.dict())
await self.repo.save(user)
Best Practices for Advanced Python OOP
1. Prefer Composition Over Inheritance
Use composition and dependency injection instead of deep inheritance hierarchies. This creates more flexible, testable, and maintainable code.
2. Use Type Hints Consistently
Always add type hints to public APIs, function signatures, and class definitions. Use Protocols for interfaces and Generics for container classes.
3. Implement __slots__ for Performance
Use __slots__ in classes that will have many instances to reduce memory usage and improve attribute access speed by 20-40%.
4. Separate Concerns with Patterns
Use Repository pattern for data access, Service pattern for business logic, and Factory pattern for object creation to maintain clean architecture.
5. Design for Testability
Write classes that are easy to test by injecting dependencies, avoiding global state, and using interfaces (Protocols) that can be mocked.
6. Use Context Managers
Implement __enter__ and __exit__ methods for resource management (files, database connections, locks) to ensure proper cleanup.
7. Embrace Async/Await Patterns
Design classes that can work in async contexts when dealing with I/O operations. Use async __aenter__ and __aexit__ for async context managers.