Structural Patterns: Enhancing Code Flexibility and Functionality

herchila

Hernán Chilabert

Posted on December 1, 2023

Structural Patterns: Enhancing Code Flexibility and Functionality

[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"}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
herchila
Hernán Chilabert

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