Structural Patterns: Enhancing Code Flexibility and Functionality
Hernán Chilabert
Posted on December 1, 2023
[UPDATED 12/7/2023]
✨ GitHub repo with real-world use cases: https://github.com/herchila/design-patterns/tree/main/structural_patterns
Adapter Pattern
Purpose: To allow incompatible interfaces to work together. It involves creating a 'wrapper' with an interface expected by the client, around the actual object.
Example: An adapter that allows Python objects to interact with a system that expects JSON formatted data.
import json
class PythonObject:
def __init__(self, data):
self.data = data
class JsonAdapter:
def __init__(self, python_object):
self.python_object = python_object
def to_json(self):
return json.dumps(self.python_object.data)
# Usage
python_data = PythonObject({"key": "value"})
adapter = JsonAdapter(python_data)
print(adapter.to_json()) # {"key": "value"}
Real-world use case
Let's consider a real-world use case involving a legacy payment system and a new payment processing service.
Scenario:
You have a legacy payment system in your application that processes payments using an old interface LegacyPayment. Now, you want to integrate a new payment processing service, NewPaymentGateway, which has a different interface.
Problem:
The new payment service (NewPaymentGateway) has a different method signature for processing payments, and you can't change the existing legacy code.
Solution:
You can use the Adapter Pattern to create an adapter that translates the interface of NewPaymentGateway into the interface expected by the legacy code, without changing the existing legacy code.
Here's how you can implement it in Python:
class LegacyPayment:
def process_payment(self, amount):
print(f"Processing legacy payment of {amount}")
class NewPaymentGateway:
def make_payment(self, amount):
print(f"Making new payment of {amount}")
class PaymentAdapter:
def __init__(self, new_payment_gateway):
self.new_payment_gateway = new_payment_gateway
def process_payment(self, amount):
# Translate (adapt) the method call here
self.new_payment_gateway.make_payment(amount)
# Existing system code
def process_payments(payment_processor, amount):
payment_processor.process_payment(amount)
# Old payment system
legacy_payment = LegacyPayment()
process_payments(legacy_payment, 100)
# New payment system with adapter
new_payment_gateway = NewPaymentGateway()
adapted_payment = PaymentAdapter(new_payment_gateway)
process_payments(adapted_payment, 150)
In this example, PaymentAdapter
adapts the interface of NewPaymentGateway
to the expected interface of the legacy system (LegacyPayment
). This way, the rest of your codebase can continue using the process_payment method without any changes, even when it's actually calling the new payment system.
Bridge Pattern
Purpose: To decouple an abstraction from its implementation so that the two can vary independently.
Example: A program to handle different message types (text, email) and different ways to send them (SMS, via App).
class MessageType:
def send(self, message): pass
class TextMessage(MessageType):
def send(self, message):
print(f"Text: {message}")
class EmailMessage(MessageType):
def send(self, message):
print(f"Email: {message}")
class MessageSender:
def __init__(self, message_type):
self.message_type = message_type
def send(self, message):
self.message_type.send(message)
# Usage
text = TextMessage()
email = EmailMessage()
sender = MessageSender(text)
sender.send("Hello World") # Text: Hello World
sender.message_type = email
sender.send("Hello World") # Email: Hello World
Real-world use case
Scenario:
Consider an application that needs to support different types of remote controls (the "abstraction") for various devices like TVs, DVDs, or Streaming Devices (the "implementation"). Each device type has its own implementation for basic operations like turn_on and turn_off, but the way you use a remote control should be consistent, regardless of the device it's controlling.
Problem:
Without the Bridge Pattern, you might end up with separate remote control classes for each device type (e.g., TVRemote, DVDRemote, etc.), leading to code duplication and difficulty in maintenance if you add more functions to the remotes or add new device types.
Solution:
Use the Bridge Pattern to decouple the remote controls from the devices they control. You can then extend them independently.
Here's how you can implement it in Python:
class Device:
def turn_on(self):
raise NotImplementedError
def turn_off(self):
raise NotImplementedError
class TV(Device):
def turn_on(self):
print("Turning on the TV.")
def turn_off(self):
print("Turning off the TV.")
class DVD(Device):
def turn_on(self):
print("Turning on the DVD Player.")
def turn_off(self):
print("Turning off the DVD Player.")
class RemoteControl:
def __init__(self, device):
self.device = device
def toggle_power(self):
if self.is_power_on:
self.device.turn_off()
self.is_power_on = False
else:
self.device.turn_on()
self.is_power_on = True
# Usage
tv = TV()
tv_remote = RemoteControl(tv)
tv_remote.toggle_power() # Turns on the TV
dvd = DVD()
dvd_remote = RemoteControl(dvd)
dvd_remote.toggle_power() # Turns on the DVD Player
In this example, RemoteControl
acts as the bridge between the Device
interface and its concrete implementations (TV
, DVD
). This allows you to add new types of devices or remote controls without affecting each other, adhering to the open-closed principle. For instance, if a new Streaming Device is introduced, you only need to create a new class implementing the Device
interface without changing the RemoteControl
abstraction.
Composite Pattern
Purpose: To treat individual objects and compositions of objects uniformly.
Example: A graphic design program where both individual shapes and groups of shapes should be treated the same way.
class Graphic:
def render(self): pass
class Circle(Graphic):
def render(self):
print("Rendering Circle")
class CompositeGraphic(Graphic):
def __init__(self):
self.graphics = []
def add(self, graphic):
self.graphics.append(graphic)
def render(self):
for graphic in self.graphics:
graphic.render()
# Usage
circle1 = Circle()
circle2 = Circle()
group = CompositeGraphic()
group.add(circle1)
group.add(circle2)
group.render() # Rendering Circle\nRendering Circle
Real-world use case
This pattern lets clients treat individual objects and compositions of objects uniformly.
Scenario:
Imagine you're building a file system with directories and files. Each directory can contain files and other directories. Here, both files and directories should be treated uniformly, as they can be either an individual object or a part of a larger structure.
Problem:
Without the Composite Pattern, you would have to check the type of each item in the file system (whether it's a file or a directory) before performing operations, leading to complex and nested conditionals in your code.
Solution:
Use the Composite Pattern to treat both files and directories as 'FileSystemEntity' objects. Both 'File' and 'Directory' classes implement this common interface. This way, you can apply operations like display or add uniformly on files and directories.
Here's how you can implement it in Python:
class FileSystemEntity:
def display(self):
raise NotImplementedError
class File(FileSystemEntity):
def __init__(self, name):
self.name = name
def display(self):
print(f"File: {self.name}")
class Directory(FileSystemEntity):
def __init__(self, name):
self.name = name
self.children = []
def add(self, entity):
self.children.append(entity)
def display(self):
print(f"Directory: {self.name}")
for child in self.children:
child.display()
# Usage
# Create files
file1 = File("File1.txt")
file2 = File("File2.doc")
# Create a directory and add files to it
directory = Directory("MyDirectory")
directory.add(file1)
directory.add(file2)
# Create a subdirectory and add it to the directory
subdirectory = Directory("MySubdirectory")
directory.add(subdirectory)
# Display structure
directory.display()
In this example, both File
and Directory
classes implement the FileSystemEntity
interface. The Directory
class can contain other directories or files, and when the display
method is called on a directory, it recursively calls display
on each of its children. This structure allows clients to treat individual files and compositions of files (directories) uniformly.
Decorator Pattern
Purpose: To add new functionalities to objects dynamically without altering their structure.
Example: Adding new features to a web application, like logging or authentication, without changing existing code.
class Component:
def operation(self): pass
class ConcreteComponent(Component):
def operation(self):
print("Basic Operation")
class Decorator(Component):
def __init__(self, component):
self.component = component
def operation(self):
self.component.operation()
class AdditionalFeatureDecorator(Decorator):
def operation(self):
print("Additional Feature")
super().operation()
# Usage
simple = ConcreteComponent()
decorated = AdditionalFeatureDecorator(simple)
decorated.operation() # Additional Feature\nBasic Operation
Real-world use case
Scenario:
Imagine you are developing a coffee shop application where you can customize a basic coffee by adding various extras like milk, sugar, whipped cream, or chocolate. Each addition changes the cost and description of the coffee.
Problem:
Without the Decorator Pattern, you would need to create a separate class for each combination of coffee and extras. This approach quickly becomes unwieldy and difficult to maintain, especially as new extras are added.
Solution:
Use the Decorator Pattern to create a base Coffee class and then dynamically add extra features to objects of this class using decorators.
Here's how you can implement it in Python:
class Coffee:
def get_cost(self):
raise NotImplementedError
def get_ingredients(self):
raise NotImplementedError
class SimpleCoffee(Coffee):
def get_cost(self):
return 2
def get_ingredients(self):
return 'Coffee'
class CoffeeDecorator(Coffee):
def __init__(self, coffee):
self.decorated_coffee = coffee
def get_cost(self):
return self.decorated_coffee.get_cost()
def get_ingredients(self):
return self.decorated_coffee.get_ingredients()
class WithMilk(CoffeeDecorator):
def __init__(self, coffee):
super().__init__(coffee)
def get_cost(self):
return super().get_cost() + 0.5
def get_ingredients(self):
return super().get_ingredients() + ', Milk'
class WithSugar(CoffeeDecorator):
def __init__(self, coffee):
super().__init__(coffee)
def get_cost(self):
return super().get_cost() + 0.25
def get_ingredients(self):
return super().get_ingredients() + ', Sugar'
# Usage
my_coffee = SimpleCoffee()
print(f"Cost: {my_coffee.get_cost()}; Ingredients: {my_coffee.get_ingredients()}")
my_coffee = WithMilk(my_coffee)
print(f"Cost: {my_coffee.get_cost()}; Ingredients: {my_coffee.get_ingredients()}")
my_coffee = WithSugar(my_coffee)
print(f"Cost: {my_coffee.get_cost()}; Ingredients: {my_coffee.get_ingredients()}")
In this example, SimpleCoffee
is a basic coffee without any extras. Decorators like WithMilk
and WithSugar
extend the functionality of SimpleCoffee
by adding additional ingredients and costs. Each decorator class (WithMilk
, WithSugar
) conforms to the Coffee
interface and wraps a Coffee
object, adding its own behavior to the existing methods (get_cost
, get_ingredients
). This way, you can dynamically compose objects to represent various combinations of coffee and extras.
Proxy Pattern
Purpose: To provide a surrogate or placeholder for another object to control access to it, like lazy initialization, logging, or access control.
Example: A virtual proxy for a resource-intensive object, like a high-resolution image.
class Image:
def display(self): pass
class HighResolutionImage(Image):
def display(self):
print("Displaying high-resolution image")
class ImageProxy(Image):
def __init__(self):
self.image = None
def display(self):
if self.image is None:
self.image = HighResolutionImage()
self.image.display()
# Usage
proxy_image = ImageProxy()
proxy_image.display() # Displaying high-resolution image
Posted on December 1, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
December 1, 2023