El patrón Singleton asegura que una clase tenga exactamente una instancia durante toda la vida de una aplicación y proporciona un punto de acceso global a esa instancia. Esta guía cubre múltiples enfoques de implementación en C# .NET, desde el más simple hasta el más robusto, junto con orientación sobre cuándo los singletons son apropiados y cuándo debería buscar alternativas.
Cuándo usar un Singleton
Los singletons son útiles cuando:
- Necesita exactamente una instancia de una clase para coordinar acciones en todo el sistema (por ejemplo, un administrador de configuración, servicio de registro o pool de conexiones).
- Crear múltiples instancias causaría conflictos o desperdiciaría recursos.
- La instancia debe ser accesible globalmente sin pasarla a través de cada llamada de método.
Implementación 1: Básica (no segura para hilos)
Esta es la implementación más simple, pero no es segura para aplicaciones multihilo:
public sealed class Singleton
{
private static Singleton _instance;
private Singleton() { }
public static Singleton Instance
{
get
{
if (_instance == null)
{
_instance = new Singleton();
}
return _instance;
}
}
}
Problema: Si dos hilos verifican _instance == null al mismo tiempo, ambos crearán una instancia, violando la garantía del singleton.
Implementación 2: Segura para hilos con Lock
Agregar una sentencia lock evita que múltiples hilos creen la instancia simultáneamente:
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;
}
}
}
}
Esto es seguro para hilos, pero cada llamada a Instance adquiere el bloqueo, lo que introduce un costo de rendimiento incluso después de que la instancia ha sido creada.
Implementación 3: Doble verificación de bloqueo
La doble verificación de bloqueo reduce la sobrecarga adquiriendo el bloqueo solo cuando la instancia aún no ha sido creada:
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;
}
}
}
La palabra clave volatile asegura que la asignación a _instance se complete antes de que la variable sea accedida por otros hilos. La verificación null externa evita el bloqueo una vez que la instancia existe. La verificación null interna maneja la condición de carrera entre dos hilos que pasaron la verificación externa.
Implementación 4: Inicialización estática (eager)
El runtime de .NET garantiza que un constructor estático se ejecuta exactamente una vez por dominio de aplicación, haciendo este enfoque inherentemente seguro para hilos:
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;
}
La instancia se crea cuando la clase es referenciada por primera vez. El constructor estático explícito evita que el runtime use la optimización beforefieldinit, que podría causar que la instancia se cree antes de lo esperado.
Compromiso: La instancia se crea tan pronto como se accede a cualquier miembro estático, incluso si nunca se llama a Instance. Para la mayoría de las aplicaciones, esto no es un problema.
Implementación 5: Lazy (recomendada)
La clase Lazy<T>, introducida en .NET 4, proporciona la inicialización perezosa más limpia y segura para hilos:
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> maneja toda la sincronización de hilos internamente. La instancia no se crea hasta la primera vez que se accede a Instance. Este es el enfoque recomendado para la mayoría de las aplicaciones C# porque es conciso, correcto y fácil de entender.
También puede controlar el modo de seguridad de hilos:
// 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);
Hacer la clase Singleton sellada
Todos los ejemplos anteriores usan la palabra clave sealed. Esto evita que las subclases creen instancias adicionales a través de la herencia. Sin sealed, una clase derivada podría exponer su propio constructor y romper la garantía de instancia única.
Agregar funcionalidad
Un singleton puede mantener estado y proporcionar métodos como cualquier otra clase:
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
}
}
Uso:
var connStr = AppSettings.Instance.ConnectionString;
Por qué debería considerar alternativas
Aunque los singletons resuelven problemas reales, tienen desventajas bien conocidas:
- Dependencias ocultas. El código que llama a
Singleton.Instancetiene una dependencia implícita que no es visible en la firma de su constructor. - Difícil de probar. No puede sustituir fácilmente un mock o implementación falsa en pruebas unitarias.
- Estado global mutable. Si el singleton mantiene estado mutable, puede introducir problemas difíciles de depurar en aplicaciones concurrentes.
- Gestión del tiempo de vida. El singleton vive durante toda la vida de la aplicación, lo cual puede no ser apropiado para todos los recursos.
Alternativa preferida: Inyección de dependencias
Las aplicaciones .NET modernas típicamente usan un contenedor de inyección de dependencias (DI) para gestionar los tiempos de vida de los objetos. Puede registrar cualquier clase con un tiempo de vida singleton:
// In Program.cs or Startup.cs
builder.Services.AddSingleton<IAppSettings, AppSettings>();
Luego inyéctela a través del constructor:
public class OrderService
{
private readonly IAppSettings _settings;
public OrderService(IAppSettings settings)
{
_settings = settings;
}
}
Este enfoque le da el mismo comportamiento de instancia única mientras mantiene las dependencias explícitas, soporta diseño basado en interfaces y permite pruebas unitarias fáciles con mocks.
Resumen comparativo
| Enfoque | Seguro para hilos | Perezoso | Complejidad | Recomendado |
|---|---|---|---|---|
| Básico (sin lock) | No | Sí | Baja | No |
| Lock simple | Sí | Sí | Baja | Para casos simples |
| Doble verificación | Sí | Sí | Media | Código legado |
| Inicialización estática | Sí | No | Baja | Cuando la carga temprana está bien |
| Lazy<T> | Sí | Sí | Baja | Sí |
| Contenedor DI | Sí | Configurable | Baja | Lo mejor para apps modernas |
Resumen
Para la mayoría de las aplicaciones C# .NET, el enfoque Lazy<T> es la mejor implementación de singleton independiente: es seguro para hilos, perezoso y fácil de leer. Sin embargo, en aplicaciones modernas que usan inyección de dependencias, prefiera registrar su servicio con AddSingleton<T>() en el contenedor DI. Esto le da la misma garantía de instancia única con los beneficios adicionales de dependencias explícitas, capacidad de prueba y arquitectura más limpia.