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.