Structural Design Patterns in Python¶
Introduction¶
What Are Structural Patterns?¶
Structural patterns explain how to assemble objects and classes into larger structures while maintaining flexibility and efficiency. They help ensure that when one part of a system changes, the entire structure doesn't need to change.
The Seven Structural Patterns¶
1. Adapter Pattern¶
The Adapter pattern allows incompatible interfaces to work together. It acts as a bridge between two incompatible interfaces.
class EuropeanSocketInterface:
def voltage(self): pass
def live(self): pass
def neutral(self): pass
def earth(self): pass
class Socket(EuropeanSocketInterface):
def voltage(self):
return 230
def live(self):
return 1
def neutral(self):
return -1
def earth(self):
return 0
class USASocketInterface:
def voltage(self): pass
def live(self): pass
def neutral(self): pass
class Adapter(USASocketInterface):
def __init__(self, socket):
self.socket = socket
def voltage(self):
return 110
def live(self):
return self.socket.live()
def neutral(self):
return self.socket.neutral()
# Usage
socket = Socket()
adapter = Adapter(socket)
print(f"Adapted voltage: {adapter.voltage()}V")
2. Bridge Pattern¶
The Bridge pattern separates an object's abstraction from its implementation, allowing both to vary independently.
from abc import ABC, abstractmethod
class DrawingAPI(ABC):
@abstractmethod
def draw_circle(self, x, y, radius):
pass
class DrawingAPI1(DrawingAPI):
def draw_circle(self, x, y, radius):
return f"API1.circle at ({x}, {y}) with radius {radius}"
class DrawingAPI2(DrawingAPI):
def draw_circle(self, x, y, radius):
return f"API2.circle at ({x}, {y}) with radius {radius}"
class Shape(ABC):
def __init__(self, drawing_api):
self.drawing_api = drawing_api
@abstractmethod
def draw(self):
pass
class Circle(Shape):
def __init__(self, x, y, radius, drawing_api):
super().__init__(drawing_api)
self.x = x
self.y = y
self.radius = radius
def draw(self):
return self.drawing_api.draw_circle(self.x, self.y, self.radius)
# Usage
circle1 = Circle(1, 2, 3, DrawingAPI1())
circle2 = Circle(5, 7, 11, DrawingAPI2())
print(circle1.draw())
print(circle2.draw())
3. Composite Pattern¶
The Composite pattern lets you compose objects into tree structures to represent part-whole hierarchies.
from abc import ABC, abstractmethod
class Component(ABC):
@abstractmethod
def operation(self):
pass
class Leaf(Component):
def __init__(self, name):
self.name = name
def operation(self):
return f"Leaf {self.name}"
class Composite(Component):
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 operation(self):
results = [f"Branch {self.name}"]
for child in self.children:
results.append(child.operation())
return "\\n".join(results)
# Usage
tree = Composite("root")
branch1 = Composite("branch1")
branch1.add(Leaf("leaf1"))
branch1.add(Leaf("leaf2"))
branch2 = Composite("branch2")
branch2.add(Leaf("leaf3"))
tree.add(branch1)
tree.add(branch2)
print(tree.operation())
4. Decorator Pattern¶
The Decorator pattern attaches new behaviors to objects dynamically by placing them inside wrapper objects.
class Coffee:
def cost(self):
return 5
def description(self):
return "Simple coffee"
class CoffeeDecorator:
def __init__(self, coffee):
self._coffee = coffee
def cost(self):
return self._coffee.cost()
def description(self):
return self._coffee.description()
class Milk(CoffeeDecorator):
def cost(self):
return self._coffee.cost() + 2
def description(self):
return self._coffee.description() + ", milk"
class Sugar(CoffeeDecorator):
def cost(self):
return self._coffee.cost() + 1
def description(self):
return self._coffee.description() + ", sugar"
# Usage
coffee = Coffee()
coffee_with_milk = Milk(coffee)
coffee_with_milk_and_sugar = Sugar(coffee_with_milk)
print(f"{coffee_with_milk_and_sugar.description()}: ${coffee_with_milk_and_sugar.cost()}")
5. Facade Pattern¶
The Facade pattern provides a simplified interface to a complex subsystem.
class CPU:
def freeze(self):
print("CPU: Freezing...")
def jump(self, position):
print(f"CPU: Jumping to {position}")
def execute(self):
print("CPU: Executing...")
class Memory:
def load(self, position, data):
print(f"Memory: Loading {data} at {position}")
class HardDrive:
def read(self, lba, size):
return f"Data from sector {lba} with size {size}"
class ComputerFacade:
def __init__(self):
self.cpu = CPU()
self.memory = Memory()
self.hard_drive = HardDrive()
def start(self):
print("Starting computer...")
self.cpu.freeze()
boot_data = self.hard_drive.read(0, 1024)
self.memory.load(0, boot_data)
self.cpu.jump(0)
self.cpu.execute()
print("Computer started!")
# Usage
computer = ComputerFacade()
computer.start()
6. Flyweight Pattern¶
The Flyweight pattern minimizes memory usage by sharing data with similar objects.
class TreeType:
def __init__(self, name, color, texture):
self.name = name
self.color = color
self.texture = texture
def draw(self, x, y):
print(f"Drawing {self.name} tree at ({x}, {y}) with {self.color} color")
class TreeFactory:
_tree_types = {}
@classmethod
def get_tree_type(cls, name, color, texture):
key = (name, color, texture)
if key not in cls._tree_types:
cls._tree_types[key] = TreeType(name, color, texture)
print(f"Created new tree type: {name}")
return cls._tree_types[key]
class Tree:
def __init__(self, x, y, tree_type):
self.x = x
self.y = y
self.tree_type = tree_type
def draw(self):
self.tree_type.draw(self.x, self.y)
# Usage
factory = TreeFactory()
trees = []
trees.append(Tree(1, 2, factory.get_tree_type("Oak", "Green", "Rough")))
trees.append(Tree(3, 4, factory.get_tree_type("Oak", "Green", "Rough")))
trees.append(Tree(5, 6, factory.get_tree_type("Pine", "Dark Green", "Smooth")))
for tree in trees:
tree.draw()
7. Proxy Pattern¶
The Proxy pattern provides a placeholder for another object to control access to it.
from abc import ABC, abstractmethod
class Image(ABC):
@abstractmethod
def display(self):
pass
class RealImage(Image):
def __init__(self, filename):
self.filename = filename
self._load_from_disk()
def _load_from_disk(self):
print(f"Loading image: {self.filename}")
def display(self):
print(f"Displaying image: {self.filename}")
class ProxyImage(Image):
def __init__(self, filename):
self.filename = filename
self._real_image = None
def display(self):
if self._real_image is None:
self._real_image = RealImage(self.filename)
self._real_image.display()
# Usage
image1 = ProxyImage("photo1.jpg")
image2 = ProxyImage("photo2.jpg")
print("Images created, but not loaded yet")
image1.display() # Loads and displays
image1.display() # Just displays
image2.display() # Loads and displays
When to Use Structural Patterns¶
- Adapter: When you need to use an existing class with an incompatible interface
- Bridge: When you want to avoid permanent binding between abstraction and implementation
- Composite: When you need to represent part-whole hierarchies of objects
- Decorator: When you need to add responsibilities to objects dynamically
- Facade: When you want to provide a simple interface to a complex subsystem
- Flyweight: When you need to support large numbers of similar objects efficiently
- Proxy: When you need to control access to an object
Best Practices¶
- Choose the right pattern: Not every problem needs a design pattern. Use them when they genuinely simplify your code.
- Keep it Pythonic: Python has built-in features (decorators, descriptors, etc.) that can sometimes replace pattern implementations.
- Favor composition over inheritance: Structural patterns often emphasize object composition.
- Document your patterns: Make it clear which patterns you're using and why.
- Don't over-engineer: Start simple and refactor to patterns when complexity demands it.
Conclusion¶
Structural design patterns are powerful tools for creating flexible, maintainable Python code. By understanding and applying these patterns appropriately, you can build systems that are easier to understand, modify, and extend. Remember that patterns are guidelines, not rules—adapt them to fit Python's idioms and your specific needs.