jueves, 15 de enero de 2026

Principios Fundamentales de Diseño en Python - Parte 4 - Patrones de Diseño Estructurales

Patrones de Diseño Estructurales en Python
Python // Design Patterns

Patrones de Diseño Estructurales

Componer objetos para crear estructuras más grandes y flexibles

Los patrones de diseño estructurales proponen formas de componer objetos para proporcionar nueva funcionalidad. Mientras que los patrones creacionales se enfocan en cómo se crean los objetos, los estructurales se concentran en cómo se organizan y conectan.

En este artículo exploraremos seis patrones fundamentales: Adapter, Decorator, Bridge, Facade, Flyweight y Proxy. Cada uno resuelve problemas específicos de composición y estructura en el diseño de software.

PATTERN_01

Adapter Pattern

El patrón Adapter es un patrón estructural que ayuda a hacer compatibles dos interfaces incompatibles. Cuando tienes un componente antiguo que quieres usar en un sistema nuevo, o viceversa, rara vez pueden comunicarse sin modificaciones. Si cambiar el código no es posible, escribimos una capa adicional que hace las modificaciones necesarias: el adaptador.

Analogía del Mundo Real

Cuando viajas de Europa a USA o UK, necesitas un adaptador de enchufe para cargar tu laptop. El mismo concepto aplica para conectar dispositivos a tu computadora: el adaptador USB.

Arquitectura del Adapter
Client Code
——→
Adapter
——→
Adaptee

Casos de Uso

SISTEMAS LEGACY
Integrar código antiguo con interfaces modernas sin modificar el código original
LIBRERÍAS EXTERNAS
Adaptar APIs de terceros a tu interfaz esperada sin acceso al código fuente

Ejemplo 1: Adaptar Sistema de Pagos Legacy

adapter_legacy.py
# Sistema de pagos legacy
class OldPaymentSystem:
    def __init__(self, currency):
        self.currency = currency
    
    def make_payment(self, amount):
        print(f"[OLD] Pay {amount} {self.currency}")

# Nuevo sistema de pagos con interfaz diferente
class NewPaymentGateway:
    def __init__(self, currency):
        self.currency = currency
    
    def execute_payment(self, amount):
        print(f"Execute payment of {amount} {self.currency}")

# El Adapter hace compatible NewPaymentGateway con la interfaz esperada
class PaymentAdapter:
    def __init__(self, system):
        self.system = system  # El adaptee
    
    def make_payment(self, amount):
        self.system.execute_payment(amount)

# Uso
if __name__ == "__main__":
    new_system = NewPaymentGateway("euro")
    adapter = PaymentAdapter(new_system)
    adapter.make_payment(100)  # Usa la interfaz esperada
python adapter_legacy.py
Execute payment of 100 euro

Ejemplo 2: Interfaz Unificada para Múltiples Clases

Adapter Genérico
Musician.play()
Dancer.dance()
← adapt →
organize_event()
adapter_unified.py
# Clase del club con interfaz esperada
class Club:
    def __init__(self, name):
        self.name = name
    
    def __str__(self):
        return f"the club {self.name}"
    
    def organize_event(self):
        return "hires an artist to perform"

# Clases externas con interfaces diferentes
class Musician:
    def __init__(self, name):
        self.name = name
    
    def __str__(self):
        return f"the musician {self.name}"
    
    def play(self):
        return "plays music"

class Dancer:
    def __init__(self, name):
        self.name = name
    
    def __str__(self):
        return f"the dancer {self.name}"
    
    def dance(self):
        return "does a dance performance"

# Adapter genérico para cualquier objeto
class Adapter:
    def __init__(self, obj, adapted_methods):
        self.obj = obj
        self.__dict__.update(adapted_methods)
    
    def __str__(self):
        return str(self.obj)

# Uso con adaptación dinámica
def main():
    objects = [
        Club("Jazz Cafe"),
        Musician("Roy Ayers"),
        Dancer("Shane Sparks"),
    ]
    for obj in objects:
        if hasattr(obj, "play"):
            obj = Adapter(obj, dict(organize_event=obj.play))
        elif hasattr(obj, "dance"):
            obj = Adapter(obj, dict(organize_event=obj.dance))
        print(f"{obj} {obj.organize_event()}")
