What is the Strategy Pattern?
Bookish Definition: The Strategy Pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
The Strategy Pattern is like the buffet of algorithms—you get to pick whichever one you want, right when you need it. It’s a behavioral design pattern that organizes algorithms into a nice, neat family, wraps each one up like a present, and lets you swap them out whenever you fancy. The best part? The algorithm and the context that uses it can live their own lives, no strings attached!
Why use the Strategy Pattern?
The Strategy Pattern is like a Swiss Army knife for algorithms. It’s a great way to organize and manage different algorithms, especially when you have a bunch of them that do similar things. Here are a few reasons why you might want to use the Strategy Pattern:
- Flexibility: The Strategy Pattern lets you swap out algorithms on the fly. You can pick the algorithm you want at runtime, without changing the context that uses it.
- Reusability: The Strategy Pattern makes it easy to reuse algorithms. You can use the same algorithm in different contexts, without duplicating code.
- Testability: The Strategy Pattern makes it easy to test algorithms in isolation. You can test each algorithm independently, without worrying about the context that uses it.
What is the need for the Strategy Pattern?
Scenario: Enhancing Redis Key Management with the Strategy Pattern
Managing keys in a Redis system can become complex, especially when different use cases require different key handling strategies. By employing the Strategy Pattern along with abstract classes, we can design a flexible and maintainable Redis system. This approach allows different key management strategies, such as FIFO (First-In, First-Out), to be applied where needed, with the ability to share common functionality across different strategies.
The Challenge
In a Redis system, managing how keys are stored, retrieved, and processed can vary depending on the requirements:
- FIFO (First-In, First-Out): Keys are processed in the order they were added.
- LIFO (Last-In, First-Out): Keys are processed in the reverse order.
- Priority: Keys are processed based on assigned priority levels.
Directly implementing these different strategies within a single Redis class can lead to code that is difficult to maintain. Instead, by using the Strategy Pattern, we can encapsulate each strategy into its own class, allowing for clean and flexible management of keys.
The Solution
Define a Strategy Interface: Create an interface that defines the common methods for key handling strategies. This interface will be implemented by the different key handling strategy classes.
We define a protocol KeyHandlingStrategy
that specifies the methods required for key management strategies. The add_key
, retrieve_key
, and get_key_order
methods are common to all key handling strategies.
from typing import List, Protocol
class KeyHandlingStrategy(Protocol):
def add_key(self, key: str, keys: List[str]) -> None:
pass
def retrieve_key(self, keys: List[str]) -> str:
pass
def get_key_order(self, keys: List[str]) -> str:
pass
Implement Concrete Strategy Classes: Create concrete classes that implement the strategy interface. Each class will provide its own implementation of the key handling methods.
We define three concrete classes for the FIFO, LIFO, and Priority key handling strategies. Each class implements the KeyHandlingStrategy
interface with its own logic for adding, retrieving, and ordering keys.
from dataclasses import dataclass, field
@dataclass
class FIFOKeyHandlingStrategy:
default_priority: int = 0
def add_key(self, key: str, keys: List[str], priority: int = None) -> None:
keys.append((key, self.default_priority)) # Default priority for FIFO
def retrieve_key(self, keys: List[str]) -> str:
if keys:
return keys.pop(0)[0] # FIFO: Removes and returns the first key
return "No keys available"
def get_key_order(self, keys: List[str]) -> str:
return " -> ".join([key for key, _ in keys]) # FIFO order
@dataclass
class LIFOKeyHandlingStrategy:
default_priority: int = 0
def add_key(self, key: str, keys: List[str], priority: int = None) -> None:
keys.append((key, self.default_priority)) # Default priority for LIFO
def retrieve_key(self, keys: List[str]) -> str:
if keys:
return keys.pop()[0] # LIFO: Removes and returns the last key
return "No keys available"
def get_key_order(self, keys: List[str]) -> str:
return " -> ".join([key for key, _ in reversed(keys)]) # LIFO order
@dataclass
class PriorityKeyHandlingStrategy:
priorities: List[Tuple[str, int]] = field(default_factory=list)
def add_key(self, key: str, keys: List[str], priority: int = None) -> None:
priority = priority if priority is not None else 0
self.priorities.append((key, priority))
self.priorities.sort(key=lambda x: x[1], reverse=True)
def retrieve_key(self, keys: List[str]) -> str:
if self.priorities:
return self.priorities.pop(0)[0]
return "No keys available"
def get_key_order(self, keys: List[str]) -> str:
return " -> ".join([key for key, _ in self.priorities])
Create a Context Class: Define a context class that uses the strategy interface to manage keys. The context class will hold a reference to the current key handling strategy and delegate key management operations to it.
We define a RedisKeyManager
class that takes a KeyHandlingStrategy
object as a parameter. The RedisKeyManager
class provides methods to add keys, retrieve keys, and display the order of keys based on the selected strategy.
class RedisKeyManager:
def __init__(self, strategy: KeyHandlingStrategy):
self.strategy = strategy
self.keys: List[str] = []
def add_key(self, key: str, priority: int = None) -> None:
self.strategy.add_key(key, self.keys, priority)
def retrieve_key(self) -> str:
return self.strategy.retrieve_key(self.keys)
def show_key_order(self) -> str:
return self.strategy.get_key_order(self.keys)
Using the Strategy Pattern: Now that we have defined the strategy interface, implemented concrete strategy classes, and created a context class, we can use the Strategy Pattern to manage keys in a Redis system.
# Using FIFO strategy with default priority
fifo_manager = RedisKeyManager(FIFOKeyHandlingStrategy())
fifo_manager.add_key("Key1")
fifo_manager.add_key("Key2")
fifo_manager.add_key("Key3")
print("FIFO Order:", fifo_manager.show_key_order()) # Output: Key1 -> Key2 -> Key3
print("Retrieve FIFO:", fifo_manager.retrieve_key()) # Output: Key1
print("After Retrieval FIFO Order:", fifo_manager.show_key_order()) # Output: Key2 -> Key3
# Using LIFO strategy with default priority
lifo_manager = RedisKeyManager(LIFOKeyHandlingStrategy())
lifo_manager.add_key("KeyA")
lifo_manager.add_key("KeyB")
lifo_manager.add_key("KeyC")
print("LIFO Order:", lifo_manager.show_key_order()) # Output: KeyC -> KeyB -> KeyA
print("Retrieve LIFO:", lifo_manager.retrieve_key()) # Output: KeyC
print("After Retrieval LIFO Order:", lifo_manager.show_key_order()) # Output: KeyB -> KeyA
# Using Priority strategy with custom priority
priority_manager = RedisKeyManager(PriorityKeyHandlingStrategy())
priority_manager.add_key("KeyX", priority=5)
priority_manager.add_key("KeyY", priority=1)
priority_manager.add_key("KeyZ", priority=10)
print("Priority Order:", priority_manager.show_key_order()) # Output: KeyZ -> KeyX -> KeyY
print("Retrieve Priority:", priority_manager.retrieve_key()) # Output: KeyZ
print("After Retrieval Priority Order:", priority_manager.show_key_order()) # Output: KeyX -> KeyY
Conclusion: Future-Proofing with Protocols in Redis Manager
By using protocols in the Redis manager example, we’ve designed a system that’s not only flexible but also future-proof. Here’s how:
-
Flexibility: The protocol-based approach allows us to introduce new strategies or modify existing ones without needing to alter the core system. For instance, if a new key-handling strategy emerges, it can be seamlessly integrated as long as it adheres to the protocol’s structure, without requiring inheritance from a specific base class.
-
Loose Coupling: Protocols promote loose coupling, where the Redis manager doesn’t need to know the exact class it’s working with, just that the class follows the expected interface. This reduces dependencies and makes the system easier to maintain and extend.
-
Scalability: As Redis evolves or as new requirements emerge, we can easily scale the system by adding new strategies or adjusting priorities without refactoring the existing codebase. This ensures that our solution remains adaptable to future changes in Redis or the application’s needs.
-
Type Safety: Protocols enhance type safety by allowing static type checkers to verify that the strategies used are valid. This helps catch errors early in development, making the system more robust and reliable.
By leveraging protocols, we’ve built a Redis management system that’s not only efficient and adaptable today but is also well-equipped to handle tomorrow’s challenges. This approach aligns with best practices in modern software development, ensuring that our solution remains relevant and functional as requirements evolve.