Patron Singleton en Python

Le singleton est un patron de conception logicielle couramment utilisé qui garantit qu’une classe ne possède qu’une seule instance et fournit un point d’accès global pour obtenir cette instance.

Concepts fondamentaux

Imaginez le poste de PDG dans une entreprise - l’entreprise entière ne peut avoir qu’un seul PDG. Peu importe le service qui doit rendre des comptes au PDG, tous accèdent au même objet PDG. Le patron singleton est le « mécanisme de gestion du poste de PDG » dans le monde de la programmation.

Pourquoi avons-nous besoin du patron singleton ?

Dans le développement réel, pour certains objets, nous n’avons besoin que d’une seule instance, par exemple :

  • Gestionnaire de configuration: L’application entière partage le même ensemble de configurations
  • Pool de connexions à la base de données: Éviter de créer des connexions de manière répétée et économiser des ressources
  • Journaliseur: Garantir que tous les journaux sont écrits dans le même fichier
  • Système de cache: Gérer les données mises en cache de manière uniforme

Façons de mettre en œuvre le patron singleton

Python propose plusieurs façons de mettre en œuvre le patron singleton. Apprenons-les une par une, du plus simple au plus complexe.

Méthode 1 : Utilisation d’un module (la façon la plus simple)

Les modules Python sont eux-mêmes naturellement des singletons. Lorsqu’un module est importé, il est initialisé une fois, et les importations suivantes utiliseront la même instance.

Exemple

# singleton_module.py
class DatabaseConnection:
    def __init__(self):
        self.connection_string = "database://localhost:5432/mydb"
        print("Connexion à la base de données créée")
   
    def query(self, sql):
        return f"Exécution de la requête : {sql}"

# Créer une instance singleton
db_instance = DatabaseConnection()

# Utilisez-le dans d'autres fichiers :
# from singleton_module import db_instance
# result = db_instance.query("SELECT * FROM users")

Méthode 2 : En utilisant__new__la méthode __new__

En redéfinissant__new__la méthode __new__ pour contrôler le processus d'instanciation.

Exemple

class Singleton:
    _instance = None
   
    def __new__(cls, *args, **kwargs):
        # Si l'instance n'existe pas, en créer une nouvelle
        if not cls._instance:
            cls._instance = super().__new__(cls)
            print("Création d'une nouvelle instance singleton")
        else:
            print("Retour de l'instance singleton existante")
        return cls._instance
   
    def __init__(self, name):
        # Remarque : __init__ est appelé à chaque fois
        self.name = name
        print(f"Initialisation de l'instance, nom : {name}")

# Code de test
print("=== Test du patron Singleton ===")
s1 = Singleton("Première instance")
s2 = Singleton("Deuxième instance")

print(f"ID de s1 : {id(s1)}")
print(f"ID de s2 : {id(s2)}")
print(f"s1 et s2 sont-ils le même objet ? {s1 is s2}")
print(f"nom de s1 : {s1.name}")
print(f"nom de s2 : {s2.name}")  # Remarque : "Deuxième instance" sera affichée ici

Résultat de sortie :

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

Méthode 3 : Utilisation d'un décorateur

Créez un décorateur singleton générique qui peut facilement convertir n'importe quelle classe en singleton.

Exemple

