Design Patterns in Python: A Comprehensive Guide with Detailed Examples
Elshad Karimov
Founder of AppMillers | Oracle ERP Fusion Cloud Expert | Teaching more than 200k people How to Code.
Design patterns are tried-and-tested solutions to common programming problems. They provide a reusable and efficient way to solve issues that developers face in software design. Python, being an object-oriented and flexible programming language, lends itself well to the implementation of design patterns. This article explores some of the most widely used design patterns in Python with detailed examples.
What Are Design Patterns?
Design patterns are best practices that help developers solve recurring problems in software design. While not code templates, they offer a structural guide that can be adapted to suit specific needs. Design patterns can be classified into three broad categories:
1. Singleton Pattern
Category: Creational
Ensures a class has only one instance and provides a global point of access to that instance.
Use Case:
Example:
In Python, the Singleton pattern can be implemented in multiple ways. Here’s a simple example using the __new__method.
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(Singleton, cls).__new__(cls)
return cls._instance
# Usage
singleton1 = Singleton()
singleton2 = Singleton()
print(singleton1 is singleton2) # True, both references point to the same instance
In the example above, the __new__ method ensures that only one instance of the Singleton class is created. When multiple objects are instantiated, they point to the same instance.
2. Singleton Design Pattern — Loading Pre Trained AI Models
To load pre-trained AI models using the Hugging Face Transformers library with the Singleton Design Pattern, we can ensure that only one instance of a model is created and reused across the entire application. This is particularly useful when working with resource-heavy models (e.g., BERT, GPT-3), ensuring that the model is loaded once and reused efficiently.
Below is an implementation of the Singleton pattern to load pre-trained models using Hugging Face Transformers.
Step-by-Step Implementation
Example: Implementing Singleton for Loading a BERT Model
from transformers import AutoTokenizer, AutoModel
import threading
class ModelLoader:
_instance = None
_lock = threading.Lock() # Ensure thread safety for the Singleton
def __new__(cls, model_name="bert-base-uncased"):
with cls._lock:
if cls._instance is None:
print(f"Loading model: {model_name}")
cls._instance = super(ModelLoader, cls).__new__(cls)
cls._instance.model_name = model_name
cls._instance.tokenizer = AutoTokenizer.from_pretrained(model_name)
cls._instance.model = AutoModel.from_pretrained(model_name)
return cls._instance
def get_tokenizer(self):
return self._instance.tokenizer
def get_model(self):
return self._instance.model
# Usage of the Singleton
def main():
# First load
loader1 = ModelLoader()
tokenizer1 = loader1.get_tokenizer()
model1 = loader1.get_model()
# Attempt to create another instance
loader2 = ModelLoader()
tokenizer2 = loader2.get_tokenizer()
model2 = loader2.get_model()
# Check if both instances are the same (Singleton)
print(loader1 is loader2) # True
print(model1 is model2) # True
print(tokenizer1 is tokenizer2) # True
if __name__ == "__main__":
main()
Explanation:
2. Model and Tokenizer Loading:
3. Reusing the Singleton Instance:
Output:
Loading model: bert-base-uncased
True
True
True
This output confirms that the model and tokenizer are loaded only once, and subsequent calls reuse the same instance.
Why Use Singleton for Model Loading?
Extending to Other Hugging Face Models
The same pattern can be used to load any other Hugging Face model, such as GPT-2 or T5. You can simply pass the desired model name to the ModelLoaderclass.
Example: Loading GPT-2 with Singleton Pattern
class ModelLoader:
_instance = None
_lock = threading.Lock()
def __new__(cls, model_name="gpt2"):
with cls._lock:
if cls._instance is None:
print(f"Loading model: {model_name}")
cls._instance = super(ModelLoader, cls).__new__(cls)
cls._instance.model_name = model_name
cls._instance.tokenizer = AutoTokenizer.from_pretrained(model_name)
cls._instance.model = AutoModel.from_pretrained(model_name)
return cls._instance
Simply replace "bert-base-uncased" with "gpt2" or any other pre-trained model name to load different models.
By implementing the Singleton Design Pattern to load pre-trained models using the Hugging Face Transformers library, you ensure that the model and tokenizer are loaded only once, saving computational resources.
This approach is ideal for applications where the same model is reused multiple times, such as in web services, real-time processing systems, or large-scale NLP pipelines.
3. Factory Pattern
Category: Creational
Defines an interface for creating objects but allows subclasses to alter the type of objects that will be created.
Use Case:
Example:
Here’s an implementation of the Factory Pattern for creating different types of animals.
from abc import ABC, abstractmethod
# Abstract Product
class Animal(ABC):
@abstractmethod
def speak(self):
pass
# Concrete Products
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
# Factory
class AnimalFactory:
@staticmethod
def get_animal(animal_type):
if animal_type == "dog":
return Dog()
elif animal_type == "cat":
return Cat()
else:
return None
# Usage
factory = AnimalFactory()
dog = factory.get_animal("dog")
cat = factory.get_animal("cat")
print(dog.speak()) # Output: Woof!
print(cat.speak()) # Output: Meow!
In this example, AnimalFactory decides which object to instantiate based on the input parameter. This pattern abstracts object creation and hides the details of object creation from the client.
3. Observer Pattern
Category: Behavioral
Defines a one-to-many relationship between objects such that when one object changes state, all its dependents are notified and updated automatically.
Use Case:
Example:
# Subject
class Subject:
def __init__(self):
self._observers = []
def attach(self, observer):
self._observers.append(observer)
def detach(self, observer):
self._observers.remove(observer)
def notify(self):
for observer in self._observers:
observer.update(self)
# Observer
class Observer:
def update(self, subject):
pass
# Concrete Observers
class ConcreteObserverA(Observer):
def update(self, subject):
print("Observer A: Reacted to the event")
class ConcreteObserverB(Observer):
def update(self, subject):
print("Observer B: Reacted to the event")
# Usage
subject = Subject()
observer_a = ConcreteObserverA()
observer_b = ConcreteObserverB()
subject.attach(observer_a)
subject.attach(observer_b)
subject.notify()
In the Observer pattern, the Subject class manages a list of observers and notifies them when a change occurs. The observers (in this case ConcreteObserverA and ConcreteObserverB) receive updates and react to the changes.
4. Strategy Pattern
Category: Behavioral
Defines a family of algorithms, encapsulates each one, and makes them interchangeable. This pattern allows the algorithm to vary independently from clients that use it.
Use Case:
Example:
# Base Component
class Coffee:
def cost(self):
return 5
# Decorator
class MilkDecorator:
def __init__(self, coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost() + 2
class SugarDecorator:
def __init__(self, coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost() + 1
# Usage
coffee = Coffee()
print(f"Basic coffee cost: {coffee.cost()}") # Output: 5
# Add milk
coffee_with_milk = MilkDecorator(coffee)
print(f"Coffee with milk cost: {coffee_with_milk.cost()}") # Output: 7
# Add sugar
coffee_with_milk_and_sugar = SugarDecorator(coffee_with_milk)
print(f"Coffee with milk and sugar cost: {coffee_with_milk_and_sugar.cost()}") # Output: 8
In this example, the Context class uses a strategy object to perform a task. By changing the strategy at runtime, you can alter the behavior of the context without modifying its implementation.
5. Decorator Pattern
Category: Structural
Allows behavior to be added to individual objects, dynamically, without affecting the behavior of other objects from the same class.
Use Case:
Example:
# Base Component
class Coffee:
def cost(self):
return 5
# Decorator
class MilkDecorator:
def __init__(self, coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost() + 2
class SugarDecorator:
def __init__(self, coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost() + 1
# Usage
coffee = Coffee()
print(f"Basic coffee cost: {coffee.cost()}") # Output: 5
# Add milk
coffee_with_milk = MilkDecorator(coffee)
print(f"Coffee with milk cost: {coffee_with_milk.cost()}") # Output: 7
# Add sugar
coffee_with_milk_and_sugar = SugarDecorator(coffee_with_milk)
print(f"Coffee with milk and sugar cost: {coffee_with_milk_and_sugar.cost()}") # Output: 8
The Decorator pattern allows the dynamic addition of behavior. In this case, we are decorating a coffee object with additional features like milk and sugar without modifying the original Coffee class.
6. Command Pattern
Category: Behavioral
Encapsulates a request as an object, allowing for parameterization of clients with different requests, queuing of requests, and logging them.
Use Case:
Example:
# Command Interface
class Command:
def execute(self):
pass
# Concrete Commands
class TurnOnLightCommand(Command):
def __init__(self, light):
self.light = light
def execute(self):
self.light.turn_on()
class TurnOffLightCommand(Command):
def __init__(self, light):
self.light = light
def execute(self):
self.light.turn_off()
# Receiver
class Light:
def turn_on(self):
print("Light is On")
def turn_off(self):
print("Light is Off")
# Invoker
class RemoteControl:
def __init__(self):
self._commands = []
def add_command(self, command):
self._commands.append(command)
def execute_commands(self):
for command in self._commands:
command.execute()
# Usage
light = Light()
turn_on = TurnOnLightCommand(light)
turn_off = TurnOffLightCommand(light)
remote = RemoteControl()
remote.add_command(turn_on)
remote.add_command(turn_off)
remote.execute_commands()
The Command pattern encapsulates requests as objects, allowing for various operations (turning the light on or off) to be treated as commands that can be executed in sequence or stored for later execution.
7. Chain of Responsibility Pattern
Category: Behavioral
Allow multiple objects to handle a request by passing it along the chain until an object handles it. This decouples the sender of the request from its receivers.
Scenario: A Customer Support System
In a typical customer support system, different types of requests (like a complaint, technical support request, or billing inquiry) are handled by different departments. The Chain of Responsibility design pattern fits well here because each department will either process the request or pass it along the chain if it’s outside their scope.
Chain of Responsibility allows several handlers to process a request without explicitly linking the sender of the request to the receiver. It creates a chain where each handler decides whether to process the request or pass it on.
Example: Handling Customer Support Requests
In this example, we’ll have the following handlers:
Each handler will attempt to process a request. If it cannot, it passes the request to the next handler in the chain.
Step-by-Step Code Implementation:
1. Abstract Handler
This class will define a base structure for all handlers, with methods to handle requests and pass them along the chain.
2. Concrete Handlers
Each handler class will attempt to process the request if it matches its responsibility; otherwise, it will pass the request to the next handler
# Abstract Handle
class Handler:
"""Abstract handler class that defines the interface for handling requests."""
def __init__(self):
self._next_handler = None
def set_next(self, handler):
"""Sets the next handler in the chain."""
self._next_handler = handler
return handler
def handle(self, request):
"""Handles the request or passes it to the next handler."""
if self._next_handler:
return self._next_handler.handle(request)
return None
# Concrete Handlers
class ComplaintHandler(Handler):
"""Handler for processing complaints."""
def handle(self, request):
if request["type"] == "complaint":
return f"ComplaintHandler: Processing complaint -> {request['message']}"
else:
return super().handle(request)
class TechnicalSupportHandler(Handler):
"""Handler for processing technical support requests."""
def handle(self, request):
if request["type"] == "technical":
return f"TechnicalSupportHandler: Handling technical support request -> {request['message']}"
else:
return super().handle(request)
class BillingHandler(Handler):
"""Handler for processing billing inquiries."""
def handle(self, request):
if request["type"] == "billing":
return f"BillingHandler: Processing billing request -> {request['message']}"
else:
return super().handle(request)
# Client code to set up the chain
def main():
# Create handlers
complaint_handler = ComplaintHandler()
technical_handler = TechnicalSupportHandler()
billing_handler = BillingHandler()
# Set up the chain of responsibility
complaint_handler.set_next(technical_handler).set_next(billing_handler)
# Create requests
requests = [
{"type": "complaint", "message": "The product arrived damaged."},
{"type": "technical", "message": "The website is not loading."},
{"type": "billing", "message": "I was overcharged on my last bill."},
{"type": "other", "message": "I have a general question about your service."}
]
# Process each request through the chain
for req in requests:
response = complaint_handler.handle(req)
if response:
print(response)
else:
print(f"No handler found for request type: {req['type']}")
if __name__ == "__main__":
main()
Explanation:
2. Concrete Handlers:
3. Chain Setup: The ComplaintHandler is the first in the chain, followed by the TechnicalSupportHandler, and finally the BillingHandler.
4. Client Code:
Output:
ComplaintHandler: Processing complaint -> The product arrived damaged.
TechnicalSupportHandler: Handling technical support request -> The website is not loading.
BillingHandler: Processing billing request -> I was overcharged on my last bill.
No handler found for request type: other
Breakdown:
Advantages of Using Chain of Responsibility Pattern:
In this example, the Chain of Responsibility pattern allows us to efficiently handle different types of customer service requests without hardcoding conditional logic. The pattern creates a clear and modular solution, making it easier to add new types of requests or modify the order of processing.
8. Flyweight Pattern
Category: Structural
The Flyweight Pattern is a structural design pattern that allows programs to support large numbers of objects efficiently by sharing as much data as possible between them. The pattern reduces memory usage when you have many similar objects, and the shared objects are called flyweights.
The Flyweight Pattern aims to:
Example: Flyweight Pattern for Character Objects in a Text Editor
Let’s imagine a text editor that needs to display thousands of characters. Instead of creating a new object for each character, which would take up a lot of memory, we can use the Flyweight Pattern to share common data like the font style and size between all characters, while each character will only store unique data like its position and the specific character it represents.
Step-by-Step Implementation
# Flyweight class representing shared intrinsic state (font, size)
class CharacterFlyweight:
def __init__(self, font, size):
self.font = font
self.size = size
def display(self, character, position):
"""Displays the character with its unique (extrinsic) state."""
print(f"Character: {character} | Font: {self.font} | Size: {self.size} | Position: {position}")
# Flyweight Factory to manage shared flyweight objects
class CharacterFlyweightFactory:
def __init__(self):
self._flyweights = {}
def get_flyweight(self, font, size):
"""Return a flyweight (shared object) if it exists, else create a new one."""
key = (font, size)
if key not in self._flyweights:
print(f"Creating new flyweight for Font: {font}, Size: {size}")
self._flyweights[key] = CharacterFlyweight(font, size)
return self._flyweights[key]
def get_flyweight_count(self):
"""Returns the number of shared flyweights."""
return len(self._flyweights)
# Client class that uses the flyweights
class TextEditor:
def __init__(self):
self.factory = CharacterFlyweightFactory()
self.characters = []
def add_character(self, character, font, size, position):
"""Adds a character with its unique (extrinsic) state."""
flyweight = self.factory.get_flyweight(font, size)
self.characters.append((flyweight, character, position))
def display_text(self):
"""Displays the text by invoking each flyweight's display method."""
for flyweight, character, position in self.characters:
flyweight.display(character, position)
# Client code to test Flyweight pattern
def main():
editor = TextEditor()
# Add characters with shared font and size, but different positions
editor.add_character("H", "Arial", 12, (0, 0))
editor.add_character("e", "Arial", 12, (1, 0))
editor.add_character("l", "Arial", 12, (2, 0))
editor.add_character("l", "Arial", 12, (3, 0))
editor.add_character("o", "Arial", 12, (4, 0))
# Add characters with a different font and size
editor.add_character("W", "Times New Roman", 14, (0, 1))
editor.add_character("o", "Times New Roman", 14, (1, 1))
editor.add_character("r", "Times New Roman", 14, (2, 1))
editor.add_character("l", "Times New Roman", 14, (3, 1))
editor.add_character("d", "Times New Roman", 14, (4, 1))
# Display all characters in the editor
editor.display_text()
# Display number of flyweight objects created
print(f"Flyweights created: {editor.factory.get_flyweight_count()}")
if __name__ == "__main__":
main()
Explanation:
2. Flyweight Factory (CharacterFlyweightFactory):
3. Client (TextEditor):
Output:
Creating new flyweight for Font: Arial, Size: 12
Creating new flyweight for Font: Times New Roman, Size: 14
Character: H | Font: Arial | Size: 12 | Position: (0, 0)
Character: e | Font: Arial | Size: 12 | Position: (1, 0)
Character: l | Font: Arial | Size: 12 | Position: (2, 0)
Character: l | Font: Arial | Size: 12 | Position: (3, 0)
Character: o | Font: Arial | Size: 12 | Position: (4, 0)
Character: W | Font: Times New Roman | Size: 14 | Position: (0, 1)
Character: o | Font: Times New Roman | Size: 14 | Position: (1, 1)
Character: r | Font: Times New Roman | Size: 14 | Position: (2, 1)
Character: l | Font: Times New Roman | Size: 14 | Position: (3, 1)
Character: d | Font: Times New Roman | Size: 14 | Position: (4, 1)
Flyweights created: 2
Breakdown:
2. Extrinsic Data: Each character has unique data (position and the character itself), which is stored separately from the shared flyweight data.
3. Memory Optimization: Instead of creating a new object for every character in the text, we create flyweight objects for each distinct font and size, reusing them across characters with the same properties.
Advantages of Flyweight Pattern:
The Flyweight Pattern helps in scenarios where many similar objects are needed, and memory usage becomes a concern. By sharing intrinsic data and separating out the extrinsic state, this pattern ensures that the system remains lightweight and efficient. In this example, we successfully applied the Flyweight Pattern in a text editor context, where character formatting (font, size) is shared among multiple characters, and each character maintains its unique position and value.
9. Builder Pattern
The Builder Pattern is a creational design pattern that provides a way to construct a complex object step by step. It separates the construction of a complex object from its representation, allowing the same construction process to create different representations.
The Builder Pattern is used to:
When to Use the Builder Pattern
Components of the Builder Pattern
Example: Building a Custom Computer
We’ll use the Builder Pattern to create a custom computer with various configurations, such as different types of processors, RAM, storage, and other components.
Step-by-Step Implementation
2. Create the Builder Interface:
3. Implement Concrete Builders:
4. Create the Director:
5. Client Code:
Code Implementation
1. Product
class Computer:
def __init__(self):
self.processor = None
self.ram = None
self.storage = None
self.graphics_card = None
def __str__(self):
return (f"Computer with:\n"
f"Processor: {self.processor}\n"
f"RAM: {self.ram}\n"
f"Storage: {self.storage}\n"
f"Graphics Card: {self.graphics_card}\n")
2. Builder Interface
class ComputerBuilder:
def set_processor(self, processor):
raise NotImplementedError
def set_ram(self, ram):
raise NotImplementedError
def set_storage(self, storage):
raise NotImplementedError
def set_graphics_card(self, graphics_card):
raise NotImplementedError
def get_computer(self):
raise NotImplementedError
3. Concrete Builder
class GamingComputerBuilder(ComputerBuilder):
def __init__(self):
self.computer = Computer()
def set_processor(self, processor):
self.computer.processor = processor
def set_ram(self, ram):
self.computer.ram = ram
def set_storage(self, storage):
self.computer.storage = storage
def set_graphics_card(self, graphics_card):
self.computer.graphics_card = graphics_card
def get_computer(self):
return self.computer
class OfficeComputerBuilder(ComputerBuilder):
def __init__(self):
self.computer = Computer()
def set_processor(self, processor):
self.computer.processor = processor
def set_ram(self, ram):
self.computer.ram = ram
def set_storage(self, storage):
self.computer.storage = storage
def set_graphics_card(self, graphics_card):
self.computer.graphics_card = graphics_card
def get_computer(self):
return self.computer
4. Director
class ComputerDirector:
def __init__(self, builder):
self.builder = builder
def build_gaming_computer(self):
self.builder.set_processor("Intel i9")
self.builder.set_ram("32GB")
self.builder.set_storage("1TB SSD")
self.builder.set_graphics_card("NVIDIA RTX 3080")
def build_office_computer(self):
self.builder.set_processor("Intel i5")
self.builder.set_ram("16GB")
self.builder.set_storage("512GB SSD")
self.builder.set_graphics_card("Integrated Graphics")
6. Client Code
def main():
# Building a Gaming Computer
gaming_builder = GamingComputerBuilder()
director = ComputerDirector(gaming_builder)
director.build_gaming_computer()
gaming_computer = gaming_builder.get_computer()
print("Gaming Computer Built:")
print(gaming_computer)
# Building an Office Computer
office_builder = OfficeComputerBuilder()
director = ComputerDirector(office_builder)
director.build_office_computer()
office_computer = office_builder.get_computer()
print("Office Computer Built:")
print(office_computer)
if __name__ == "__main__":
main()
Explanation:
2. Builder Interface (ComputerBuilder):
3. Concrete Builders (GamingComputerBuilder, OfficeComputerBuilder):
4. Director (ComputerDirector):
5. Client Code:
Output:
领英推荐
Gaming Computer Built:
Computer with:
Processor: Intel i9
RAM: 32GB
Storage: 1TB SSD
Graphics Card: NVIDIA RTX 3080
Office Computer Built:
Computer with:
Processor: Intel i5
RAM: 16GB
Storage: 512GB SSD
Graphics Card: Integrated Graphics
Advantages of Builder Pattern:
The Builder Pattern is ideal for creating complex objects with many optional components or configurations. It simplifies the construction process by separating it from the final representation of the object, making it easier to manage and extend. In this example, we built various types of computers using the Builder Pattern, demonstrating its flexibility and effectiveness in managing complex object creation.
10. Iterator Pattern
The Iterator Pattern is a behavioral design pattern that allows sequential access to elements in a collection without exposing the underlying representation of the collection. It provides a way to access the elements of an aggregate object one at a time without exposing its internal structure.
The Iterator Pattern aims to:
Components of the Iterator Pattern
Example: Implementing an Iterator for a Custom Collection
Let’s create an example with a custom collection called BookCollection. This collection will store a list of books, and we’ll implement an iterator to traverse the collection.
1. Iterator Interface
The Iterator interface defines methods for accessing elements and checking if there are more elements.
from abc import ABC, abstractmethod
class Iterator(ABC):
@abstractmethod
def has_next(self):
"""Check if there are more elements to iterate."""
pass
@abstractmethod
def next(self):
"""Return the next element in the collection."""
pass
2. Concrete Iterator
The BookIterator implements the Iterator interface and provides the actual iteration logic.
class BookIterator(Iterator):
def __init__(self, books):
self._books = books
self._index = 0
def has_next(self):
"""Check if there are more books to iterate."""
return self._index < len(self._books)
def next(self):
"""Return the next book in the collection."""
if self.has_next():
book = self._books[self._index]
self._index += 1
return book
raise StopIteration("No more elements to iterate.")
3. Aggregate Interface
The Aggregate interface defines a method for creating an iterator.
class Aggregate(ABC):
@abstractmethod
def create_iterator(self):
"""Create an iterator for the aggregate collection."""
pass
4. Concrete Aggregate
The BookCollection implements the Aggregate interface and provides the iterator for the collection.
class BookCollection(Aggregate):
def __init__(self):
self._books = []
def add_book(self, book):
"""Add a book to the collection."""
self._books.append(book)
def create_iterator(self):
"""Create an iterator for the book collection."""
return BookIterator(self._books)
5. Client Code
The client code uses the BookCollection and BookIterator to access and iterate over the collection of books.
def main():
# Create a book collection and add books
collection = BookCollection()
collection.add_book("The Great Gatsby")
collection.add_book("To Kill a Mockingbird")
collection.add_book("1984")
collection.add_book("Pride and Prejudice")
# Create an iterator for the book collection
iterator = collection.create_iterator()
# Iterate over the collection and print the books
while iterator.has_next():
book = iterator.next()
print(f"Book: {book}")
if __name__ == "__main__":
main()
Explanation:
2. Concrete Iterator (BookIterator):
3. Aggregate Interface (Aggregate):
4. Concrete Aggregate (BookCollection):
5. Client Code:
Advantages of the Iterator Pattern:
The Iterator Pattern is a powerful tool for managing and traversing collections of objects. By providing a standard interface for iteration, it ensures that clients can access elements in a consistent manner without needing to know the underlying details of the collection’s implementation. In this example, we used the pattern to create a custom book collection and iterator, showcasing how to effectively manage and traverse complex data structures.
11. Composite Pattern
The Composite Pattern is a structural design pattern used to allow clients to treat individual objects and compositions of objects uniformly. It is particularly useful when dealing with tree-like structures, where objects are organized into hierarchies.
The Composite Pattern aims to:
Components of the Composite Pattern
Example: File System Hierarchy
We’ll use the Composite Pattern to model a file system where both files and directories can be treated uniformly. In this example:
1. Component Interface
The FileSystemComponent defines common operations for files and directories.
from abc import ABC, abstractmethod
class FileSystemComponent(ABC):
@abstractmethod
def get_name(self):
"""Return the name of the component."""
pass
@abstractmethod
def print_structure(self, indent=0):
"""Print the structure of the component."""
pass
2. Leaf
The File class represents a file, which is a leaf in the file system hierarchy.
class File(FileSystemComponent):
def __init__(self, name):
self._name = name
def get_name(self):
return self._name
def print_structure(self, indent=0):
print(' ' * indent + self.get_name())
3. Composite
The Directory class represents a directory, which can contain other files and directories.
class Directory(FileSystemComponent):
def __init__(self, name):
self._name = name
self._children = []
def add(self, component):
self._children.append(component)
def remove(self, component):
self._children.remove(component)
def get_name(self):
return self._name
def print_structure(self, indent=0):
print(' ' * indent + self.get_name())
for child in self._children:
child.print_structure(indent + 2)
4. Client Code
The client code demonstrates how to create a file system hierarchy and print its structure.
def main():
# Create files
file1 = File("file1.txt")
file2 = File("file2.txt")
file3 = File("file3.txt")
# Create directories
directory1 = Directory("Directory1")
directory2 = Directory("Directory2")
# Add files to directories
directory1.add(file1)
directory1.add(file2)
directory2.add(file3)
# Create root directory and add subdirectories
root_directory = Directory("Root")
root_directory.add(directory1)
root_directory.add(directory2)
# Print the file system structure
root_directory.print_structure()
if __name__ == "__main__":
main()
Explanation:
2. Leaf (File):
3. Composite (Directory):
4. Client Code:
Output:
Root
Directory1
file1.txt
file2.txt
Directory2
file3.txt
Advantages of the Composite Pattern:
The Composite Pattern is a powerful design pattern for managing hierarchical collections of objects. It enables you to treat individual objects and compositions of objects in a uniform way, simplifying code and making it easier to work with complex structures. In this example, we modeled a file system hierarchy, demonstrating how both files and directories can be managed and displayed using the Composite Pattern.
12. Proxy Pattern
The Proxy Pattern is a structural design pattern that provides an object representing another object. The proxy acts as an intermediary or placeholder for the real object and can control access to it, manage its lifecycle, or add additional functionality.
The Proxy Pattern aims to:
Components of the Proxy Pattern
Example: Virtual Proxy for Loading Images
Let’s use the Proxy Pattern to manage the loading of large images. The proxy will delay the loading of the image until it’s actually needed (virtual proxy).
1. Subject Interface
The Image interface defines common operations for both the real image and the proxy.
from abc import ABC, abstractmethod
class Image(ABC):
@abstractmethod
def display(self):
"""Display the image."""
pass
2. RealObject
The RealImage class represents the actual image, which is loaded and displayed.
class RealImage(Image):
def __init__(self, filename):
self._filename = filename
self._load_image()
def _load_image(self):
"""Simulate loading a large image."""
print(f"Loading image: {self._filename}")
def display(self):
"""Display the image."""
print(f"Displaying image: {self._filename}")
3. Proxy
The ProxyImage class acts as an intermediary for RealImage. It controls access and can delay the creation of the RealImage until it's needed.
class ProxyImage(Image):
def __init__(self, filename):
self._filename = filename
self._real_image = None
def display(self):
"""Control access to the real image."""
if self._real_image is None:
self._real_image = RealImage(self._filename)
self._real_image.display()
4. Client Code
The client code uses the ProxyImage to access and display images.
def main():
# Create a proxy image
proxy_image = ProxyImage("large_image.jpg")
# Display the image
# The image will be loaded and displayed when needed
proxy_image.display()
# Display the image again
# The image will not be reloaded
proxy_image.display()
if __name__ == "__main__":
main()
Explanation:
2. RealObject (RealImage):
4. Proxy (ProxyImage):
5. Client Code:
Output:
Loading image: large_image.jpg
Displaying image: large_image.jpg
Displaying image: large_image.jpg
Advantages of the Proxy Pattern:
The Proxy Pattern is a versatile design pattern that provides a way to control access to an object and manage its lifecycle. By using a proxy, you can add additional functionality, such as lazy loading or access control, while keeping the real object unchanged. In this example, we demonstrated a virtual proxy for loading and displaying images, showcasing how proxies can be used to manage expensive operations efficiently.
14. Facade Pattern
The Facade Pattern is a structural design pattern that provides a simplified interface to a complex subsystem. It is used to provide a unified and simplified interface to a set of interfaces in a subsystem, making it easier to use the subsystem without dealing with its complexity.
The Facade Pattern aims to:
Components of the Facade Pattern
Example: Home Theater System
We’ll use the Facade Pattern to simplify interaction with a home theater system. The system includes various components such as a DVD player, projector, amplifier, and lights. The facade will provide a simple interface for turning on and off the home theater system, without needing to interact with each component directly.
1. Subsystem Classes
These classes represent the various components of the home theater system.
class DVDPlayer:
def on(self):
print("DVD Player is now ON.")
def off(self):
print("DVD Player is now OFF.")
def play(self, movie):
print(f"DVD Player is playing: {movie}")
def stop(self):
print("DVD Player stopped.")
class Projector:
def on(self):
print("Projector is now ON.")
def off(self):
print("Projector is now OFF.")
def set_input(self, input):
print(f"Projector input set to: {input}")
def wide_screen_mode(self):
print("Projector in wide screen mode.")
2. Facade
The HomeTheaterFacade class provides a simplified interface to the complex subsystem.
class HomeTheaterFacade:
def __init__(self, dvd_player, projector, amplifier, lights):
self._dvd_player = dvd_player
self._projector = projector
self._amplifier = amplifier
self._lights = lights
def watch_movie(self, movie):
print("Get ready to watch a movie...")
self._lights.dim(10)
self._projector.on()
self._projector.set_input("DVD")
self._projector.wide_screen_mode()
self._amplifier.on()
self._amplifier.set_volume(5)
self._amplifier.set_audio_mode("Surround")
self._dvd_player.on()
self._dvd_player.play(movie)
def end_movie(self):
print("Shutting down the home theater...")
self._dvd_player.stop()
self._dvd_player.off()
self._projector.off()
self._amplifier.off()
self._lights.dim(100)
3. Client Code
The client code uses the HomeTheaterFacade to interact with the home theater system in a simplified manner.
def main():
# Create subsystem components
dvd_player = DVDPlayer()
projector = Projector()
amplifier = Amplifier()
lights = Lights()
# Create the facade
home_theater = HomeTheaterFacade(dvd_player, projector, amplifier, lights)
# Use the facade to watch a movie
home_theater.watch_movie("Inception")
# End the movie and shut down the system
home_theater.end_movie()
if __name__ == "__main__":
main()
Explanation:
2. Facade (HomeTheaterFacade):
3. Client Code:
Output:
Get ready to watch a movie...
Lights dimmed to 10%
Projector is now ON.
Projector input set to: DVD
Projector in wide screen mode.
Amplifier is now ON.
Amplifier volume set to: 5
Amplifier audio mode set to: Surround
DVD Player is now ON.
DVD Player is playing: Inception
Shutting down the home theater...
DVD Player stopped.
DVD Player is now OFF.
Projector is now OFF.
Amplifier is now OFF.
Lights dimmed to 100%
Advantages of the Facade Pattern:
The Facade Pattern is a valuable design pattern for managing complexity in systems with multiple interacting components. By providing a unified and simplified interface, the facade makes it easier for clients to interact with complex subsystems, improving code readability and maintainability. In this example, we demonstrated how a home theater system can be simplified using a facade, allowing clients to control the system with ease.
15. Adapter Pattern
The Adapter Pattern is a structural design pattern that allows incompatible interfaces to work together. It acts as a bridge between two different interfaces, enabling objects with different interfaces to collaborate. The Adapter Pattern is particularly useful when integrating legacy code or systems with new code that uses a different interface.
The Adapter Pattern aims to:
Components of the Adapter Pattern
Example: Integrating a Legacy Media Player
Let’s say we have a modern media player interface (MediaPlayer) that expects to play MP4 and VLC files. However, we also have a legacy audio player (AudioPlayer) that only supports MP3 files. We’ll use the Adapter Pattern to integrate the legacy AudioPlayer into the modern media player system.
1. Target Interface
The MediaPlayer interface defines the expected operations for playing media files.
from abc import ABC, abstractmethod
class MediaPlayer(ABC):
@abstractmethod
def play(self, audio_type, filename):
"""Play a media file."""
pass
2. Adaptee (Legacy Class)
The AudioPlayer class represents a legacy player that only supports playing MP3 files.
class AudioPlayer:
def play_mp3(self, filename):
print(f"Playing MP3 file: {filename}")
3. Adapter
The MediaAdapter class implements the MediaPlayer interface and adapts the AudioPlayer (the legacy class) to be compatible with the new media player interface.
class MediaAdapter(MediaPlayer):
def __init__(self, audio_type):
self.audio_type = audio_type
self.audio_player = AudioPlayer()
def play(self, audio_type, filename):
if audio_type == "mp3":
self.audio_player.play_mp3(filename)
else:
print(f"Audio type {audio_type} not supported by AudioPlayer.")
4. Concrete Client
The AdvancedMediaPlayer class is the modern player that supports playing MP4 and VLC formats. It will use the MediaAdapter to handle MP3 files.
class AdvancedMediaPlayer(MediaPlayer):
def play(self, audio_type, filename):
if audio_type == "mp4":
print(f"Playing MP4 file: {filename}")
elif audio_type == "vlc":
print(f"Playing VLC file: {filename}")
elif audio_type == "mp3":
# Use the adapter for MP3 files
adapter = MediaAdapter(audio_type)
adapter.play(audio_type, filename)
else:
print(f"Invalid media type: {audio_type}. Supported types are MP4, VLC, and MP3.")
5. Client Code
The client code interacts with the AdvancedMediaPlayer, using it to play various types of media files.
def main():
player = AdvancedMediaPlayer()
# Play different types of media
player.play("mp3", "song.mp3") # MP3 file (using the adapter)
player.play("mp4", "video.mp4") # MP4 file (supported directly)
player.play("vlc", "movie.vlc") # VLC file (supported directly)
player.play("avi", "clip.avi") # Unsupported format
if __name__ == "__main__":
main()
Explanation:
2. Adaptee (AudioPlayer):
3. Adapter (MediaAdapter):
4. Client Code:
Output:
Playing MP3 file: song.mp3
Playing MP4 file: video.mp4
Playing VLC file: movie.vlc
Invalid media type: avi. Supported types are MP4, VLC, and MP3.
Advantages of the Adapter Pattern:
The Adapter Pattern is a versatile pattern that enables systems with incompatible interfaces to work together. By introducing an adapter, you can integrate legacy code or third-party libraries with modern systems without modifying the original classes. In this example, we demonstrated how to use the Adapter Pattern to integrate a legacy audio player that only supports MP3 files into a modern media player system, allowing seamless playback of various media formats.
Conclusion
Design patterns provide a reusable blueprint for solving common problems in software design. Python, with its object-oriented capabilities, makes implementing these patterns straightforward. Understanding and using design patterns such as Singleton, Factory, Observer, Strategy, Decorator, and Command can greatly improve the structure, maintainability, and scalability of your code.
Each of these patterns addresses specific challenges and enhances different aspects of software architecture. While it’s important to know when and how to use design patterns, overuse of them can also lead to unnecessary complexity, so it’s essential to apply them judiciously.
If you found them helpful:
Discover more in my online courses at Appmillers
Connect on LinkedIn: Elshad Karimov
Follow on X (Twitter) : Elshad Karimov
If you stop getting better, you've stopped being good.
3 周Elshad this is quite an overview! A videos series would be great!
Software Engineer at Smart Applications International Ltd
1 个月Good job