Le patron Singleton garantit qu’une classe n’a qu’une seule instance pendant toute la durée de vie d’une application et fournit un point d’accès global à cette instance. Ce guide couvre plusieurs approches d’implémentation en C# .NET, de la plus simple à la plus robuste, avec des conseils sur quand les singletons sont appropriés et quand vous devriez chercher des alternatives.

Quand utiliser un Singleton

Les singletons sont utiles lorsque :

  • Vous avez besoin d’exactement une instance d’une classe pour coordonner les actions dans tout le système (par exemple, un gestionnaire de configuration, un service de journalisation ou un pool de connexions).
  • Créer plusieurs instances causerait des conflits ou gaspillerait des ressources.
  • L’instance doit être accessible globalement sans la passer à chaque appel de méthode.

Implémentation 1 : Basique (non thread-safe)

C’est l’implémentation la plus simple, mais elle n’est pas sûre pour les applications multithread :

public sealed class Singleton
{
    private static Singleton _instance;

    private Singleton() { }

    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new Singleton();
            }
            return _instance;
        }
    }
}

Problème : Si deux threads vérifient _instance == null en même temps, les deux créeront une instance, violant la garantie du singleton.

Implémentation 2 : Thread-safe avec Lock

L’ajout d’une instruction lock empêche plusieurs threads de créer l’instance simultanément :

public sealed class Singleton
{
    private static Singleton _instance;
    private static readonly object _lock = new object();

    private Singleton() { }

    public static Singleton Instance
    {
        get
        {
            lock (_lock)
            {
                if (_instance == null)
                {
                    _instance = new Singleton();
                }
                return _instance;
            }
        }
    }
}

C’est thread-safe, mais chaque appel à Instance acquiert le verrou, ce qui introduit un coût de performance même après la création de l’instance.

Implémentation 3 : Double-check locking

Le double-check locking réduit la surcharge en n’acquérant le verrou que lorsque l’instance n’a pas encore été créée :

public sealed class Singleton
{
    private static volatile Singleton _instance;
    private static readonly object _lock = new object();

    private Singleton() { }

    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                lock (_lock)
                {
                    if (_instance == null)
                    {
                        _instance = new Singleton();
                    }
                }
            }
            return _instance;
        }
    }
}

Le mot-clé volatile garantit que l’affectation à _instance est terminée avant que la variable ne soit accédée par d’autres threads. La vérification null externe évite le verrou une fois que l’instance existe. La vérification null interne gère la condition de concurrence entre deux threads qui ont passé la vérification externe.

Implémentation 4 : Initialisation statique (eager)

Le runtime .NET garantit qu’un constructeur statique s’exécute exactement une fois par domaine d’application, rendant cette approche intrinsèquement thread-safe :

public sealed class Singleton
{
    private static readonly Singleton _instance = new Singleton();

    // Explicit static constructor tells the runtime
    // not to mark the type with beforefieldinit
    static Singleton() { }

    private Singleton() { }

    public static Singleton Instance => _instance;
}

L’instance est créée lors de la première référence à la classe. Le constructeur statique explicite empêche le runtime d’utiliser l’optimisation beforefieldinit, qui pourrait provoquer la création de l’instance plus tôt que prévu.

Compromis : L’instance est créée dès qu’un membre statique est accédé, même si Instance n’est jamais appelé. Pour la plupart des applications, ce n’est pas un problème.

Implémentation 5 : Lazy (recommandée)

La classe Lazy<T>, introduite dans .NET 4, fournit l’initialisation paresseuse thread-safe la plus propre :

public sealed class Singleton
{
    private static readonly Lazy<Singleton> _lazy =
        new Lazy<Singleton>(() => new Singleton());

    private Singleton() { }

    public static Singleton Instance => _lazy.Value;
}

Lazy<T> gère toute la synchronisation des threads en interne. L’instance n’est pas créée avant le premier accès à Instance. C’est l’approche recommandée pour la plupart des applications C# car elle est concise, correcte et facile à comprendre.

