Patrón Singleton en Python

El patrón Singleton es un patrón de diseño de software común que garantiza que una clase tenga una única instancia y proporciona un punto de acceso global para obtenerla.

Concepto central

Imagina el puesto de CEO de una empresa: solo puede haber un CEO en toda la empresa. Sin importar qué departamento necesite reportar al CEO, todos acceden al mismo objeto CEO. El patrón Singleton es el "mecanismo de gestión del puesto de CEO" en el mundo de la programación.

¿Por qué se necesita el patrón Singleton?

En el desarrollo real, hay objetos para los que solo necesitamos una única instancia, por ejemplo:

  • Gestor de configuración: toda la aplicación comparte la misma configuración
  • Pool de conexiones de base de datos: evita crear conexiones repetidamente y ahorra recursos
  • Logger: asegura que todos los registros se escriban en el mismo archivo
  • Sistema de caché: gestiona los datos de caché de manera unificada

Métodos de implementación del patrón Singleton

Python ofrece múltiples formas de implementar el patrón Singleton. Vamos a conocerlas una por una, de lo simple a lo complejo.

Método 1: Usar un módulo (la forma más sencilla)

Los módulos de Python son por sí mismos un patrón Singleton natural. Cuando un módulo se importa, se inicializa una vez; las importaciones posteriores usarán la misma instancia.

Ejemplo

# singleton_module.py
class DatabaseConnection:
    def __init__(self):
        self.connection_string = "database://localhost:5432/mydb"
        print("Conexión a la base de datos creada")
   
    def query(self, sql):
        return f"Ejecutando consulta: {sql}"

# Crear la instancia Singleton
db_instance = DatabaseConnection()

# Usar en otros archivos:
# from singleton_module import db_instance
# result = db_instance.query("SELECT * FROM users")

Método 2: Usar__new__Método

Sobrescribiendo__new__método para controlar el proceso de creación de la instancia.

Ejemplo

class Singleton:
    _instance = None
   
    def __new__(cls, *args, **kwargs):
        # Si la instancia no existe, crear una nueva instancia
        if not cls._instance:
            cls._instance = super().__new__(cls)
            print("Creando nueva instancia Singleton")
        else:
            print("Devolviendo la instancia Singleton existente")
        return cls._instance
   
    def __init__(self, name):
        # Nota: __init__ se llamará cada vez
        self.name = name
        print(f"Inicializando instancia, nombre: {name}")

# Código de prueba
print("=== Prueba del patrón Singleton ===")
s1 = Singleton("Primera instancia")
s2 = Singleton("Segunda instancia")

print(f"ID de s1: {id(s1)}")
print(f"ID de s2: {id(s2)}")
print(f"¿Son s1 y s2 el mismo objeto? {s1 is s2}")
print(f"Nombre de s1: {s1.name}")
print(f"Nombre de s2: {s2.name}")  # Nota: aquí se mostrará "Segunda instancia"

Resultado de salida:

=== 测试单例模式 ===
创建新的单例实例
初始化实例,名称: 第一个实例
返回已存在的单例实例
初始化实例,名称: 第二个实例
s1 的 ID: 140245678945600
s2 的 ID: 140245678945600
s1 和 s2 是同一个对象吗? True
s1 名称: 第二个实例
s2 名称: 第二个实例

Método 3: Usar decoradores

Crea un decorador Singleton genérico que puede convertir fácilmente cualquier clase en un Singleton.

Ejemplo

def singleton(cls):
    """Decorador Singleton"""
    instances = {}
   
    def get_instance(*args, **kwargs):
        # Si la clase aún no tiene una instancia, crear una nueva instancia
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
            print(f"Creando nueva instancia de {cls.__name__}")
        else:
            print(f"Devolviendo instancia existente de {cls.__name__}")
        return instances[cls]
   
    return get_instance

@singleton
class ConfigurationManager:
    def __init__(self):
        self.settings = {}
        self.load_default_settings()
   
    def load_default_settings(self):
        self.settings = {
            "app_name": "Mi aplicación",
            "version": "1.0.0",
            "debug_mode": True
        }
   
    def get_setting(self, key):
        return self.settings.get(key)
   
    def set_setting(self, key, value):
        self.settings[key] = value