def singleton(cls):
    """Décorateur singleton"""
    instances = {}
   
    def get_instance(*args, **kwargs):
        # Si cette classe n'a pas encore d'instance, créez-en une nouvelle
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
            print(f"Création d'une nouvelle instance de {cls.__name__}")
        else:
            print(f"Retour de l'instance existante {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": "Mon application",
            "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

# Code de test
print("\n"=== Test du Singleton décorateur ===")
config1 = ConfigurationManager()
config2 = ConfigurationManager()

config1.set_setting("theme", "dark")
print(f"thème de config1 : {config1.get_setting('theme')}")
print(f"thème de config2 : {config2.get_setting('theme')}")  # Les deux instances partagent la même configuration

Méthode 4 : Utilisation d'une métaclasse (utilisation avancée)

Une métaclasse peut contrôler le processus de création des classes, ce qui est une autre manière puissante d'implémenter le patron singleton.

Exemple

class SingletonMeta(type):
    """Métaclasse Singleton"""
    _instances = {}
   
    def __call__(cls, *args, **kwargs):
        # Si cette classe n'a pas encore d'instance, créez-en une nouvelle
        if cls not in cls._instances:
            instance = super().__call__(*args, **kwargs)
            cls._instances[cls] = instance
            print(f"Métaclasse : création d'une nouvelle instance de {cls.__name__}")
        else:
            print(f"Métaclasse : retour de l'instance existante 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 initialisé, fichier : {log_file}")
   
    def log(self, message):
        log_entry = f"[{self.get_timestamp()}] {message}"
        self.logs.append(log_entry)
        print(f"Enregistrement de l'entrée : {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()

# Code de test
print("\n"=== Test du singleton de métaclasse ===")
logger1 = Logger("application.log")
logger2 = Logger("different.log")  # Ce nom de fichier sera ignoré

logger1.log("Démarrage du système")
logger2.log("Connexion de l'utilisateur")

print(f"nombre de journaux de logger1 : {len(logger1.get_logs())}")
print(f"nombre de journaux de logger2 : {len(logger2.get_logs())}")
print(f"S'agit-il du même logger ? {logger1 is logger2}")

Scénarios d'application du patron Singleton

Voyons l'application du patron singleton dans des projets réels à travers un exemple complet.

Cas pratique : Gestionnaire de configuration d'application

Exemple

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):
        # Empêcher l'initialisation répétée
        if not self._initialized:
            self.config_data = {}
            self.load_config()
            self._initialized = True
   
    def load_config(self):
        """Simuler le chargement de la configuration à partir d'un fichier de configuration"""
        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("Configuration chargée")
   
    def get(self, key_path, default=None):
        """Obtenir la valeur de configuration par chemin, par exemple '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):
        """Définir la valeur de configuration"""
        keys = key_path.split('.')
        config = self.config_data
       
        # Parcourir jusqu'à la clé avant la dernière
        for key in keys[:-1]:
            if key not in config:
                config[key] = {}
            config = config[key]
       
        # Définir la valeur finale
        config[keys[-1]] = value
        print(f"Configuration mise à jour : {key_path} = {value}")

# Exemple d'utilisation
def demonstrate_config_usage():
    print("\n"=== Démo d'utilisation du gestionnaire de configuration ===")
   
    # Obtenir l'instance du gestionnaire de configuration à différents endroits
    config1 = AppConfig()
    config2 = AppConfig()
   
    print(f"S'agit-il du même gestionnaire de configuration ? {config1 is config2}")
   
    # Lire la configuration
    db_host = config1.get("database.host")
    server_port = config1.get("server.port")
    print(f"Hôte de la base de données : {db_host}")
    print(f"Port du serveur : {server_port}")
   
    # Mettre à jour la configuration
    config2.set("database.host", "192.168.1.100")
    config2.set("features.debug_mode", True)
   
    # Vérifier la synchronisation de la configuration
    print(f"Hôte de la base de données de config1 : {config1.get('database.host')}")
    print(f"mode de débogage de config1 : {config1.get('features.debug_mode')}")

# Exécuter la démo
demonstrate_config_usage()

Considérations sur le patron Singleton

Avantages

  1. Économie de ressources: Éviter de créer des objets de manière répétée, économisant la mémoire et les ressources système
  2. Cohérence des données: Tous les clients utilisent la même instance, garantissant la cohérence des données
  3. Accès global: Fournir un point d'accès unifié pour faciliter la gestion

Inconvénients

  1. État global: L'objet singleton détient un état global, ce qui peut causer des effets de bord inattendus
  2. Tests difficiles: En raison de l'état global, les tests unitaires peuvent devenir compliqués
  3. Viole le principe de responsabilité unique: La classe singleton doit non seulement gérer sa propre logique métier, mais aussi contrôler l'instanciation

Bonnes pratiques

Exemple

class ThreadSafeSingleton:
    """Patron singleton thread-safe"""
    _instance = None
    _lock = threading.Lock()
   
    def __new__(cls):
        if cls._instance is None:
            with cls._lock:
                # Verrouillage à double vérification
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    print("Création d'une instance singleton sûre pour les threads")
        return cls._instance
   
    def __init__(self):
        # S'assurer qu'il n'est initialisé qu'une seule fois
        if not hasattr(self, '_initialized'):
            self.data = {}
            self._initialized = True

Exercices et réflexion

Exercices pratiques

  1. Implémenter un gestionnaire de cache:

    • Créer une classe de cache singleton
    • Prendre en charge la définition, la récupération et la suppression des éléments de cache
    • Ajouter une fonctionnalité de délai d'expiration du cache
  2. Améliorer le gestionnaire de configuration:

    • Ajouter une fonctionnalité pour charger la configuration depuis un fichier JSON
    • Implémenter un écouteur de changement de configuration
    • Ajouter un mécanisme de validation de configuration

Questions de réflexion

  1. Dans quelles situations faut-il éviter le patron singleton ?
  2. Comment le patron singleton affecte-t-il la testabilité du code ?
  3. Que faut-il prendre en compte lorsqu'on utilise le patron singleton dans un environnement multithread ?

Résumé

Le patron singleton est un patron de conception très important en Python. En garantissant qu'une classe n'a qu'une seule instance, il fournit une gestion unifiée des ressources. Par le biais des modules,__new__méthodes, décorateurs, métaclasses et autres approches d'implémentation, nous pouvons choisir la méthode la plus adaptée selon des besoins spécifiques.

Rappelez-vous que, bien que le patron singleton soit très pratique, il convient de l'utiliser avec prudence pour éviter le couplage du code et les difficultés de test causées par une utilisation excessive. Dans des projets réels, l'utilisation appropriée du patron singleton peut grandement améliorer la qualité et la maintenabilité du code.

Autres extensions