Structural patterns
Table of contents
Structural design patterns deal with object composition and the organization of classes and objects. They help ensure that components in a system are arranged efficiently, promoting flexibility and reusability. They emphasize APIs to keep complex structures easy to manage and extend.
Adapter
Adapter allows incompatible interfaces to work together by acting as a bridge between them. It translates one interface into another (adapts it), enabling classes with different APIs to communicate seamlessly without modifying their existing code. It is an in-between component.
class OldPrinter: # an existing class (incompatible interface)
def print_text(self, text):
print(f"Old Printer: {text}")
class NewPrinter: # a new expected interface
def print(self, text):
raise NotImplementedError
class PrinterAdapter(NewPrinter):
def __init__(self, old_printer):
self.old_printer = old_printer
def print(self, text):
self.old_printer.print_text(text) # adapting the old method
old_printer = OldPrinter()
adapter = PrinterAdapter(old_printer)
adapter.print("Printed text.")
Bridge
Bridge decouples (separates) abstraction from implementation, allowing them to vary independently and preventing a Cartesian complexity explosion (where the number of subclasses grows exponentially as new features are added). It acts as a bridge between high-level abstractions and concrete implementations by connecting these two hierarchies of different classes with a parameter.
The first hierarchy (Sender
and its subclasses like FastSender
and SecureSender
) represents the implementation. The second hierarchy (Message
and its subclasses like EmailMessage
and SMSMessage
) represents the abstraction. The sender
parameter in Message.__init__()
acts as a bridge that connects these two hierarchies dynamically.
from abc import ABC, abstractmethod
class Sender(ABC):
@abstractmethod
def send(self, message):
pass
class FastSender(Sender):
def send(self, message):
print(f"Sending message quickly: {message}")
class SecureSender(Sender):
def send(self, message):
print(f"Sending message securely: {message}")
class Message: # a Bridge
def __init__(self, sender): # this parameter connects the two hierarchies
self.sender = sender
def send(self, content):
pass
class EmailMessage(Message):
def send(self, content):
print("Sending Email:")
self.sender.send(content)
class SMSMessage(Message):
def send(self, content):
print("Sending SMS:")
self.sender.send(content)
# Cartesian combinations
# FastSender EmailMessage
# FastSender SMSMessage
# SecureSender EmailMessage
# SecureSender SMSMessage
fast_sender = FastSender()
secure_sender = SecureSender()
email = EmailMessage(fast_sender)
email.send("Hello via Email!")
sms = SMSMessage(secure_sender)
sms.send("Hello via SMS!")
Composite
Composite treats individual objects and compositions of objects uniformly. It is commonly used in file systems to represent hierarchical structures where folders can contain both files and other folders. Instead of writing separate logic for handling individual files and directories, we use the same interface, making it easier to traverse and manage the entire structure.
from abc import ABC, abstractmethod
class FileSystemItem(ABC): # an interface (common for both files and folders)
@abstractmethod
def display(self, indent = 0):
pass
class File(FileSystemItem): # a leaf class (single file)
def __init__(self, name):
self.name = name
def display(self, indent = 0):
print(" " * indent + f"- {self.name}")
class Folder(FileSystemItem): # a Composite class (a folder that can contain files and other folders)
def __init__(self, name):
self.name = name
self.items = []
def add(self, item):
self.items.append(item)
def display(self, indent = 0):
print(" " * indent + f"[Folder] {self.name}")
for item in self.items: # treating files and folders in the same way
item.display(indent + 2)
root = Folder("Root")
file1 = File("file1.txt")
file2 = File("file2.txt")
subfolder = Folder("Subfolder")
subfolder.add(File("subfile.txt"))
root.add(file1)
root.add(subfolder)
root.add(file2)
root.display()
Decorator
Decorator allows dynamic addition of behavior to objects without altering their original code, enabling flexible and reusable functionality extension. It adheres to the OCP (new functionalities can be added via decorators) and the SRP (the new functionalities are kept separate).
The Decorator class is responsible for adding new behavior or functionality to an existing object dynamically, without modifying the object's original code, by wrapping the object and delegating method calls to it while potentially altering or enhancing the behavior.
from abc import ABC, abstractmethod
class Coffee(ABC): # an interface
@abstractmethod
def cost(self):
pass
class SimpleCoffee(Coffee): # a concrete component
def cost(self):
return 5 # the base cost of coffee
class MilkDecorator(Coffee): # a Decorator class
def __init__(self, coffee):
self._coffee = coffee # holding a reference to the coffee object
# This decorator calls the cost() method from the wrapped object (SimpleCoffee) and adds the cost of milk
def cost(self):
return self._coffee.cost() + 2 # adding the cost of milk to the coffee
# Creating a simple coffee
simple_coffee = SimpleCoffee()
print(f"Cost of simple coffee: {simple_coffee.cost()}")
# Adding milk to the coffee using the MilkDecorator
milk_coffee = MilkDecorator(simple_coffee) # MilkDecorator "wraps around" SimpleCoffee
print(f"Cost of coffee with milk: {milk_coffee.cost()}")
The Decorator pattern allows behavior to be added to objects dynamically by wrapping them with additional functionality, typically using classes, while a Pythonic functional decorator achieves a similar outcome using function-based syntax (@decorator
) to modify or extend the behavior of functions or methods without altering their original code structure (check out an article about Pythonic functional decorators).
Facade
Facade provides a simplified interface to a larger, more complicated system, hiding the complexity and offering a higher-level API for clients. Optionally, we may wish to expose internals through the facade, e.g., for more detailed control or specific operations that bypass the simplified interface, allowing advanced users to interact directly with system components when necessary.
class TV:
def turn_on(self):
print("TV is now ON")
def turn_off(self):
print("TV is now OFF")
class SoundSystem:
def turn_on(self):
print("Sound system is now ON")
def turn_off(self):
print("Sound system is now OFF")
class StreamingService:
def start(self):
print("Streaming service is starting...")
def stop(self):
print("Streaming service is stopping...")
class HomeTheaterFacade: # a Facade
def __init__(self, tv, sound_system, streaming_service):
self.tv = tv
self.sound_system = sound_system
self.streaming_service = streaming_service
def watch_movie(self):
print("Starting movie mode...")
self.tv.turn_on()
self.sound_system.turn_on()
self.streaming_service.start()
print("Movie is ready to watch!")
def end_movie(self):
print("Shutting down movie mode...")
self.streaming_service.stop()
self.sound_system.turn_off()
self.tv.turn_off()
print("Movie mode ended.")
tv = TV()
sound_system = SoundSystem()
streaming_service = StreamingService()
home_theater = HomeTheaterFacade(tv, sound_system, streaming_service)
home_theater.watch_movie() # one call replaces multiple system interactions
home_theater.end_movie() # one call shuts everything down
Flyweight
Flyweight optimizes memory usage by sharing common object data, particularly for large numbers of similar objects (e.g., a database of users that can have the same names). It reduces the memory footprint by storing shared data externally and reusing common objects rather than creating new instances for each.
The Flyweight pattern typically has two parts: the Flyweight class, holding the shared data (intrinsic state), and the Flyweight Factory (or similar), responsible for creating and managing shared instances, ensuring that identical objects are reused instead of duplicated.
class Name: # a Flyweight class
def __init__(self, name):
self.name = name
def display(self, type):
print(f"Displaying {self.name} as {type}")
class NameDatabase: # a Flyweight Factory
_names = {}
@staticmethod
def get_name(name):
if name not in NameDatabase._names:
NameDatabase._names[name] = Name(name)
return NameDatabase._names[name]
john1 = NameDatabase.get_name("John")
alice1 = NameDatabase.get_name("Alice")
# Reusing "John" and "Alice" from the database instead of creating new objects
john2 = NameDatabase.get_name("John")
alice2 = NameDatabase.get_name("Alice")
john1.display("formal")
john2.display("informal")
alice1.display("formal")
alice2.display("informal")
# Checking whether the same instances were reused
print(john1 is john2)
print(alice1 is alice2)
Proxy
Proxy acts as an intermediary or placeholder for another object, controlling access. Proxies are used to add additional functionality or restrictions when interacting with objects, allowing for behaviors like access control, Lazy initialization, and resource optimization. There are different types of proxies, including Protection Proxy, which regulates access based on permissions or roles, and Virtual Proxy, which delays the creation of resource-intensive objects until they are needed, optimizing performance. A Virtual Proxy is one way to implement Lazy initialization, but, of course, Lazy initialization can be applied independently of Proxies.
Protection Proxy
class Movie:
def __init__(self, title, rating):
self.title = title
self.rating = rating
def watch(self):
return f"Watching {self.title}"
class AgeRestrictedMovie: # a Proxy
def __init__(self, movie):
self.movie = movie
def watch(self, age):
if age >= self.movie.rating:
return self.movie.watch()
else:
return "Age restriction: too young"
movie1 = AgeRestrictedMovie(Movie("Action movie", 18))
print(movie1.watch(20))
print(movie1.watch(16))
movie2 = AgeRestrictedMovie(Movie("Family movie", 0))
print(movie2.watch(10))
Virtual Proxy
import time
class HeavyResource:
def __init__(self):
print("Loading heavy resource...")
time.sleep(2)
self.data = "Heavy resource loaded"
def display(self):
return self.data
class VirtualProxy:
def __init__(self):
self._real_resource = None
def _load_resource(self):
if not self._real_resource:
self._real_resource = HeavyResource() # Lazy initialization
return self._real_resource
def display(self):
return self._load_resource().display()
proxy = VirtualProxy()
print("Before accessing the resource...")
print(proxy.display()) # heavy resource is loaded now, so it takes time
print(proxy.display()) # resource is already loaded, no delay
Proxy VS Decorator
Proxy provides an identical interface to the original object, focusing on controlling access or managing resources, while Decorator enhances the object's interface by adding new functionality. Decorator typically holds a reference to the object it decorates, whereas Proxy may not even interact with a materialized object, especially in cases like Lazy Initialization or access control. Essentially, Proxies manage access and resources, while Decorators extend behavior.