# Código de prueba
print("\n"=== Prueba del Singleton con decorador ===")
config1 = ConfigurationManager()
config2 = ConfigurationManager()

config1.set_setting("theme", "dark")
print(f"Tema de config1: {config1.get_setting('theme')}")
print(f"Tema de config2: {config2.get_setting('theme')}")  # Ambas instancias comparten la misma configuración

Método 4: Usar metaclases (uso avanzado)

Las metaclases pueden controlar el proceso de creación de clases, otra forma potente de implementar el patrón Singleton.

Ejemplo

class SingletonMeta(type):
    """Metaclase Singleton"""
    _instances = {}
   
    def __call__(cls, *args, **kwargs):
        # Si la clase aún no tiene una instancia, crear una nueva instancia
        if cls not in cls._instances:
            instance = super().__call__(*args, **kwargs)
            cls._instances[cls] = instance
            print(f"Metaclase: creando nueva instancia de {cls.__name__}")
        else:
            print(f"Metaclase: devolviendo instancia existente de {cls.__name__}")
        return cls._instances[cls]

class Logger(metaclass=SingletonMeta):
    def __init__(self, log_file="app.log"):
        self.log_file = log_file
        self.logs = []
        print(f"Logger inicializado, archivo: {log_file}")
   
    def log(self, message):
        log_entry = f"[{self.get_timestamp()}] {message}"
        self.logs.append(log_entry)
        print(f"Registrando log: {log_entry}")
        return log_entry
   
    def get_timestamp(self):
        from datetime import datetime
        return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
   
    def get_logs(self):
        return self.logs.copy()

# Código de prueba
print("\n"=== Prueba del Singleton con metaclase ===")
logger1 = Logger("application.log")
logger2 = Logger("different.log")  # Este nombre de archivo se ignorará

logger1.log("Inicio del sistema")
logger2.log("Inicio de sesión de usuario")

print(f"Cantidad de registros de logger1: {len(logger1.get_logs())}")
print(f"Cantidad de registros de logger2: {len(logger2.get_logs())}")
print(f"¿Es el mismo logger? {logger1 is logger2}")

Escenarios de aplicación del patrón Singleton

Veamos la aplicación del patrón Singleton en proyectos reales mediante un ejemplo completo.

Caso práctico: Gestor de configuración de la aplicación

Ejemplo

class AppConfig:
    _instance = None
   
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._initialized = False
        return cls._instance
   
    def __init__(self):
        # Evitar inicialización repetida
        if not self._initialized:
            self.config_data = {}
            self.load_config()
            self._initialized = True
   
    def load_config(self):
        """Simula cargar la configuración desde un archivo de configuración"""
        self.config_data = {
            "database": {
                "host": "localhost",
                "port": 5432,
                "name": "myapp_db"
            },
            "server": {
                "host": "0.0.0.0",
                "port": 8000
            },
            "features": {
                "cache_enabled": True,
                "debug_mode": False
            }
        }
        print("Configuración cargada")
   
    def get(self, key_path, default=None):
        """Obtiene el valor de configuración mediante una ruta, como 'database.host'"""
        keys = key_path.split('.')
        value = self.config_data
       
        try:
            for key in keys:
                value = value[key]
            return value
        except (KeyError, TypeError):
            return default
   
    def set(self, key_path, value):
        """Establece el valor de configuración"""
        keys = key_path.split('.')
        config = self.config_data
       
        # Recorrer hasta la clave anterior a la última
        for key in keys[:-1]:
            if key not in config:
                config[key] = {}
            config = config[key]
       
        # Establecer el valor final
        config[keys[-1]] = value
        print(f"Configuración actualizada: {key_path} = {value}")

# Ejemplo de uso
def demonstrate_config_usage():
    print("\n"=== Demostración del gestor de configuración ===")
   
    # Obtener instancias del gestor de configuración en diferentes lugares
    config1 = AppConfig()
    config2 = AppConfig()
   
    print(f"¿Es el mismo gestor de configuración? {config1 is config2}")
   
    # Leer configuración
    db_host = config1.get("database.host")
    server_port = config1.get("server.port")
    print(f"Host de base de datos: {db_host}")
    print(f"Puerto del servidor: {server_port}")
   
    # Actualizar configuración
    config2.set("database.host", "192.168.1.100")
    config2.set("features.debug_mode", True)
   
    # Verificar la sincronización de la configuración
    print(f"Host de base de datos de config1: {config1.get('database.host')}")
    print(f"Modo de depuración de config1: {config1.get('features.debug_mode')}")

# Ejecutar demostración
demonstrate_config_usage()

Consideraciones sobre el patrón Singleton

Ventajas

  1. Ahorro de recursos: evita crear objetos repetidamente, ahorra memoria y recursos del sistema
  2. Consistencia de datos: todos los clientes usan la misma instancia, lo que garantiza la coherencia de los datos
  3. Acceso global: proporciona un punto de acceso unificado, fácil de gestionar

Desventajas

  1. Estado global: el objeto Singleton mantiene un estado global, lo que puede causar efectos secundarios inesperados
  2. Dificultad de pruebas: debido al estado global, las pruebas unitarias pueden volverse complejas
  3. Violación del principio de responsabilidad única: la clase Singleton debe gestionar su propia lógica de negocio y también controlar la instanciación

Mejores prácticas

Ejemplo

class ThreadSafeSingleton:
    """Patrón Singleton seguro para subprocesos"""
    _instance = None
    _lock = threading.Lock()
   
    def __new__(cls):
        if cls._instance is None:
            with cls._lock:
                # Bloqueo de doble verificación
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    print("Creando instancia Singleton segura para subprocesos")
        return cls._instance
   
    def __init__(self):
        # Asegurar que se inicialice solo una vez
        if not hasattr(self, '_initialized'):
            self.data = {}
            self._initialized = True

Ejercicios y reflexión

Ejercicios prácticos

  1. Implementar un gestor de caché:

    • Crear una clase de caché Singleton
    • Soportar establecer, obtener y eliminar elementos de caché
    • Agregar función de tiempo de expiración de caché
  2. Mejorar el gestor de configuración:

    • Agregar funcionalidad para cargar configuración desde un archivo JSON
    • Implementar un listener de cambios de configuración
    • Agregar mecanismo de validación de configuración

Preguntas de reflexión

  1. ¿En qué situaciones se debe evitar el patrón Singleton?
  2. ¿Cómo afecta el patrón Singleton a la capacidad de prueba del código?
  3. ¿Qué se debe tener en cuenta al utilizar el patrón singleton en entornos multihilo?

Resumen

El patrón singleton es un patrón de diseño muy importante en Python. Al garantizar que una clase tenga una única instancia, proporciona una gestión unificada de los recursos. A través de módulos,__new__métodos, decoradores y metaclases, entre otras formas de implementación, podemos elegir el método más adecuado según las necesidades específicas.

Recuerda que, aunque el patrón singleton es muy práctico, también debe usarse con precaución para evitar el acoplamiento de código y las dificultades de prueba que causa el uso excesivo. En proyectos reales, un uso razonable del patrón singleton puede mejorar en gran medida la calidad y el mantenimiento del código.

Otras extensiones