Vous pouvez également contrôler le mode de sécurité des threads :

// Default: thread-safe (LazyThreadSafetyMode.ExecutionAndPublication)
new Lazy<Singleton>(() => new Singleton());

// Not thread-safe (use only if single-threaded)
new Lazy<Singleton>(() => new Singleton(), isThreadSafe: false);

// Thread-safe, but each thread may create its own instance; only one is kept
new Lazy<Singleton>(() => new Singleton(),
    LazyThreadSafetyMode.PublicationOnly);

Rendre la classe Singleton scellée

Tous les exemples ci-dessus utilisent le mot-clé sealed. Cela empêche les sous-classes de créer des instances supplémentaires par héritage. Sans sealed, une classe dérivée pourrait exposer son propre constructeur et briser la garantie d’instance unique.

Ajouter des fonctionnalités

Un singleton peut contenir un état et fournir des méthodes comme n’importe quelle autre classe :

public sealed class AppSettings
{
    private static readonly Lazy<AppSettings> _lazy =
        new Lazy<AppSettings>(() => new AppSettings());

    public static AppSettings Instance => _lazy.Value;

    public string ConnectionString { get; private set; }
    public int MaxRetries { get; private set; }

    private AppSettings()
    {
        // Load settings from configuration file
        ConnectionString = "Server=.;Database=MyApp;Trusted_Connection=true;";
        MaxRetries = 3;
    }

    public void Reload()
    {
        // Re-read settings from configuration file
    }
}

Utilisation :

var connStr = AppSettings.Instance.ConnectionString;

Pourquoi vous devriez considérer les alternatives

Bien que les singletons résolvent des problèmes réels, ils présentent des inconvénients bien connus :

  • Dépendances cachées. Le code qui appelle Singleton.Instance a une dépendance implicite qui n’est pas visible dans la signature de son constructeur.
  • Difficile à tester. Vous ne pouvez pas facilement substituer un mock ou une fausse implémentation dans les tests unitaires.
  • État global mutable. Si le singleton contient un état mutable, il peut introduire des problèmes difficiles à déboguer dans les applications concurrentes.
  • Gestion de la durée de vie. Le singleton vit pendant toute la durée de vie de l’application, ce qui peut ne pas être approprié pour toutes les ressources.

Alternative préférée : Injection de dépendances

Les applications .NET modernes utilisent généralement un conteneur d’injection de dépendances (DI) pour gérer les durées de vie des objets. Vous pouvez enregistrer n’importe quelle classe avec une durée de vie singleton :

// In Program.cs or Startup.cs
builder.Services.AddSingleton<IAppSettings, AppSettings>();

Puis injectez-la via le constructeur :

public class OrderService
{
    private readonly IAppSettings _settings;

    public OrderService(IAppSettings settings)
    {
        _settings = settings;
    }
}

Cette approche vous donne le même comportement d’instance unique tout en gardant les dépendances explicites, en supportant la conception basée sur les interfaces et en permettant des tests unitaires faciles avec des mocks.

Tableau comparatif

ApprocheThread-safeParesseuxComplexitéRecommandé
Basique (sans lock)NonOuiFaibleNon
Lock simpleOuiOuiFaiblePour les cas simples
Double-check lockingOuiOuiMoyenneCode existant
Initialisation statiqueOuiNonFaibleQuand le chargement anticipé est acceptable
Lazy<T>OuiOuiFaibleOui
Conteneur DIOuiConfigurableFaibleLe meilleur pour les apps modernes

Résumé

Pour la plupart des applications C# .NET, l’approche Lazy<T> est la meilleure implémentation de singleton autonome : elle est thread-safe, paresseuse et facile à lire. Cependant, dans les applications modernes utilisant l’injection de dépendances, préférez enregistrer votre service avec AddSingleton<T>() dans le conteneur DI. Cela vous donne la même garantie d’instance unique avec les avantages supplémentaires de dépendances explicites, de testabilité et d’une architecture plus propre.