python adapter_unified.py
the club Jazz Cafe hires an artist to perform
the musician Roy Ayers plays music
the dancer Shane Sparks does a dance performance
El patrón Adapter es pragmático: permite integrar código sin modificar el original. Úsalo cuando no tengas acceso al código fuente o sea impráctico refactorizarlo.
PATTERN_02

Decorator Pattern

El patrón Decorator permite agregar responsabilidades a un objeto dinámicamente, de manera transparente y sin afectar otros objetos. Python tiene una característica incorporada de decoradores que extiende este concepto aún más.

Concepto del Decorator
Original Function
——→
@decorator
——→
Enhanced Function

Beneficios

SIN HERENCIA
Extiende comportamiento sin crear subclases
CROSS-CUTTING
Ideal para logging, caching, validación
COMPOSABLE
Múltiples decoradores pueden apilarse

Implementación: Memoization Decorator

Un caso de uso clásico es la memoización: cachear resultados de funciones costosas para evitar recálculos.

decorator_memoize.py
import functools

def memoize(func):
    """Decorator que cachea resultados de funciones"""
    cache = dict()
    
    @functools.wraps(func)
    def memoized(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    
    return memoized

@memoize
def number_sum(n):
    """Returns the sum of the first n numbers"""
    print(f"Calculating sum for {n}...")
    return sum(range(n + 1))

@memoize
def fibonacci(n):
    """Returns the nth Fibonacci number"""
    if n in (0, 1):
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

# Uso
if __name__ == "__main__":
    # Primera llamada: calcula
    print(f"Sum: {number_sum(100)}")
    # Segunda llamada: usa cache
    print(f"Sum (cached): {number_sum(100)}")
    
    print(f"Fibonacci(30): {fibonacci(30)}")
python decorator_memoize.py
Calculating sum for 100...
Sum: 5050
Sum (cached): 5050
Fibonacci(30): 832040
@functools.wraps(func) preserva los atributos __name__ y __doc__ de la función original. Sin él, la función decorada perdería su identidad.
PATTERN_03

Bridge Pattern

Mientras que el Adapter se usa después para hacer funcionar clases incompatibles, el Bridge se diseña desde el inicio para desacoplar una abstracción de su implementación, permitiendo que ambas varíen independientemente.

Arquitectura del Bridge
Abstraction
ResourceContent
← bridge →
Implementor
URLFetcher
FileFetcher

Casos de Uso

DEVICE DRIVERS
OS define interfaz, vendors implementan drivers
GUI TOOLKITS
Separar lógica de UI de la plataforma específica
PAYMENT GATEWAYS
Checkout consistente con diferentes implementaciones
CONTENT MANAGEMENT
Extraer contenido de múltiples fuentes

Implementación: Sistema de Extracción de Contenido

bridge.py
from abc import ABC, abstractmethod
import urllib.request

# Interfaz del Implementor
class ResourceContentFetcher(ABC):
    @abstractmethod
    def fetch(self, path: str) -> str:
        pass

# Implementación concreta: URL
class URLFetcher(ResourceContentFetcher):
    def fetch(self, path: str) -> str:
        req = urllib.request.Request(path)
        with urllib.request.urlopen(req) as response:
            return response.read().decode('utf-8')

# Implementación concreta: Archivo Local
class LocalFileFetcher(ResourceContentFetcher):
    def fetch(self, path: str) -> str:
        with open(path, 'r') as f:
            return f.read()

# La Abstracción
class ResourceContent:
    def __init__(self, fetcher: ResourceContentFetcher):
        self.fetcher = fetcher  # Bridge al implementor
    
    def show_content(self, path: str):
        content = self.fetcher.fetch(path)
        print(f"Fetched content with {len(content)} characters")

# Uso
if __name__ == "__main__":
    # Mismo ResourceContent, diferentes implementaciones
    url_resource = ResourceContent(URLFetcher())
    url_resource.show_content("https://python.org")
    
    local_resource = ResourceContent(LocalFileFetcher())
    local_resource.show_content("config.txt")
python bridge.py
Fetched content with 51265 characters
Fetched content with 1327 characters
El Bridge permite agregar nuevas fuentes de contenido (FTP, Database, API) sin modificar ResourceContent. Abstracción e implementación evolucionan independientemente.
PATTERN_04

Facade Pattern

A medida que los sistemas evolucionan, pueden volverse muy complejos. El patrón Facade nos ayuda a ocultar la complejidad interna y exponer solo lo necesario a través de una interfaz simplificada. Es una capa de abstracción sobre un sistema existente complejo.

Analogía del Mundo Real

Una computadora es un facade: todo lo que necesitas hacer es presionar un botón para encenderla. Toda la complejidad del hardware (BIOS, boot loader, kernel) se maneja de forma transparente. Lo mismo aplica a la llave de tu auto o al servicio al cliente de un banco.

Facade Simplifica Acceso
Client
——→
Facade
——→
Subsystem A
Subsystem B
Subsystem C

Beneficios

SIMPLICIDAD
Un único punto de entrada al sistema complejo
DESACOPLAMIENTO
Cambios internos no afectan al cliente
CAPAS
Permite comunicación entre capas via facades

Implementación: Sistema Operativo Multi-Server

facade.py
from abc import ABC, abstractmethod
from enum import Enum

State = Enum("State", "NEW RUNNING SLEEPING RESTART ZOMBIE")

# Clases internas del sistema (ocultas al cliente)
class User:
    pass

class Process:
    pass

class File:
    pass

# Servidor abstracto
class Server(ABC):
    @abstractmethod
    def boot(self):
        pass
    
    @abstractmethod
    def kill(self, restart=True):
        pass

# Servidores específicos
class FileServer(Server):
    def boot(self):
        print("booting the FileServer")
    
    def kill(self, restart=True):
        print("Killing FileServer")
    
    def create_file(self, user, name, perms):
        print(f"creating file '{name}' for user '{user}' with {perms}")

class ProcessServer(Server):
    def boot(self):
        print("booting the ProcessServer")
    
    def kill(self, restart=True):
        print("Killing ProcessServer")
    
    def create_process(self, user, name):
        print(f"creating process '{name}' for user '{user}'")

# El FACADE: interfaz simple para el sistema complejo
class OperatingSystem:
    """The Facade - oculta la complejidad del sistema"""
    
    def __init__(self):
        self.fs = FileServer()
        self.ps = ProcessServer()
    
    def start(self):
        """Único método que el cliente necesita"""
        [server.boot() for server in (self.fs, self.ps)]
    
    def create_file(self, user, name, perms):
        return self.fs.create_file(user, name, perms)
    
    def create_process(self, user, name):
        return self.ps.create_process(user, name)

# Uso: el cliente solo conoce el Facade
if __name__ == "__main__":
    os = OperatingSystem()
    os.start()
    os.create_file("foo", "hello.txt", "-rw-r-r")
    os.create_process("bar", "ls /tmp")
python facade.py
booting the FileServer
booting the ProcessServer
creating file 'hello.txt' for user 'foo' with -rw-r-r
creating process 'ls /tmp' for user 'bar'
El cliente puede crear archivos y procesos sin conocer detalles internos como la existencia de múltiples servidores. Toda la complejidad está encapsulada en OperatingSystem.
PATTERN_05

Flyweight Pattern

El patrón Flyweight es una técnica de optimización para minimizar el uso de memoria compartiendo datos entre objetos similares. Un flyweight contiene datos inmutables (intrínsecos) compartidos. Los datos mutables (extrínsecos) deben proporcionarse explícitamente por el código cliente.

Ejemplo del Mundo Real

En juegos FPS como Counter-Strike, todos los soldados del mismo equipo comparten la misma representación visual. En lugar de crear objetos únicos para cada soldado, compartimos los datos comunes (modelo 3D, animaciones) y solo variamos los datos específicos (posición, salud, armas).

Flyweight: Compartir Datos
Shared Pool
SUBCOMPACT
COMPACT
SUV
+ extrinsic →
18 cars rendered
3 objects created

Requisitos para usar Flyweight (GoF)

MUCHOS OBJETOS
La aplicación usa una gran cantidad de objetos
COSTO ELEVADO
Almacenar/renderizar objetos es demasiado costoso
IDENTIDAD NO IMPORTA
La identidad de objetos no es relevante para la app

Implementación: Renderer de Autos

flyweight.py
import random
from enum import Enum

CarType = Enum("CarType", "SUBCOMPACT COMPACT SUV")

class Car:
    # Pool compartido (atributo de clase)
    pool = dict()
    
    def __new__(cls, car_type):
        # Reutilizar objeto existente o crear nuevo
        obj = cls.pool.get(car_type, None)
        if not obj:
            obj = object.__new__(cls)
            cls.pool[car_type] = obj
            obj.car_type = car_type
        return obj
    
    def render(self, color, x, y):
        # color, x, y son datos extrínsecos (mutables)
        car_type = self.car_type
        print(f"render a {color} {car_type.name} car at ({x}, {y})")

def main():
    colors = ["white", "black", "silver", "gray", "red", 
              "blue", "brown", "beige", "yellow", "green"]
    car_counter = 0
    
    # Renderizar 10 SUBCOMPACTs
    for _ in range(10):
        c = Car(CarType.SUBCOMPACT)
        c.render(random.choice(colors), 
                 random.randint(0, 100), 
                 random.randint(0, 100))
        car_counter += 1
    
    # Renderizar 3 COMPACTs
    for _ in range(3):
        c = Car(CarType.COMPACT)
        c.render(random.choice(colors),
                 random.randint(0, 100),
                 random.randint(0, 100))
        car_counter += 1
    
    # Renderizar 5 SUVs
    for _ in range(5):
        c = Car(CarType.SUV)
        c.render(random.choice(colors),
                 random.randint(0, 100),
                 random.randint(0, 100))
        car_counter += 1
    
    print(f"\ncars rendered: {car_counter}")
    print(f"cars actually created: {len(Car.pool)}")
    
    # Demostrar que objetos del mismo tipo son idénticos
    c1 = Car(CarType.SUBCOMPACT)
    c2 = Car(CarType.SUBCOMPACT)
    c3 = Car(CarType.SUV)
    print(f"\n{id(c1)} == {id(c2)}? {id(c1) == id(c2)}")
    print(f"{id(c2)} == {id(c3)}? {id(c2) == id(c3)}")

if __name__ == "__main__":
    main()
python flyweight.py
render a gray SUBCOMPACT car at (25, 79)
render a black SUBCOMPACT car at (31, 99)
render a brown SUBCOMPACT car at (16, 74)
...
render a green SUV car at (16, 51)

cars rendered: 18
cars actually created: 3

4493672400 == 4493672400? True
4493672400 == 4493457488? False
Con Flyweight no puedes confiar en la identidad de objetos. Objetos que parecen diferentes al cliente pueden tener la misma identidad si pertenecen a la misma familia de flyweight.
PATTERN_06

Proxy Pattern

El patrón Proxy usa un objeto sustituto (proxy) para realizar acciones importantes antes de acceder al objeto real. Hay cuatro tipos principales de proxy, cada uno resolviendo problemas específicos.

Tipos de Proxy

01
Virtual Proxy
Lazy initialization: diferir creación de objetos costosos hasta que sean necesarios
02
Protection Proxy
Control de acceso: verificar permisos antes de acceder a recursos sensibles
03
Remote Proxy
Representación local de un objeto que existe en otro address space (ej: servidor)
04
Smart Proxy
Acciones extra al acceder objetos: reference counting, thread-safety

Implementación 1: Virtual Proxy (Lazy Loading)

proxy_virtual.py
class LazyProperty:
    """Descriptor para lazy initialization"""
    
    def __init__(self, method):
        self.method = method
        self.method_name = method.__name__
    
    def __get__(self, obj, cls):
        if not obj:
            return None
        # Calcular valor solo en primer acceso
        value = self.method(obj)
        # Reemplazar método con el valor calculado
        setattr(obj, self.method_name, value)
        return value

class Test:
    def __init__(self):
        self.x = "foo"
        self.y = "bar"
        self._resource = None
    
    @LazyProperty
    def resource(self):
        print("initializing self._resource (expensive!)")
        self._resource = tuple(range(5))
        return self._resource

# Uso
if __name__ == "__main__":
    t = Test()
    print(t.x, t.y)           # No inicializa resource
    print(t.resource)         # Ahora sí inicializa
    print(t.resource)         # Usa valor cacheado
python proxy_virtual.py
foo bar
initializing self._resource (expensive!)
(0, 1, 2, 3, 4)
(0, 1, 2, 3, 4)

Implementación 2: Protection Proxy

proxy_protection.py
class SensitiveInfo:
    """Recurso protegido"""
    def __init__(self):
        self.users = ["nick", "tom", "ben", "mike"]
    
    def read(self):
        nb = len(self.users)
        print(f"There are {nb} users: {' '.join(self.users)}")
    
    def add(self, user):
        self.users.append(user)
        print(f"Added user {user}")

class Info:
    """Protection Proxy"""
    def __init__(self):
        self.protected = SensitiveInfo()
        self.secret = "0xdeadbeef"
    
    def read(self):
        # Lectura permitida sin autenticación
        self.protected.read()
    
    def add(self, user):
        # Escritura requiere autenticación
        sec = input("what is the secret? ")
        if sec == self.secret:
            self.protected.add(user)
        else:
            print("That's wrong!")

# Uso
if __name__ == "__main__":
    info = Info()
    info.read()              # Sin restricción
    info.add("alice")        # Requiere secreto

Implementación 3: Smart Proxy (Reference Counting)

proxy_smart.py
from typing import Protocol

class DBConnectionInterface(Protocol):
    def exec_query(self, query):
        ...

class DBConnection:
    def __init__(self):
        print("DB connection created")
    
    def exec_query(self, query):
        return f"Executing query: {query}"
    
    def close(self):
        print("DB connection closed")

class SmartProxy:
    """Maneja reference counting automáticamente"""
    
    def __init__(self):
        self.cnx = None
        self.ref_count = 0
    
    def access_resource(self):
        if self.cnx is None:
            self.cnx = DBConnection()
        self.ref_count += 1
        print(f"DB now has {self.ref_count} references")
    
    def exec_query(self, query):
        if self.cnx is None:
            self.access_resource()
        result = self.cnx.exec_query(query)
        print(result)
        self.release_resource()
        return result
    
    def release_resource(self):
        if self.ref_count > 0:
            self.ref_count -= 1
            print(f"{self.ref_count} remaining refs")
        if self.ref_count == 0 and self.cnx is not None:
            self.cnx.close()
            self.cnx = None

# Uso
if __name__ == "__main__":
    proxy = SmartProxy()
    proxy.exec_query("SELECT * FROM users")
    proxy.exec_query("UPDATE users SET name = 'John'")
python proxy_smart.py
DB connection created
DB now has 1 references
Executing query: SELECT * FROM users
0 remaining refs
DB connection closed
DB connection created
DB now has 1 references
Executing query: UPDATE users SET name = 'John'
0 remaining refs
DB connection closed
El Smart Proxy garantiza que la conexión a la base de datos se crea on-demand y se cierra automáticamente cuando no hay más referencias. Previene resource leaks y exhaustion.

Resumen de Patrones

01 // Adapter

Hace compatibles interfaces incompatibles. Úsalo para integrar sistemas legacy o librerías externas.

02 // Decorator

Agrega responsabilidades dinámicamente sin herencia. Ideal para cross-cutting concerns.

03 // Bridge

Desacopla abstracción de implementación desde el inicio. Ambas pueden variar independientemente.

04 // Facade

Proporciona una interfaz simple a un sistema complejo. Oculta la complejidad interna.

05 // Flyweight

Minimiza memoria compartiendo datos inmutables. Optimización para muchos objetos similares.

06 // Proxy

Objeto sustituto que controla acceso al real. Virtual, Protection, Remote y Smart variants.

Basado en "Mastering Python Design Patterns" // Kamon Ayeva & Sakis Kasampalis